Files
Jay Brown 72de690894 Merged in feature/chatbot1 (pull request #223)
Chatbot functionality

* baseline working

* missing test file

* more tests
2026-05-07 20:56:18 +00:00

685 lines
25 KiB
Go

package bot
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"unicode/utf8"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/serviceconfig/chatbot"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// titleMaxRunes caps the auto-derived session title at 120 runes per
// plan §6: "title = first 120 runes of prompt".
const titleMaxRunes = 120
// minGraceSeconds is the floor enforced on the in-flight grace window.
// Plan §8 specifies InflightGraceSeconds = 2 * RequestTimeoutSeconds,
// but a too-small RequestTimeoutSeconds (e.g. 1s in test fixtures)
// would otherwise produce a 2s grace, which is below typical
// FastAPI round-trip latency. The floor protects users whose request
// was still legitimately in flight at the time a sibling caller
// arrived.
const minGraceSeconds = 60
// effectiveGraceSeconds returns the larger of the configured grace and
// the minimum floor. The chatbot config's InflightGraceSeconds method
// stays as the literal `2 * RequestTimeoutSeconds` for spec parity
// with plan §8; this helper is the value AddTurn actually passes to
// AbandonExpiredInflightForOwner.
func effectiveGraceSeconds(cfg chatbot.ChatbotConfig) int {
if cfg.InflightGraceSeconds() > minGraceSeconds {
return cfg.InflightGraceSeconds()
}
return minGraceSeconds
}
// pgUniqueViolation is the SQLSTATE Postgres returns when an INSERT
// trips a unique index. The partial unique index
// `bot_turns_one_inflight_per_session` raises this when a second
// in_flight row would land on a session.
const pgUniqueViolation = "23505"
// AddTurn implements plan §6 verbatim: tx1 lock + abandon + classify +
// insert/reset; FastAPI call OUTSIDE any transaction; tx2 re-lock with
// compare-and-set on attempt_id. Caller-supplied Ordinal is the
// authoritative target — the handler computes it via prompt-match
// semantics before invoking AddTurn.
func (s *Service) AddTurn(ctx context.Context, input AddTurnInput) (AddTurnResult, error) {
if err := validatePrompt(input.Prompt, s.cfg.MaxTurnChars); err != nil {
return AddTurnResult{}, err
}
attemptID := uuid.New()
tx1, err := s.runAddTurnTx1(ctx, input, attemptID)
if err != nil {
return AddTurnResult{}, err
}
requestID := fmt.Sprintf("%s:%d", input.SessionID, input.Ordinal)
chatReq, err := s.buildChatRequest(ctx, input, requestID, tx1.sessionState)
if err != nil {
return AddTurnResult{}, err
}
callResult, callErr := s.fastAPI.Chat(ctx, chatReq)
return s.runAddTurnTx2(ctx, input, attemptID, callResult, callErr)
}
// addTurnTx1Result is the per-call snapshot tx1 hands to the FastAPI
// call and tx2 path. sessionState is the bot_sessions.state from tx1
// (used to feed FastAPI per plan §7 mapping table).
type addTurnTx1Result struct {
sessionState json.RawMessage
}
// runAddTurnTx1 owns the tx1 portion of the AddTurn algorithm. It
// locks the session FOR UPDATE, abandons grace-expired in_flight rows,
// re-derives ordinals, classifies the existing row at input.Ordinal,
// then either resets a terminal-failure row or inserts a fresh
// in_flight row. Returns the session state for the FastAPI payload.
func (s *Service) runAddTurnTx1(ctx context.Context, input AddTurnInput, attemptID uuid.UUID) (addTurnTx1Result, error) {
queries := s.dbCfg.GetDBQueries()
var snapshot addTurnTx1Result
txErr := s.dbCfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error {
session, err := q.LockBotSessionForOwner(txCtx, &repository.LockBotSessionForOwnerParams{
SessionID: input.SessionID,
ClientID: input.ClientID,
CreatedBy: input.Actor,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrSessionNotFound
}
return fmt.Errorf("bot.AddTurn tx1 lock: %w", err)
}
grace := int32(effectiveGraceSeconds(s.cfg)) //nolint:gosec // grace is bounded by minGraceSeconds floor
if err := q.AbandonExpiredInflightForOwner(txCtx, &repository.AbandonExpiredInflightForOwnerParams{
SessionID: input.SessionID,
GraceSeconds: grace,
ClientID: input.ClientID,
CreatedBy: input.Actor,
}); err != nil {
return fmt.Errorf("bot.AddTurn tx1 abandon: %w", err)
}
// Re-derive ordinals after abandonment by re-locking. The
// second lock is a no-op since the row is already locked but
// gives us the fresh derived columns.
refreshed, err := q.LockBotSessionForOwner(txCtx, &repository.LockBotSessionForOwnerParams{
SessionID: input.SessionID,
ClientID: input.ClientID,
CreatedBy: input.Actor,
})
if err != nil {
return fmt.Errorf("bot.AddTurn tx1 re-lock: %w", err)
}
snapshot.sessionState = append(json.RawMessage(nil), session.State...)
if err := s.classifyAndPlaceTurn(txCtx, q, input, attemptID, refreshed); err != nil {
return err
}
if input.Ordinal == 1 {
title := truncateRunes(input.Prompt, titleMaxRunes)
if err := q.SetBotSessionTitle(txCtx, &repository.SetBotSessionTitleParams{
SessionID: input.SessionID,
Title: title,
}); err != nil {
return fmt.Errorf("bot.AddTurn set title: %w", err)
}
}
return nil
})
if txErr != nil {
return addTurnTx1Result{}, txErr
}
_ = queries // queries not needed outside tx1
return snapshot, nil
}
// classifyAndPlaceTurn implements the plan §6 existing-row vs new-row
// branching for the requested ordinal. Either inserts a new in_flight
// row, resets a terminal-failure row, or returns one of the documented
// 409 sentinels.
func (s *Service) classifyAndPlaceTurn(
ctx context.Context,
q *repository.Queries,
input AddTurnInput,
attemptID uuid.UUID,
session *repository.LockBotSessionForOwnerRow,
) error {
ord32 := int32(input.Ordinal) //nolint:gosec // bounded by handler-side ordinal computation
existing, err := q.GetBotTurn(ctx, &repository.GetBotTurnParams{
SessionID: input.SessionID,
Ordinal: ord32,
})
if err == nil {
return s.classifyExistingTurn(ctx, q, input, attemptID, session, existing)
}
if !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("bot.AddTurn get existing: %w", err)
}
return s.placeNewTurn(ctx, q, input, attemptID, session)
}
// classifyExistingTurn handles the existing-row branch of plan §6.
// The status determines the sentinel; for errored/abandoned the prompt
// must match and ordinal must equal last_terminal_ordinal for the reset
// to proceed.
func (s *Service) classifyExistingTurn(
ctx context.Context,
q *repository.Queries,
input AddTurnInput,
attemptID uuid.UUID,
session *repository.LockBotSessionForOwnerRow,
existing *repository.BotTurn,
) error {
switch existing.Status {
case "in_flight":
return ErrTurnInFlight
case "completed":
return ErrAlreadyComplete
case "session_deleted":
return ErrTurnSessionDeleted
case "errored", "abandoned":
if int32(input.Ordinal) != session.LastTerminalOrdinal { //nolint:gosec // ordinal already validated
return ErrOrdinalOutOfRange
}
if existing.Prompt != input.Prompt {
return ErrPromptMismatch
}
if err := q.ResetBotTurnForRetry(ctx, &repository.ResetBotTurnForRetryParams{
AttemptID: attemptID,
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec // ordinal already validated
}); err != nil {
if isUniqueViolation(err) {
return ErrPriorTurnInFlight
}
return fmt.Errorf("bot.AddTurn reset retry: %w", err)
}
return nil
default:
return fmt.Errorf("bot.AddTurn unknown existing status %q", existing.Status)
}
}
// placeNewTurn handles the no-existing-row branch of plan §6. Verifies
// the requested ordinal matches the natural target then inserts a
// fresh in_flight row. Partial unique index rejection maps to
// ErrPriorTurnInFlight.
func (s *Service) placeNewTurn(
ctx context.Context,
q *repository.Queries,
input AddTurnInput,
attemptID uuid.UUID,
session *repository.LockBotSessionForOwnerRow,
) error {
hasInflight := session.LastSeenOrdinal != session.LastTerminalOrdinal
var expected int32
if hasInflight {
expected = session.LastSeenOrdinal + 1
} else {
expected = session.LastTerminalOrdinal + 1
}
if int32(input.Ordinal) != expected { //nolint:gosec // ordinal validated
return ErrOrdinalOutOfRange
}
_, err := q.InsertBotTurnPrompt(ctx, &repository.InsertBotTurnPromptParams{
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec // ordinal validated
Prompt: input.Prompt,
AttemptID: attemptID,
})
if err != nil {
if isUniqueViolation(err) {
return ErrPriorTurnInFlight
}
return fmt.Errorf("bot.AddTurn insert: %w", err)
}
return nil
}
// buildChatRequest assembles the FastAPI request from the session
// state captured in tx1. The conversation history and scope are loaded
// outside any transaction since they are read-only queries that do not
// require the session lock.
func (s *Service) buildChatRequest(ctx context.Context, input AddTurnInput, requestID string, sessionState json.RawMessage) (ChatRequest, error) {
conv, err := s.BuildConversationHistory(ctx, input.SessionID)
if err != nil {
return ChatRequest{}, err
}
scope, err := s.BuildChatScope(ctx, input.SessionID, input.ClientID)
if err != nil {
return ChatRequest{}, err
}
req := ChatRequest{
RequestID: requestID,
UserID: input.Actor,
SessionID: input.SessionID.String(),
Message: input.Prompt,
ConversationHistory: conv,
Scope: scope,
}
if len(sessionState) > 0 && string(sessionState) != "null" {
state := SessionState(sessionState)
req.SessionState = &state
}
return req, nil
}
// runAddTurnTx2 implements the tx2 portion of plan §6: re-lock the
// session, decide between MarkBotTurnSessionDeleted (session vanished),
// FailBotTurn (FastAPI failed or response invalid), or CompleteBotTurn
// (FastAPI succeeded). Each compare-and-set checks attempt_id; a stale
// callback (zero rows affected) maps to ErrTurnSuperseded.
//
// The DB writes (FailBotTurn, MarkBotTurnSessionDeleted, CompleteBotTurn)
// MUST commit even when the outcome is a 409/410/502-class sentinel.
// ExecuteDBTransaction rolls back on any non-nil inner-error, so the
// closure captures the outcome via outErr and returns nil to commit;
// the outer function returns outErr after the tx completes.
func (s *Service) runAddTurnTx2(ctx context.Context, input AddTurnInput, attemptID uuid.UUID, callResult ChatCallResult, callErr error) (AddTurnResult, error) {
attemptCount := extractAttemptCount(callResult, callErr)
var result AddTurnResult
var outErr error
txErr := s.dbCfg.ExecuteDBTransaction(ctx, func(txCtx context.Context, q *repository.Queries) error {
_, err := q.LockBotSessionForOwner(txCtx, &repository.LockBotSessionForOwnerParams{
SessionID: input.SessionID,
ClientID: input.ClientID,
CreatedBy: input.Actor,
})
var outcome tx2Outcome
switch {
case err != nil && errors.Is(err, pgx.ErrNoRows):
outcome = s.handleTx2SessionGone(txCtx, q, input, attemptID, attemptCount, &result)
case err != nil:
return fmt.Errorf("bot.AddTurn tx2 lock: %w", err)
case callErr != nil:
outcome = s.handleTx2CallFailure(txCtx, q, input, attemptID, attemptCount, callErr, &result)
default:
outcome = s.handleTx2CallSuccess(txCtx, q, input, attemptID, attemptCount, callResult, &result)
}
if outcome.dbErr != nil {
return outcome.dbErr
}
outErr = outcome.outcomeErr
return nil
})
if txErr != nil {
return result, txErr
}
return result, outErr
}
// tx2Outcome carries the outcome of one tx2 handler. dbErr signals a
// real DB error that should roll back the transaction; outcomeErr is
// the plan §6 sentinel that should be returned to the handler AFTER
// the tx commits.
type tx2Outcome struct {
dbErr error
outcomeErr error
}
// handleTx2SessionGone runs when tx2's LockBotSessionForOwner found no
// row (the session was soft-deleted while the FastAPI call was in
// flight). MarkBotTurnSessionDeleted runs against the bare bot_turns
// row outside the now-disappeared session lock.
func (s *Service) handleTx2SessionGone(ctx context.Context, q *repository.Queries, input AddTurnInput, attemptID uuid.UUID, attemptCount int, result *AddTurnResult) tx2Outcome {
errJSON := buildErrorJSON("session_deleted_during_call",
"session was soft-deleted while FastAPI call was in flight",
attemptCount)
if _, err := q.MarkBotTurnSessionDeleted(ctx, &repository.MarkBotTurnSessionDeletedParams{
Error: errJSON,
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec // ordinal validated upstream
AttemptID: attemptID,
}); err != nil {
return tx2Outcome{dbErr: fmt.Errorf("bot.AddTurn tx2 mark session_deleted: %w", err)}
}
result.Ordinal = int32(input.Ordinal) //nolint:gosec
result.Status = "session_deleted"
result.Error = errJSON
result.AttemptCount = attemptCount
return tx2Outcome{outcomeErr: ErrSessionDeletedDuringCall}
}
// handleTx2CallFailure runs when the FastAPI call returned a non-nil
// error. FailBotTurn writes the error JSON; the outcome err is the
// underlying typed sentinel so the handler can surface it.
func (s *Service) handleTx2CallFailure(ctx context.Context, q *repository.Queries, input AddTurnInput, attemptID uuid.UUID, attemptCount int, callErr error, result *AddTurnResult) tx2Outcome {
code, message := classifyCallError(callErr)
errJSON := buildErrorJSON(code, message, attemptCount)
rows, err := q.FailBotTurn(ctx, &repository.FailBotTurnParams{
Error: errJSON,
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec // ordinal validated
AttemptID: attemptID,
})
if err != nil {
return tx2Outcome{dbErr: fmt.Errorf("bot.AddTurn tx2 fail: %w", err)}
}
if rows == 0 {
return tx2Outcome{outcomeErr: ErrTurnSuperseded}
}
result.Ordinal = int32(input.Ordinal) //nolint:gosec
result.Status = "errored"
result.Error = errJSON
result.AttemptCount = attemptCount
return tx2Outcome{outcomeErr: normalizeCallError(callErr)}
}
// handleTx2CallSuccess runs when the FastAPI call returned a nil
// error. Validates the answer.text length, completes the turn via
// compare-and-set, then applies session-state semantics: null preserves
// prior; non-null gets stripped and written if under cap.
func (s *Service) handleTx2CallSuccess(ctx context.Context, q *repository.Queries, input AddTurnInput, attemptID uuid.UUID, attemptCount int, callResult ChatCallResult, result *AddTurnResult) tx2Outcome {
if err := validateCompletion(callResult, s.cfg.MaxTurnChars); err != nil {
return s.recordInvalidResponse(ctx, q, input, attemptID, attemptCount, err, result)
}
answerText := callResult.Response.Answer.Text
latencyMs, tokensIn, tokensOut := SummarizeMetadata(
callResult.Response.Metadata, callResult.SentAt, callResult.ReceivedAt)
rows, err := q.CompleteBotTurn(ctx, &repository.CompleteBotTurnParams{
Completion: &answerText,
LatencyMs: ptrInt32(latencyMs),
TokensIn: ptrInt32(tokensIn),
TokensOut: ptrInt32(tokensOut),
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec
AttemptID: attemptID,
})
if err != nil {
return tx2Outcome{dbErr: fmt.Errorf("bot.AddTurn tx2 complete: %w", err)}
}
if rows == 0 {
return tx2Outcome{outcomeErr: ErrTurnSuperseded}
}
if err := s.applySessionStateUpdate(ctx, q, input, callResult.Response.UpdatedSessionState); err != nil {
return tx2Outcome{dbErr: err}
}
result.Ordinal = int32(input.Ordinal) //nolint:gosec
result.Status = "completed"
result.Completion = &answerText
result.LatencyMs = latencyMs
result.TokensIn = tokensIn
result.TokensOut = tokensOut
result.AttemptCount = attemptCount
return tx2Outcome{}
}
// recordInvalidResponse persists an errored row when the FastAPI
// response failed validation (empty answer, too long, etc.).
func (s *Service) recordInvalidResponse(ctx context.Context, q *repository.Queries, input AddTurnInput, attemptID uuid.UUID, attemptCount int, validationErr error, result *AddTurnResult) tx2Outcome {
errJSON := buildErrorJSON("agent_invalid_response", validationErr.Error(), attemptCount)
rows, err := q.FailBotTurn(ctx, &repository.FailBotTurnParams{
Error: errJSON,
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec
AttemptID: attemptID,
})
if err != nil {
return tx2Outcome{dbErr: fmt.Errorf("bot.AddTurn tx2 fail invalid: %w", err)}
}
if rows == 0 {
return tx2Outcome{outcomeErr: ErrTurnSuperseded}
}
result.Ordinal = int32(input.Ordinal) //nolint:gosec
result.Status = "errored"
result.Error = errJSON
result.AttemptCount = attemptCount
return tx2Outcome{outcomeErr: ErrAgentInvalidResponse}
}
// applySessionStateUpdate runs the plan §6 state-management logic on a
// successful completion: nil updated_session_state preserves prior;
// non-nil gets cached_results stripped and written when under
// chatbot.StateMaxBytes; oversize logs warn + preserves prior.
func (s *Service) applySessionStateUpdate(ctx context.Context, q *repository.Queries, input AddTurnInput, updated *json.RawMessage) error {
if updated == nil {
return nil
}
stripped, err := StripCachedResults(*updated)
if err != nil {
slog.Warn("bot.AddTurn: strip cached_results failed; preserving prior state",
"error", err, "session_id", input.SessionID)
return nil
}
if SessionStateOversize(stripped) {
slog.Warn("bot.AddTurn: stripped session state exceeds cap; preserving prior",
"size", len(stripped), "cap", chatbot.StateMaxBytes,
"session_id", input.SessionID)
return nil
}
if err := q.UpdateBotSessionState(ctx, &repository.UpdateBotSessionStateParams{
State: stripped,
SessionID: input.SessionID,
ClientID: input.ClientID,
}); err != nil {
return fmt.Errorf("bot.AddTurn tx2 update state: %w", err)
}
return nil
}
// CompleteBotTurnWithAttempt is the public facade plan §11's stale-tx2
// test exercises directly. It runs the same compare-and-set the
// AddTurn tx2 path uses; affected rows == 0 → ErrTurnSuperseded.
func (s *Service) CompleteBotTurnWithAttempt(ctx context.Context, input CompleteTurnInput) error {
rows, err := s.dbCfg.GetDBQueries().CompleteBotTurn(ctx, &repository.CompleteBotTurnParams{
Completion: &input.Completion,
LatencyMs: ptrInt32(input.LatencyMs),
TokensIn: ptrInt32(input.TokensIn),
TokensOut: ptrInt32(input.TokensOut),
SessionID: input.SessionID,
Ordinal: int32(input.Ordinal), //nolint:gosec // bounded by caller
AttemptID: input.AttemptID,
})
if err != nil {
return fmt.Errorf("bot.CompleteBotTurnWithAttempt: %w", err)
}
if rows == 0 {
return ErrTurnSuperseded
}
return nil
}
// ListTurns returns every turn for an owner-scoped session, ordered
// ASC by ordinal. Owner check via GetBotSessionForOwner first; on miss
// returns ErrSessionNotFound so the handler maps to 404.
func (s *Service) ListTurns(ctx context.Context, sessionID uuid.UUID, clientID, actor string) ([]Turn, error) {
if _, err := s.GetSessionForOwner(ctx, sessionID, clientID, actor); err != nil {
return nil, err
}
rows, err := s.dbCfg.GetDBQueries().ListTurnsForSession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("bot.ListTurns: %w", err)
}
out := make([]Turn, 0, len(rows))
for _, r := range rows {
out = append(out, Turn{
Ordinal: r.Ordinal,
Prompt: r.Prompt,
Completion: r.Completion,
Status: r.Status,
LatencyMs: r.LatencyMs,
TokensIn: r.TokensIn,
TokensOut: r.TokensOut,
Error: r.Error,
CreatedAt: r.CreatedAt.Time,
})
}
return out, nil
}
// FindTargetOrdinalForPrompt is the handler-facing helper that picks
// the target ordinal from session state given the input prompt. The
// rule: search for any existing row with matching prompt; if found,
// reuse that ordinal so the service classifies it via the existing-row
// branch of plan §6. Otherwise compute the natural target.
//
// When the last terminal turn is errored/abandoned and the prompt did
// NOT match an existing row, the function advances to
// last_terminal_ordinal+1 rather than returning last_terminal_ordinal.
// Same prompt → step 1 finds it → ordinal N → AddTurn resets and
// retries. Different prompt → no match → ordinal N+1 → fresh in_flight.
// Plan: plans/chatbot.stuff/fix.chat.bugs.1.md §3 Option A — replaces
// the previous wedge-on-error wire behavior from plan v8 §3 decision 9.
func (s *Service) FindTargetOrdinalForPrompt(ctx context.Context, sessionID uuid.UUID, clientID, actor, prompt string) (int, error) {
queries := s.dbCfg.GetDBQueries()
matched, err := queries.FindBotTurnByPrompt(ctx, &repository.FindBotTurnByPromptParams{
SessionID: sessionID,
Prompt: prompt,
})
if err == nil {
return int(matched.Ordinal), nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("bot.FindTargetOrdinal: lookup prompt match: %w", err)
}
session, err := queries.GetBotSessionForOwner(ctx, &repository.GetBotSessionForOwnerParams{
SessionID: sessionID,
ClientID: clientID,
CreatedBy: actor,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrSessionNotFound
}
return 0, fmt.Errorf("bot.FindTargetOrdinal: read session: %w", err)
}
if session.LastSeenOrdinal != session.LastTerminalOrdinal {
return int(session.LastSeenOrdinal + 1), nil
}
return int(session.LastTerminalOrdinal + 1), nil
}
// ---------- helpers ----------
// validatePrompt enforces plan §6's "1 <= runeCount(prompt) <= MaxTurnChars".
func validatePrompt(prompt string, maxTurnChars int) error {
trimmed := strings.TrimSpace(prompt)
if trimmed == "" {
return ErrPromptEmpty
}
if utf8.RuneCountInString(prompt) > maxTurnChars {
return ErrPromptTooLong
}
return nil
}
// validateCompletion enforces the same rune-count cap on the FastAPI
// response's answer.text.
func validateCompletion(callResult ChatCallResult, maxTurnChars int) error {
if callResult.Response == nil || callResult.Response.Answer == nil {
return errors.New("agent response missing answer payload")
}
text := callResult.Response.Answer.Text
if strings.TrimSpace(text) == "" {
return errors.New("agent answer.text is empty")
}
if utf8.RuneCountInString(text) > maxTurnChars {
return fmt.Errorf("agent answer.text exceeds MaxTurnChars=%d", maxTurnChars)
}
return nil
}
// truncateRunes returns the first n runes of s. Used for title
// derivation so multi-byte runes are not split.
func truncateRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
r := []rune(s)
return string(r[:n])
}
// isUniqueViolation reports whether err wraps a Postgres unique-constraint
// violation (SQLSTATE 23505). The partial unique index
// `bot_turns_one_inflight_per_session` raises this on second-in_flight.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == pgUniqueViolation
}
return false
}
// classifyCallError maps a FastAPIClient.Chat error onto a code/message
// pair for the persisted error JSON. Falls back to a generic
// transport_error when the sentinel is not one of the documented
// agent-* values.
func classifyCallError(err error) (code, message string) {
switch {
case errors.Is(err, ErrAgentApplicationError):
return "agent_application_error", err.Error()
case errors.Is(err, ErrAgentMissingAnswer):
return "agent_missing_answer", err.Error()
case errors.Is(err, ErrAgentInvalidResponse):
return "agent_invalid_response", err.Error()
}
return "agent_transport_error", err.Error()
}
// normalizeCallError returns the typed sentinel the handler should
// surface. FastAPI transport-exhaustion errors have no documented
// sentinel from the client; this helper rewrites them to
// ErrAgentTransportError so mapBotError can map to 502.
func normalizeCallError(err error) error {
switch {
case errors.Is(err, ErrAgentApplicationError),
errors.Is(err, ErrAgentMissingAnswer),
errors.Is(err, ErrAgentInvalidResponse):
return err
}
return fmt.Errorf("%w: %s", ErrAgentTransportError, err.Error())
}
// extractAttemptCount pulls the AttemptCount off a FastAPI call result
// or its error sentinel. Plan §7: the count must be persistable from
// either path.
func extractAttemptCount(callResult ChatCallResult, callErr error) int {
if callErr != nil {
var counter AttemptCounter
if errors.As(callErr, &counter) {
return counter.AttemptCount()
}
return 1
}
return callResult.AttemptCount
}
// buildErrorJSON returns the canonical error JSON shape plan §6
// mandates: {"code","message","attempt"}. Marshal cannot fail for a
// fixed map[string]any of basic types; the error is dropped.
func buildErrorJSON(code, message string, attempt int) []byte {
out, _ := json.Marshal(map[string]any{
"code": code,
"message": message,
"attempt": attempt,
})
return out
}
// ptrInt32 returns &v as *int32 after a checked narrowing cast. Used
// for nullable repository columns latency_ms / tokens_in / tokens_out.
func ptrInt32(v int) *int32 {
out := int32(v) //nolint:gosec // bounded; negative values clamped at caller
return &out
}