package support import ( "context" "encoding/json" "errors" "log/slog" "math" "regexp" "strings" "time" "unicode" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) var placeholderRe = regexp.MustCompile(`\{\{\s*([a-z0-9_]+)\s*\}\}`) // RedactSecretsForMatch strips secret material before matching / logging. // Delegates to security.RedactSecrets (shared with AI prompt path). func RedactSecretsForMatch(s string) string { return security.RedactSecrets(s) } func tokenizeMatchText(s string) map[string]struct{} { s = strings.ToLower(s) tokens := make(map[string]struct{}) var b strings.Builder flush := func() { if b.Len() == 0 { return } tok := b.String() b.Reset() if len(tok) < 2 { return } tokens[tok] = struct{}{} } for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(r) continue } flush() } flush() return tokens } func scoreCorpusItem(haystack string, tokens map[string]struct{}, category string, keywords, intents, cats []string, weight int) float64 { if len(keywords) == 0 && len(intents) == 0 && len(cats) == 0 { return 0 } score := 0.0 if len(keywords) > 0 { hits := 0 for _, kw := range keywords { kw = strings.ToLower(strings.TrimSpace(kw)) if kw == "" { continue } if _, ok := tokens[kw]; ok || strings.Contains(haystack, kw) { hits++ } } score += 0.70 * (float64(hits) / float64(len(keywords))) } if len(intents) > 0 { hits := 0 for _, intent := range intents { key := strings.ToLower(strings.TrimSpace(intent)) if key == "" { continue } phrase := strings.ReplaceAll(key, "_", " ") parts := strings.Fields(phrase) ok := true if len(parts) == 0 { ok = false } for _, p := range parts { if _, has := tokens[p]; !has && !strings.Contains(haystack, p) { ok = false break } } if ok { hits++ } } score += 0.15 * (float64(hits) / float64(len(intents))) } if category != "" && len(cats) > 0 { for _, c := range cats { if strings.EqualFold(c, category) { score += 0.15 break } } } if weight > 0 { score += math.Min(0.05, float64(weight)*0.005) } if score > 1 { score = 1 } return score } func applyTemplatePlaceholders(body string, ticket Ticket) string { return placeholderRe.ReplaceAllStringFunc(body, func(m string) string { sub := placeholderRe.FindStringSubmatch(m) if len(sub) < 2 { return m } key := strings.ToLower(sub[1]) if _, ok := templatePlaceholderAllowlist[key]; !ok { return m } switch key { case "subject": return ticket.Subject case "category": return ticket.Category default: return m } }) } func labelAutoBody(body string) string { body = strings.TrimSpace(body) if strings.Contains(body, "Automated answer from help center") { return body } return body + autoReplyFooter } // MatchAutoReply scores published KB articles and active templates against a ticket. // It never posts a message and never calls an LLM. func (s *Service) MatchAutoReply(ctx context.Context, ticket Ticket) (MatchAutoReplyResult, error) { empty := MatchAutoReplyResult{Matched: false, Kind: MatchKindNone} if s == nil || s.Pool == nil { return empty, nil } arts, tmps, err := s.loadMatchCorpus(ctx) if err != nil { if IsMissingRelation(err) { return empty, nil } return empty, err } subject := RedactSecretsForMatch(ticket.Subject) body := "" if len(ticket.Messages) > 0 { // Prefer latest customer message; fall back to first. for i := len(ticket.Messages) - 1; i >= 0; i-- { if ticket.Messages[i].AuthorRole == "user" && !ticket.Messages[i].IsInternalNote { body = ticket.Messages[i].Body break } } if body == "" { body = ticket.Messages[0].Body } } body = RedactSecretsForMatch(body) haystack := strings.ToLower(strings.TrimSpace(subject + " " + body)) tokens := tokenizeMatchText(haystack) best := empty bestWeight := -1 for _, a := range arts { conf := scoreCorpusItem(haystack, tokens, ticket.Category, a.Keywords, a.IntentKeys, a.CategorySlugs, a.PriorityWeight) if conf > best.Confidence || (conf == best.Confidence && a.PriorityWeight > bestWeight) { id := a.ID best = MatchAutoReplyResult{ Matched: conf > 0, Confidence: conf, ReplyBody: strings.TrimSpace(a.BodyMD), ArticleID: &id, Kind: MatchKindKBArticle, Label: a.Title, } bestWeight = a.PriorityWeight } } for _, t := range tmps { conf := scoreCorpusItem(haystack, tokens, ticket.Category, t.Keywords, t.IntentKeys, t.CategorySlugs, t.PriorityWeight) if conf > best.Confidence || (conf == best.Confidence && t.PriorityWeight > bestWeight && best.Kind != MatchKindKBArticle) { id := t.ID rendered := applyTemplatePlaceholders(t.Body, ticket) best = MatchAutoReplyResult{ Matched: conf > 0, Confidence: conf, ReplyBody: strings.TrimSpace(rendered), TemplateID: &id, Kind: MatchKindTemplate, Label: t.Name, } bestWeight = t.PriorityWeight } } if best.Confidence <= 0 || best.ReplyBody == "" { return empty, nil } return best, nil } // GetAutoConfig loads the singleton FAQ + AI auto-reply config (defaults if missing). func (s *Service) GetAutoConfig(ctx context.Context) (AutoConfig, error) { cfg := AutoConfig{ Enabled: false, FAQEnabled: true, MatchConfidenceThreshold: 0.78, AIEnabled: false, AIConfidenceThreshold: 0.65, AIDelivery: AIDeliveryDraft, AIUseGlobalSupportRole: true, } err := s.Pool.QueryRow(ctx, ` SELECT enabled, faq_enabled, match_confidence_threshold, retry_on_first_customer_reply, ai_enabled, ai_confidence_threshold, ai_delivery, ai_use_global_support_role, ai_provider_override, ai_model_override, ai_base_url_override, updated_at FROM support_auto_config WHERE id = 1`).Scan( &cfg.Enabled, &cfg.FAQEnabled, &cfg.MatchConfidenceThreshold, &cfg.RetryOnFirstCustomerReply, &cfg.AIEnabled, &cfg.AIConfidenceThreshold, &cfg.AIDelivery, &cfg.AIUseGlobalSupportRole, &cfg.AIProviderOverride, &cfg.AIModelOverride, &cfg.AIBaseURLOverride, &cfg.UpdatedAt, ) if errors.Is(err, pgx.ErrNoRows) || IsMissingRelation(err) { return cfg, nil } if err != nil { // Pre-033 schema: fall back to FAQ-only columns. err2 := s.Pool.QueryRow(ctx, ` SELECT enabled, faq_enabled, match_confidence_threshold, retry_on_first_customer_reply, updated_at FROM support_auto_config WHERE id = 1`).Scan( &cfg.Enabled, &cfg.FAQEnabled, &cfg.MatchConfidenceThreshold, &cfg.RetryOnFirstCustomerReply, &cfg.UpdatedAt, ) if errors.Is(err2, pgx.ErrNoRows) || IsMissingRelation(err2) { return cfg, nil } if err2 != nil { return cfg, err } return cfg, nil } if cfg.AIDelivery == "" { cfg.AIDelivery = AIDeliveryDraft } return cfg, nil } // UpdateAutoConfig patches FAQ / AI auto-reply settings (platform admin). func (s *Service) UpdateAutoConfig(ctx context.Context, in AutoConfigInput) (AutoConfig, error) { cur, err := s.GetAutoConfig(ctx) if err != nil { return AutoConfig{}, err } if in.Enabled != nil { cur.Enabled = *in.Enabled } if in.FAQEnabled != nil { cur.FAQEnabled = *in.FAQEnabled } if in.MatchConfidenceThreshold != nil { t := *in.MatchConfidenceThreshold if t < 0.50 || t > 0.95 { return AutoConfig{}, ErrInvalidMatchThreshold } cur.MatchConfidenceThreshold = t } if in.RetryOnFirstCustomerReply != nil { cur.RetryOnFirstCustomerReply = *in.RetryOnFirstCustomerReply } if in.AIEnabled != nil { cur.AIEnabled = *in.AIEnabled } if in.AIConfidenceThreshold != nil { t := *in.AIConfidenceThreshold if t < 0.50 || t > 0.95 { return AutoConfig{}, ErrInvalidAIThreshold } cur.AIConfidenceThreshold = t } if in.AIDelivery != nil { d := strings.TrimSpace(*in.AIDelivery) if d != AIDeliveryDraft && d != AIDeliveryAutoSend { return AutoConfig{}, ErrInvalidAIDelivery } cur.AIDelivery = d } if in.AIUseGlobalSupportRole != nil { cur.AIUseGlobalSupportRole = *in.AIUseGlobalSupportRole } if in.AIProviderOverride != nil { cur.AIProviderOverride = strings.TrimSpace(*in.AIProviderOverride) } if in.AIModelOverride != nil { cur.AIModelOverride = strings.TrimSpace(*in.AIModelOverride) } if in.AIBaseURLOverride != nil { cur.AIBaseURLOverride = strings.TrimSpace(*in.AIBaseURLOverride) } if cur.AIDelivery == "" { cur.AIDelivery = AIDeliveryDraft } _, err = s.Pool.Exec(ctx, ` INSERT INTO support_auto_config ( id, enabled, faq_enabled, match_confidence_threshold, retry_on_first_customer_reply, ai_enabled, ai_confidence_threshold, ai_delivery, ai_use_global_support_role, ai_provider_override, ai_model_override, ai_base_url_override, updated_at ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now()) ON CONFLICT (id) DO UPDATE SET enabled = EXCLUDED.enabled, faq_enabled = EXCLUDED.faq_enabled, match_confidence_threshold = EXCLUDED.match_confidence_threshold, retry_on_first_customer_reply = EXCLUDED.retry_on_first_customer_reply, ai_enabled = EXCLUDED.ai_enabled, ai_confidence_threshold = EXCLUDED.ai_confidence_threshold, ai_delivery = EXCLUDED.ai_delivery, ai_use_global_support_role = EXCLUDED.ai_use_global_support_role, ai_provider_override = EXCLUDED.ai_provider_override, ai_model_override = EXCLUDED.ai_model_override, ai_base_url_override = EXCLUDED.ai_base_url_override, updated_at = now()`, cur.Enabled, cur.FAQEnabled, cur.MatchConfidenceThreshold, cur.RetryOnFirstCustomerReply, cur.AIEnabled, cur.AIConfidenceThreshold, cur.AIDelivery, cur.AIUseGlobalSupportRole, cur.AIProviderOverride, cur.AIModelOverride, cur.AIBaseURLOverride) if err != nil { return AutoConfig{}, err } return s.GetAutoConfig(ctx) } // PostMatchedAutoReply inserts a labeled system message when match exceeds threshold. // Idempotent: skips if auto already disabled/matched/sent or a public auto message exists. func (s *Service) PostMatchedAutoReply(ctx context.Context, ticketID uuid.UUID, match MatchAutoReplyResult, threshold float64) (Ticket, bool, error) { if !match.Matched || match.Confidence < threshold || strings.TrimSpace(match.ReplyBody) == "" { return Ticket{}, false, nil } body, err := normalizeBody(labelAutoBody(match.ReplyBody)) if err != nil { if isUniqueViolation(err) { return Ticket{}, false, nil } return Ticket{}, false, err } tx, err := s.Pool.Begin(ctx) if err != nil { if isUniqueViolation(err) { return Ticket{}, false, nil } return Ticket{}, false, err } defer func() { _ = tx.Rollback(ctx) }() var t Ticket var disabled bool var status string err = tx.QueryRow(ctx, ` SELECT id, company_id, created_by_user_id, status, COALESCE(auto_reply_disabled, false), COALESCE(auto_reply_status, 'none') FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID, ).Scan(&t.ID, &t.CompanyID, &t.CreatedByUserID, &t.Status, &disabled, &status) if errors.Is(err, pgx.ErrNoRows) { return Ticket{}, false, ErrNotFound } if err != nil { if isUniqueViolation(err) { return Ticket{}, false, nil } return Ticket{}, false, err } if disabled || t.Status == "closed" || t.Status == "resolved" { return Ticket{}, false, nil } switch status { case AutoReplyMatched, "ai_sent", AutoReplyHandedOff: return Ticket{}, false, nil } var prior int _ = tx.QueryRow(ctx, ` SELECT count(*) FROM support_messages WHERE ticket_id = $1 AND COALESCE(is_auto_reply, false) = true AND is_internal_note = false`, ticketID).Scan(&prior) if prior > 0 { return Ticket{}, false, nil } now := time.Now().UTC() source := AutoSourceKB refType := MatchKindKBArticle var refID *uuid.UUID if match.Kind == MatchKindTemplate { source = AutoSourceTemplate refType = MatchKindTemplate refID = match.TemplateID } else { refID = match.ArticleID } var msgID uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO support_messages ( ticket_id, company_id, author_user_id, author_role, body, is_internal_note, created_at, is_auto_reply, auto_source, auto_confidence, auto_ref_type, auto_ref_id ) VALUES ($1,$2,NULL,'system',$3,false,$4,true,$5,$6,$7,$8) RETURNING id`, ticketID, t.CompanyID, body, now, source, match.Confidence, refType, refID, ).Scan(&msgID) if err != nil { if isUniqueViolation(err) { return Ticket{}, false, nil } return Ticket{}, false, err } _, err = tx.Exec(ctx, ` UPDATE support_tickets SET status = CASE WHEN status = 'open' THEN 'pending' ELSE status END, last_message_at = $2, last_agent_message_at = $2, auto_reply_status = $3, auto_reply_attempted_at = $2, auto_reply_message_id = $4, updated_at = $2 WHERE id = $1`, ticketID, now, AutoReplyMatched, msgID) if err != nil { if isUniqueViolation(err) { return Ticket{}, false, nil } return Ticket{}, false, err } meta, _ := json.Marshal(map[string]any{ "source": source, "confidence": match.Confidence, "kind": match.Kind, "label": match.Label, }) if err := insertActivity(ctx, tx, ticketID, t.CompanyID, ActivityAutoReply, "system", nil, &msgID, meta); err != nil && !IsMissingRelation(err) { return Ticket{}, false, err } if err := insertNotification(ctx, tx, t.CreatedByUserID, ticketID, &msgID, "auto_reply"); err != nil { // Fallback if CHECK not migrated yet. if err2 := insertNotification(ctx, tx, t.CreatedByUserID, ticketID, &msgID, "agent_reply"); err2 != nil { return Ticket{}, false, err } } if err := tx.Commit(ctx); err != nil { return Ticket{}, false, err } out, err := s.GetForUser(ctx, t.CompanyID, t.CreatedByUserID, ticketID) if err != nil { out, err = s.GetAdmin(ctx, ticketID) } return out, true, err } // MaybeAutoReplyOnCreate runs Stage A FAQ match after ticket create (sync, no LLM). // On miss / low confidence, enqueues Stage B AI fallback (async; never awaits LLM). // Failures are soft: ticket create remains successful for the caller. func (s *Service) MaybeAutoReplyOnCreate(ctx context.Context, ticket Ticket) (Ticket, MatchAutoReplyResult, error) { empty := MatchAutoReplyResult{Kind: MatchKindNone} cfg, err := s.GetAutoConfig(ctx) if err != nil { return ticket, empty, err } if !cfg.Enabled { _ = s.markAutoAttempt(ctx, ticket.ID, AutoReplySkipped) return ticket, empty, nil } match := empty if cfg.FAQEnabled { match, err = s.MatchAutoReply(ctx, ticket) if err != nil { return ticket, empty, err } if match.Matched && match.Confidence >= cfg.MatchConfidenceThreshold { out, posted, postErr := s.PostMatchedAutoReply(ctx, ticket.ID, match, cfg.MatchConfidenceThreshold) if postErr != nil { return ticket, match, postErr } if posted { return out, match, nil } } } // Stage B — FAQ miss / disabled / below threshold. if cfg.AIEnabled { if enqErr := s.EnqueueAIFallback(ctx, ticket); enqErr != nil { slog.Warn("support_auto_ai_enqueue_failed", "ticket_id", ticket.ID.String(), "company_id", ticket.CompanyID.String(), "err", RedactForAutoLog(enqErr.Error()), ) } return ticket, match, nil } _ = s.markAutoAttempt(ctx, ticket.ID, AutoReplySkipped) return ticket, match, nil } // MaybeAutoReplyOnCustomerReply optionally retries FAQ match on first follow-up. func (s *Service) MaybeAutoReplyOnCustomerReply(ctx context.Context, ticket Ticket) (Ticket, MatchAutoReplyResult, error) { empty := MatchAutoReplyResult{Kind: MatchKindNone} cfg, err := s.GetAutoConfig(ctx) if err != nil { return ticket, empty, err } if !cfg.Enabled || !cfg.FAQEnabled || !cfg.RetryOnFirstCustomerReply { return ticket, empty, nil } // Handoff if a public auto reply already exists. var autoPublic int _ = s.Pool.QueryRow(ctx, ` SELECT count(*) FROM support_messages WHERE ticket_id = $1 AND COALESCE(is_auto_reply, false) = true AND is_internal_note = false`, ticket.ID).Scan(&autoPublic) if autoPublic > 0 { _ = s.markAutoHandOff(ctx, ticket.ID) return ticket, empty, nil } var disabled bool var status string _ = s.Pool.QueryRow(ctx, ` SELECT COALESCE(auto_reply_disabled, false), COALESCE(auto_reply_status, 'none') FROM support_tickets WHERE id = $1`, ticket.ID).Scan(&disabled, &status) if disabled || status == AutoReplyMatched || status == "ai_sent" { return ticket, empty, nil } return s.MaybeAutoReplyOnCreate(ctx, ticket) } func (s *Service) markAutoAttempt(ctx context.Context, ticketID uuid.UUID, status string) error { _, err := s.Pool.Exec(ctx, ` UPDATE support_tickets SET auto_reply_status = $2, auto_reply_attempted_at = now(), updated_at = now() WHERE id = $1 AND COALESCE(auto_reply_status, 'none') IN ('none', 'skipped')`, ticketID, status) return err } func (s *Service) markAutoHandOff(ctx context.Context, ticketID uuid.UUID) error { _, err := s.Pool.Exec(ctx, ` UPDATE support_tickets SET auto_reply_disabled = true, auto_reply_status = $2, auto_reply_attempted_at = now(), updated_at = now() WHERE id = $1`, ticketID, AutoReplyHandedOff) return err } // TopKBSnippetsForTicket returns the best lexical KB hits for AI fallback prompts // (even when below the auto-send threshold). Platform-global snippets only. func (s *Service) TopKBSnippetsForTicket(ctx context.Context, ticket Ticket, limit int) ([]KBSnippet, error) { if limit <= 0 { limit = 5 } if limit > 10 { limit = 10 } arts, _, err := s.loadMatchCorpus(ctx) if err != nil { if IsMissingRelation(err) { return nil, nil } return nil, err } subject := RedactSecretsForMatch(ticket.Subject) body := "" for i := len(ticket.Messages) - 1; i >= 0; i-- { if ticket.Messages[i].AuthorRole == "user" && !ticket.Messages[i].IsInternalNote { body = ticket.Messages[i].Body break } } body = RedactSecretsForMatch(body) haystack := strings.ToLower(strings.TrimSpace(subject + " " + body)) tokens := tokenizeMatchText(haystack) type scored struct { art KBArticle score float64 } ranked := make([]scored, 0, len(arts)) for _, a := range arts { conf := scoreCorpusItem(haystack, tokens, ticket.Category, a.Keywords, a.IntentKeys, a.CategorySlugs, a.PriorityWeight) if conf <= 0 { continue } ranked = append(ranked, scored{art: a, score: conf}) } for i := 0; i < len(ranked); i++ { for j := i + 1; j < len(ranked); j++ { if ranked[j].score > ranked[i].score || (ranked[j].score == ranked[i].score && ranked[j].art.PriorityWeight > ranked[i].art.PriorityWeight) { ranked[i], ranked[j] = ranked[j], ranked[i] } } } if len(ranked) > limit { ranked = ranked[:limit] } out := make([]KBSnippet, 0, len(ranked)) for _, r := range ranked { out = append(out, KBSnippet{ Slug: r.art.Slug, Title: r.art.Title, BodyMD: r.art.BodyMD, }) } return out, nil } // ScoreCorpusItemForTest exports scoring for unit tests. func ScoreCorpusItemForTest(haystack string, category string, keywords, intents, cats []string, weight int) float64 { return scoreCorpusItem(haystack, tokenizeMatchText(haystack), category, keywords, intents, cats, weight) }