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 }