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,121 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// TryAutoReplyLLM is the ONLY intended entry point for LLM-assisted ticket
|
||||
// replies using platformsettings.AIRoleSupport / aiprovider.RoleSupport.
|
||||
//
|
||||
// Security gates (always applied):
|
||||
// - context deadline (AutoReplyTimeout)
|
||||
// - per-company + platform AI rate limits
|
||||
// - ticket load scoped by id (company_id captured for prompt isolation)
|
||||
// - skip when auto_reply_disabled / already posted / closed
|
||||
//
|
||||
// LLM completion remains product-gated: until a SupportAIRunner is configured
|
||||
// on Service (agent 4 wiring), this returns ErrAIAutoReplyDisabled after gates.
|
||||
// Create / ReplyAsUser / ReplyAsAgent must not call a Completer directly.
|
||||
//
|
||||
// Guided /docs Ask remains rule-based and must never use AIRoleSupport.
|
||||
func (s *Service) TryAutoReplyLLM(ctx context.Context, ticketID uuid.UUID) error {
|
||||
if s == nil {
|
||||
return ErrAIAutoReplyDisabled
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, AutoReplyTimeout)
|
||||
defer cancel()
|
||||
|
||||
companyID, err := s.loadTicketCompanyForAuto(ctx, ticketID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fail closed before claim/rate-limit consume when no runner is wired.
|
||||
if s.SupportAI == nil {
|
||||
return ErrAIAutoReplyDisabled
|
||||
}
|
||||
|
||||
limiter := s.aiLimiter()
|
||||
if !limiter.Allow(companyID) {
|
||||
slog.Info("support_auto_ai_rate_limited",
|
||||
"ticket_id", ticketID.String(),
|
||||
"company_id", companyID.String(),
|
||||
)
|
||||
_ = s.markAutoHandOff(ctx, ticketID)
|
||||
return ErrAIRateLimited
|
||||
}
|
||||
|
||||
claim, err := s.ClaimAutoReplyAttempt(ctx, ticketID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAutoReplyAlreadyPosted) ||
|
||||
errors.Is(err, ErrAutoReplyDisabled) ||
|
||||
errors.Is(err, ErrTicketClosed) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return ErrAIAutoReplyTimeout
|
||||
}
|
||||
return err
|
||||
}
|
||||
runCtx, runCancel := context.WithTimeout(ctx, AutoReplyTimeout)
|
||||
defer runCancel()
|
||||
err = s.SupportAI.RunAutoReply(runCtx, s, claim)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
slog.Warn("support_auto_ai_timeout",
|
||||
"ticket_id", ticketID.String(),
|
||||
"company_id", companyID.String(),
|
||||
"err", RedactForAutoLog(err.Error()),
|
||||
)
|
||||
_ = s.markAutoHandOff(ctx, ticketID)
|
||||
return ErrAIAutoReplyTimeout
|
||||
}
|
||||
if errors.Is(err, ErrAIAutoReplyDisabled) ||
|
||||
errors.Is(err, ErrAutoReplyAlreadyPosted) ||
|
||||
errors.Is(err, ErrAutoReplyDisabled) ||
|
||||
errors.Is(err, ErrTicketClosed) {
|
||||
return err
|
||||
}
|
||||
slog.Warn("support_auto_ai_failed",
|
||||
"ticket_id", ticketID.String(),
|
||||
"company_id", companyID.String(),
|
||||
"err", RedactForAutoLog(err.Error()),
|
||||
)
|
||||
// Runner may have already handed off; ensure claimable human queue.
|
||||
_ = s.markAutoHandOff(ctx, ticketID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportAIRunner performs the LLM call + draft/send after security gates pass.
|
||||
// Implemented by agent 4; nil keeps TryAutoReplyLLM refuse-by-default.
|
||||
type SupportAIRunner interface {
|
||||
RunAutoReply(ctx context.Context, svc *Service, claim AutoClaim) error
|
||||
}
|
||||
|
||||
func (s *Service) aiLimiter() *AIRateLimiter {
|
||||
if s != nil && s.AIRateLimiter != nil {
|
||||
return s.AIRateLimiter
|
||||
}
|
||||
return AIRateLimiterDefault()
|
||||
}
|
||||
|
||||
func (s *Service) loadTicketCompanyForAuto(ctx context.Context, ticketID uuid.UUID) (uuid.UUID, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return uuid.Nil, ErrAIAutoReplyDisabled
|
||||
}
|
||||
var companyID uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT company_id FROM support_tickets WHERE id = $1`, ticketID,
|
||||
).Scan(&companyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, ErrNotFound
|
||||
}
|
||||
return companyID, err
|
||||
}
|
||||
Reference in New Issue
Block a user