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.
This commit is contained in:
@@ -0,0 +1,633 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user