Files
2026-08-23 22:03:57 +02:00

369 lines
9.8 KiB
Go

package support
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
"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
}
ctx = aiaudit.WithCall(ctx, aiaudit.CallContext{CompanyID: companyID, Role: aiaudit.RoleSupport})
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 = &noteID
}
_ = 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)