package support import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strconv" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/google/uuid" ) // CompleterSupportAI implements SupportAIRunner using the platform admin // ai_roles.support completer (aiprovider.RoleSupport). No parallel BYOK store. type CompleterSupportAI struct { // Resolve is optional; when nil, AI (aiprovider.Service) is used. Resolve SupportAIResolver } // SupportAIResolver resolves RoleSupport completers (aiprovider.Service). type SupportAIResolver interface { ResolveCompleterForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Completer, string, bool, error) } // NewCompleterSupportAI wraps an aiprovider.Service (or test mock). func NewCompleterSupportAI(r SupportAIResolver) *CompleterSupportAI { return &CompleterSupportAI{Resolve: r} } // RunAutoReply drafts or auto-sends an AI-assisted reply for a claimed ticket. func (r *CompleterSupportAI) RunAutoReply(ctx context.Context, svc *Service, claim AutoClaim) error { if r == nil || svc == nil || svc.Pool == nil { return ErrAIAutoReplyDisabled } cfg, err := svc.GetAutoConfig(ctx) if err != nil { return err } if !cfg.Enabled || !cfg.AIEnabled { _ = svc.markAutoAttempt(ctx, claim.TicketID, AutoReplySkipped) return ErrAIAutoReplyDisabled } ticket, err := svc.loadTicketForAI(ctx, claim.TicketID, claim.CompanyID) if err != nil { return err } if ticket.AutoReplyDisabled { return ErrAutoReplyDisabled } completer, err := r.resolveCompleter(ctx, claim.CompanyID, cfg) if err != nil { return svc.handoffAI(ctx, claim, "completer_error", err) } if completer == nil { return svc.handoffAI(ctx, claim, "completer_unset", ErrAIAutoReplyDisabled) } snippets := svc.kbSnippetsForAI(ctx, ticket) subject, body := ticketSubjectAndBody(ticket) system, user := BuildAutoReplyMessages(AutoPromptInput{ Subject: subject, Body: body, Category: ticket.Category, Tags: ticket.Tags, RelatedSKU: ticket.RelatedSKU, KBSnippets: snippets, TicketID: ticket.ID, CompanyID: ticket.CompanyID, }) comp, obj, err := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{ MaxTokens: 800, Temperature: 0.2, }) _ = comp if err != nil { return svc.handoffAI(ctx, claim, "llm_error", err) } result, err := parseAIAssistResult(obj) if err != nil { return svc.handoffAI(ctx, claim, "parse_error", err) } if result.Handoff || result.Confidence < cfg.AIConfidenceThreshold || strings.TrimSpace(result.Body) == "" { return svc.handoffAI(ctx, claim, "low_confidence_or_handoff", nil) } delivery := cfg.AIDelivery if delivery != AIDeliveryAutoSend { delivery = AIDeliveryDraft } internal := delivery == AIDeliveryDraft msgBody := strings.TrimSpace(result.Body) if internal { msgBody = aiDraftBodyPrefix + msgBody } else { msgBody = labelAIBody(msgBody) } msgID, err := svc.InsertAutoSystemMessage(ctx, claim, msgBody, AutoSourceAI, result.Confidence, "", nil, internal) if err != nil { if errors.Is(err, ErrAutoReplyAlreadyPosted) || errors.Is(err, ErrAutoReplyDisabled) { return err } return svc.handoffAI(ctx, claim, "post_error", err) } meta, _ := json.Marshal(map[string]any{ "source": AutoSourceAI, "confidence": result.Confidence, "delivery": delivery, "citations": result.Citations, "handoff": false, }) activity := ActivityAIDraft if !internal { activity = ActivityAISent if err := svc.notifyAutoReplyCustomer(ctx, ticket.CreatedByUserID, claim.TicketID, msgID); err != nil { slog.Warn("support_auto_ai_notify_failed", "ticket_id", claim.TicketID.String(), "company_id", claim.CompanyID.String(), "err", RedactForAutoLog(err.Error()), ) } } status := AutoReplyAISent if internal { status = AutoReplyAIDraft } _ = svc.RecordAutoReplyOutcome(ctx, claim.TicketID, status, false, &msgID, meta, activity) slog.Info("support_auto_ai_ok", "ticket_id", claim.TicketID.String(), "company_id", claim.CompanyID.String(), "delivery", delivery, "confidence", result.Confidence, ) return nil } func (r *CompleterSupportAI) resolveCompleter(ctx context.Context, companyID uuid.UUID, cfg AutoConfig) (processing.Completer, error) { resolver := r.Resolve if resolver == nil { return nil, nil } c, _, _, err := resolver.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleSupport) if err != nil { return nil, err } if c == nil { return nil, nil } // Optional model/base_url overrides — same API key, never a second secret store. if oc, ok := c.(*processing.OpenAIClient); ok && cfg.AIUseGlobalSupportRole { if m := strings.TrimSpace(cfg.AIModelOverride); m != "" { oc.Model = m } if u := strings.TrimSpace(cfg.AIBaseURLOverride); u != "" { oc.BaseURL = strings.TrimRight(u, "/") } } return c, nil } type aiAssistResult struct { Body string Confidence float64 Handoff bool Citations []string } func parseAIAssistResult(obj map[string]any) (aiAssistResult, error) { var out aiAssistResult if obj == nil { return out, fmt.Errorf("empty AI result") } if v, ok := obj["body"].(string); ok { out.Body = strings.TrimSpace(v) } out.Confidence = asFloat01(obj["confidence"]) if v, ok := obj["handoff"].(bool); ok { out.Handoff = v } if arr, ok := obj["citations"].([]any); ok { for _, item := range arr { if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { out.Citations = append(out.Citations, strings.TrimSpace(s)) } } } return out, nil } func asFloat01(v any) float64 { switch t := v.(type) { case float64: return clamp01(t) case float32: return clamp01(float64(t)) case int: return clamp01(float64(t)) case json.Number: f, err := t.Float64() if err != nil { return 0 } return clamp01(f) case string: f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) if err != nil { return 0 } return clamp01(f) default: return 0 } } func clamp01(f float64) float64 { if f < 0 { return 0 } if f > 1 { return 1 } return f } func labelAIBody(body string) string { body = strings.TrimSpace(body) if strings.Contains(body, "AI-assisted reply") { return body } return body + aiAssistedFooter } func ticketSubjectAndBody(t Ticket) (subject, body string) { subject = t.Subject for i := len(t.Messages) - 1; i >= 0; i-- { m := t.Messages[i] if m.AuthorRole == "user" && !m.IsInternalNote { return subject, m.Body } } if len(t.Messages) > 0 { return subject, t.Messages[0].Body } return subject, "" } func (s *Service) loadTicketForAI(ctx context.Context, ticketID, companyID uuid.UUID) (Ticket, error) { t, err := s.GetAdmin(ctx, ticketID) if err != nil { return Ticket{}, err } if t.CompanyID != companyID { return Ticket{}, ErrForbidden } return t, nil } func (s *Service) kbSnippetsForAI(ctx context.Context, ticket Ticket) []KBSnippet { arts, _, err := s.loadMatchCorpus(ctx) if err != nil || len(arts) == 0 { return nil } subject, body := ticketSubjectAndBody(ticket) haystack := strings.ToLower(strings.TrimSpace(subject + " " + body)) tokens := tokenizeMatchText(haystack) type scored struct { art KBArticle score float64 } var ranked []scored for _, a := range arts { if !a.IsPublished { continue } sc := scoreCorpusItem(haystack, tokens, ticket.Category, a.Keywords, a.IntentKeys, a.CategorySlugs, a.PriorityWeight) if sc <= 0 { continue } ranked = append(ranked, scored{art: a, score: sc}) } // Simple insertion sort by score desc (corpus is small). for i := 1; i < len(ranked); i++ { j := i for j > 0 && ranked[j].score > ranked[j-1].score { ranked[j], ranked[j-1] = ranked[j-1], ranked[j] j-- } } const maxN = 5 out := make([]KBSnippet, 0, maxN) for i := 0; i < len(ranked) && i < maxN; i++ { a := ranked[i].art out = append(out, KBSnippet{ Slug: a.Slug, Title: a.Title, BodyMD: a.BodyMD, Company: uuid.Nil, }) } return out } func (s *Service) handoffAI(ctx context.Context, claim AutoClaim, reason string, cause error) error { meta := map[string]any{ "source": AutoSourceAI, "reason": reason, } if cause != nil { meta["err"] = RedactForAutoLog(cause.Error()) } raw, _ := json.Marshal(meta) slog.Info("support_auto_ai_handoff", "ticket_id", claim.TicketID.String(), "company_id", claim.CompanyID.String(), "reason", reason, ) noteID, noteErr := s.InsertAutoSystemMessage(ctx, claim, humanReviewNote, AutoSourceAI, 0, "", nil, true) if noteErr != nil && !errors.Is(noteErr, ErrAutoReplyAlreadyPosted) && !errors.Is(noteErr, ErrAutoReplyDisabled) { // Fall through to status update even if note fails (e.g. missing columns). slog.Warn("support_auto_ai_handoff_note_failed", "ticket_id", claim.TicketID.String(), "company_id", claim.CompanyID.String(), "err", RedactForAutoLog(noteErr.Error()), ) } var msgPtr *uuid.UUID if noteErr == nil { msgPtr = ¬eID } _ = s.RecordAutoReplyOutcome(ctx, claim.TicketID, AutoReplyHandedOff, true, msgPtr, raw, ActivityHandedOff) _ = s.markAutoHandOff(ctx, claim.TicketID) if cause != nil { return cause } return nil } func (s *Service) notifyAutoReplyCustomer(ctx context.Context, userID, ticketID, msgID uuid.UUID) error { tx, err := s.Pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() if err := insertNotification(ctx, tx, userID, ticketID, &msgID, "auto_reply"); err != nil { if err2 := insertNotification(ctx, tx, userID, ticketID, &msgID, "agent_reply"); err2 != nil { return err } } return tx.Commit(ctx) } // Ensure CompleterSupportAI stays compatible with aiprovider.Service method set. var _ SupportAIResolver = (*aiprovider.Service)(nil)