369 lines
9.8 KiB
Go
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 = ¬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)
|