Files
warmbly/internal/api/handler/template_analyze.go
T

146 lines
4.8 KiB
Go

// AI spam analysis for a campaign template. The rules-based score
// (POST /templates/score) says how safe the copy is; this says which word,
// which sentence, and whether it is in the subject or the body, and what to
// write instead. Flow matches every other metered AI endpoint: gate the org,
// reserve credits before the provider call, refund on a provider failure,
// settle real token usage after.
//
// The rules pass runs in the same request and comes back under "rules", so the
// two halves of the editor's panel are scored from one reading of the copy
// rather than from two drafts a keystroke apart.
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/credits"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/spamcheck"
"github.com/warmbly/warmbly/internal/pkg/warmlint"
)
// analyzeMaxLen bounds the template a single request may analyze. Well past any
// cold email; a body beyond it is a pasted newsletter, not outreach.
const analyzeMaxLen = 60000
type analyzeTemplateRequest struct {
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyPlain string `json:"body_plain"`
}
// AnalyzeTemplateContent — POST /templates/analyze
func (h *Handler) AnalyzeTemplateContent(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
var req analyzeTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
if len(req.Subject)+len(req.BodyHTML)+len(req.BodyPlain) > analyzeMaxLen {
errx.JSON(c, errx.New(errx.BadRequest, "this template is too long to analyze"))
return
}
if strings.TrimSpace(req.Subject) == "" && strings.TrimSpace(req.BodyPlain) == "" && strings.TrimSpace(req.BodyHTML) == "" {
errx.JSON(c, errx.New(errx.BadRequest, "there is nothing written to analyze yet"))
return
}
if h.AIProvider == nil {
// Identified, because "no provider here" is permanent for this
// deployment while a provider outage is not, and the editor hides the
// button only for the first.
errx.JSON(c, errx.NewWithIdentifier(errx.ServiceUnavailable, "ai_not_configured",
"AI analysis is not configured on this deployment."))
return
}
allowed, xerr := h.FeatureGateService.CanUseWritingAssistant(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
if !allowed {
errx.JSON(c, errx.New(errx.Forbidden, "AI spam analysis requires an active plan or trial."))
return
}
rules := warmlint.Score(req.Subject, req.BodyHTML, req.BodyPlain)
paid, _ := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), *orgID)
model := h.AIProvider.ModelForTier(paid)
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
local := h.AIProvider.IsLocal()
reqCtx := c.Request.Context()
{
meta := models.CreditMeta{Context: models.CreditContext{Detail: "spam analysis"}}
if actor, aerr := middleware.GetUserUUID(c); aerr == nil {
meta.ActorID = actor
}
reqCtx = models.WithCreditMeta(reqCtx, meta)
}
var remaining int
if local {
if bal, berr := h.CreditService.GetBalance(reqCtx, *orgID); berr == nil {
remaining = bal
}
} else {
var cerr error
remaining, cerr = h.CreditService.Consume(reqCtx, *orgID, credits.CostSpamAnalysis, "spam_analysis", model, 0, idemKey)
if cerr != nil {
mapCreditError(c, cerr)
return
}
}
analysis, aerr := spamcheck.Analyze(c.Request.Context(), h.AIProvider, model, spamcheck.Input{
Subject: req.Subject,
BodyHTML: req.BodyHTML,
BodyPlain: req.BodyPlain,
Rules: rules,
Voice: h.orgVoice(c.Request.Context(), *orgID, ""),
})
if aerr != nil {
if !local {
if bal, rerr := h.CreditService.Grant(reqCtx, *orgID, credits.CostSpamAnalysis, "spam_analysis_refund"); rerr == nil {
remaining = bal
}
}
errx.JSON(c, errx.New(errx.ServiceUnavailable, "The spam analyzer is temporarily unavailable. Your credits were not charged."))
return
}
charged := 0
if !local {
charged = credits.CostSpamAnalysis
if extra, serr := h.CreditService.SettleUsage(reqCtx, *orgID, credits.CostSpamAnalysis, analysis.Model, analysis.TokensUsed, "spam_analysis", settleKey(idemKey)); serr == nil && extra > 0 {
remaining -= extra
charged += extra
}
}
c.JSON(http.StatusOK, gin.H{
"score": analysis.Score,
"verdict": analysis.Verdict,
"findings": analysis.Findings,
"suggested_subject": analysis.SuggestedSubject,
"improvements": analysis.Improvements,
"rules": rules,
"model": analysis.Model,
"tokens_used": analysis.TokensUsed,
"credits_remaining": remaining,
"credits_charged": charged,
})
}