Files
descrybe/apps/api/internal/support/staff_auto.go
T
greeneclipse 8580c996c3 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.
2026-08-09 22:47:43 +02:00

136 lines
3.5 KiB
Go

package support
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
)
// Queue flag filters for staff inbox (agent 8).
const (
FlagNeedsHuman = "needs_human"
FlagAIDraft = "ai_draft"
)
// ApproveAIDraftInput is the staff approve payload for draft_only AI replies.
type ApproveAIDraftInput struct {
Body string `json:"body"`
Status *string `json:"status"`
}
// ApplyQueueFlag appends WHERE clauses for staff auto-assist filters.
// Unknown flags are ignored (fail open on filter only — never broaden visibility).
func ApplyQueueFlag(f *ListFilter, args *[]any, where *string) {
if f == nil || args == nil || where == nil {
return
}
flag := strings.ToLower(strings.TrimSpace(f.Flag))
switch flag {
case FlagAIDraft:
*where += ` AND COALESCE(t.auto_reply_status, 'none') = 'ai_draft'`
case FlagNeedsHuman:
*where += ` AND COALESCE(t.auto_reply_status, 'none') IN ('handed_off','failed','skipped')`
default:
return
}
}
func findAIDraftMessage(msgs []Message, preferredID *uuid.UUID) *Message {
if preferredID != nil {
for i := range msgs {
if msgs[i].ID == *preferredID {
return &msgs[i]
}
}
}
for i := len(msgs) - 1; i >= 0; i-- {
m := &msgs[i]
if !m.IsInternalNote {
continue
}
src := strings.ToLower(strings.TrimSpace(m.AutoSource))
if m.IsAutoReply && (src == "ai" || src == "") {
return m
}
if src == "ai" {
return m
}
}
for i := len(msgs) - 1; i >= 0; i-- {
m := &msgs[i]
if m.IsInternalNote && m.IsAutoReply {
return m
}
}
return nil
}
// ApproveAIDraft posts an edited (or original) AI draft as a public staff reply.
// Enforces the same ticket visibility as other desk mutations via the caller.
func (s *Service) ApproveAIDraft(ctx context.Context, agentUserID, ticketID uuid.UUID, in ApproveAIDraftInput) (Ticket, error) {
t, err := s.GetAdmin(ctx, ticketID)
if err != nil {
return Ticket{}, err
}
if strings.ToLower(strings.TrimSpace(t.AutoReplyStatus)) != AutoReplyAIDraft {
return Ticket{}, ErrNoAIDraft
}
body := strings.TrimSpace(in.Body)
if body == "" {
draft := findAIDraftMessage(t.Messages, t.AutoReplyMessageID)
if draft == nil {
return Ticket{}, ErrNoAIDraft
}
body = draft.Body
}
out, err := s.ReplyAsAgent(ctx, agentUserID, ticketID, ReplyInput{
Body: body,
Status: in.Status,
})
if err != nil {
return Ticket{}, err
}
_ = out
meta, _ := json.Marshal(map[string]any{
"action": "approve_ai_draft",
"by": agentUserID.String(),
})
_ = s.RecordAutoReplyOutcome(ctx, ticketID, AutoReplyAISent, true, nil, meta, ActivityAISent)
return s.GetAdmin(ctx, ticketID)
}
// DiscardAIDraft marks the ticket as handed off, disables further auto, and keeps the internal note.
func (s *Service) DiscardAIDraft(ctx context.Context, agentUserID, ticketID uuid.UUID) (Ticket, error) {
t, err := s.GetAdmin(ctx, ticketID)
if err != nil {
return Ticket{}, err
}
status := strings.ToLower(strings.TrimSpace(t.AutoReplyStatus))
draft := findAIDraftMessage(t.Messages, t.AutoReplyMessageID)
if status != AutoReplyAIDraft && draft == nil {
return Ticket{}, ErrNoAIDraft
}
disabled := true
if _, err := s.UpdateAdmin(ctx, ticketID, agentUserID, AdminUpdateInput{
AutoReplyDisabled: &disabled,
}); err != nil {
return Ticket{}, err
}
meta, _ := json.Marshal(map[string]any{
"action": "discard_ai_draft",
"by": agentUserID.String(),
})
if err := s.RecordAutoReplyOutcome(ctx, ticketID, AutoReplyHandedOff, true, t.AutoReplyMessageID, meta, ActivityHandedOff); err != nil {
return Ticket{}, err
}
return s.GetAdmin(ctx, ticketID)
}