122 lines
3.6 KiB
Go
122 lines
3.6 KiB
Go
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
|
||
|
|
}
|