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,366 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user