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,221 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// AutoReplyTimeout is the hard deadline for one LLM auto-reply attempt.
|
||||
const AutoReplyTimeout = 25 * time.Second
|
||||
|
||||
// AutoClaim is a successful idempotency claim for posting one auto reply.
|
||||
type AutoClaim struct {
|
||||
TicketID uuid.UUID
|
||||
CompanyID uuid.UUID
|
||||
}
|
||||
|
||||
// ClaimAutoReplyAttempt marks the ticket as in-progress for auto-reply if no public
|
||||
// auto message exists yet and auto is not disabled. Concurrent claims fail with
|
||||
// ErrAutoReplyAlreadyPosted.
|
||||
func (s *Service) ClaimAutoReplyAttempt(ctx context.Context, ticketID uuid.UUID) (AutoClaim, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return AutoClaim{}, ErrAIAutoReplyDisabled
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return AutoClaim{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var (
|
||||
companyID uuid.UUID
|
||||
disabled bool
|
||||
status string
|
||||
msgID *uuid.UUID
|
||||
tStatus string
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT company_id,
|
||||
COALESCE(auto_reply_disabled, false),
|
||||
COALESCE(auto_reply_status, 'none'),
|
||||
auto_reply_message_id,
|
||||
status
|
||||
FROM support_tickets
|
||||
WHERE id = $1
|
||||
FOR UPDATE`, ticketID,
|
||||
).Scan(&companyID, &disabled, &status, &msgID, &tStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return AutoClaim{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return AutoClaim{}, err
|
||||
}
|
||||
if disabled {
|
||||
return AutoClaim{}, ErrAutoReplyDisabled
|
||||
}
|
||||
if tStatus == "closed" || tStatus == "resolved" {
|
||||
return AutoClaim{}, ErrTicketClosed
|
||||
}
|
||||
if msgID != nil || status == "matched" || status == "ai_sent" || status == "ai_draft" {
|
||||
return AutoClaim{}, ErrAutoReplyAlreadyPosted
|
||||
}
|
||||
|
||||
var existing uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id FROM support_messages
|
||||
WHERE ticket_id = $1 AND company_id = $2
|
||||
AND is_auto_reply = true AND is_internal_note = false
|
||||
LIMIT 1`, ticketID, companyID,
|
||||
).Scan(&existing)
|
||||
if err == nil {
|
||||
return AutoClaim{}, ErrAutoReplyAlreadyPosted
|
||||
}
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return AutoClaim{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE support_tickets
|
||||
SET auto_reply_attempted_at = $2, updated_at = $2
|
||||
WHERE id = $1 AND company_id = $3
|
||||
AND auto_reply_message_id IS NULL
|
||||
AND COALESCE(auto_reply_disabled, false) = false`,
|
||||
ticketID, now, companyID,
|
||||
)
|
||||
if err != nil {
|
||||
return AutoClaim{}, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return AutoClaim{}, ErrAutoReplyAlreadyPosted
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return AutoClaim{}, err
|
||||
}
|
||||
return AutoClaim{TicketID: ticketID, CompanyID: companyID}, nil
|
||||
}
|
||||
|
||||
// InsertAutoSystemMessage posts a labeled system auto-reply scoped to claim.CompanyID.
|
||||
// Unique partial index prevents double public auto posts under races.
|
||||
func (s *Service) InsertAutoSystemMessage(
|
||||
ctx context.Context,
|
||||
claim AutoClaim,
|
||||
body, source string,
|
||||
confidence float64,
|
||||
refType string,
|
||||
refID *uuid.UUID,
|
||||
internal bool,
|
||||
) (uuid.UUID, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return uuid.Nil, ErrAIAutoReplyDisabled
|
||||
}
|
||||
body = sanitizeAutoBody(body)
|
||||
if body == "" {
|
||||
return uuid.Nil, ErrBodyRequired
|
||||
}
|
||||
if source != "kb" && source != "template" && source != "ai" {
|
||||
source = "ai"
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var companyID uuid.UUID
|
||||
var disabled bool
|
||||
var msgID *uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT company_id, COALESCE(auto_reply_disabled, false), auto_reply_message_id
|
||||
FROM support_tickets WHERE id = $1 FOR UPDATE`, claim.TicketID,
|
||||
).Scan(&companyID, &disabled, &msgID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if companyID != claim.CompanyID {
|
||||
return uuid.Nil, ErrForbidden
|
||||
}
|
||||
if disabled {
|
||||
return uuid.Nil, ErrAutoReplyDisabled
|
||||
}
|
||||
if msgID != nil && !internal {
|
||||
return uuid.Nil, ErrAutoReplyAlreadyPosted
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var refTypeArg any
|
||||
if strings.TrimSpace(refType) == "" {
|
||||
refTypeArg = nil
|
||||
} else {
|
||||
refTypeArg = strings.TrimSpace(refType)
|
||||
}
|
||||
|
||||
var newID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO support_messages (
|
||||
ticket_id, company_id, author_user_id, author_role, body, is_internal_note,
|
||||
created_at, is_auto_reply, auto_source, auto_confidence, auto_ref_type, auto_ref_id
|
||||
) VALUES ($1,$2,NULL,'system',$3,$4,$5,true,$6,$7,$8,$9)
|
||||
RETURNING id`,
|
||||
claim.TicketID, claim.CompanyID, body, internal, now, source, confidence, refTypeArg, refID,
|
||||
).Scan(&newID)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return uuid.Nil, ErrAutoReplyAlreadyPosted
|
||||
}
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
status := "ai_draft"
|
||||
if !internal {
|
||||
status = "ai_sent"
|
||||
if source == "kb" || source == "template" {
|
||||
status = "matched"
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE support_tickets
|
||||
SET auto_reply_status = $2,
|
||||
auto_reply_message_id = $3,
|
||||
status = CASE WHEN status = 'open' THEN 'pending' ELSE status END,
|
||||
last_message_at = $4,
|
||||
last_agent_message_at = $4,
|
||||
updated_at = $4
|
||||
WHERE id = $1 AND company_id = $5`,
|
||||
claim.TicketID, status, newID, now, claim.CompanyID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE support_tickets
|
||||
SET auto_reply_status = $2, updated_at = $3
|
||||
WHERE id = $1 AND company_id = $4`,
|
||||
claim.TicketID, status, now, claim.CompanyID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
func sanitizeAutoBody(body string) string {
|
||||
body = strings.TrimSpace(strings.ReplaceAll(body, "\x00", ""))
|
||||
if utf8.RuneCountInString(body) <= maxBodyLen {
|
||||
return body
|
||||
}
|
||||
return string([]rune(body)[:maxBodyLen])
|
||||
}
|
||||
Reference in New Issue
Block a user