Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

634 lines
19 KiB
Go

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)
}