72de690894
Chatbot functionality * baseline working * missing test file * more tests
685 lines
25 KiB
Go
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
|
|
}
|