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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+121
View File
@@ -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
}