Files
query-orchestration/docs/ai.generated/12-chatbot-guide.md
T
Jay Brown 33b0931b8c Merged in feature/chatbot-limits (pull request #228)
fix chatbot limits and test

* add tests
2026-05-28 19:23:39 +00:00

395 lines
21 KiB
Markdown

# Chatbot Backend Guide
This guide covers the chatbot backend that queryAPI exposes for the Aaria
Agent Service integration. It is the operator entry point; design
rationale lives in `plans/chatbot_plan_codex.v10.md`.
## Overview
queryAPI exposes a synchronous HTTP facade over the Aaria FastAPI agent
service. Client UIs talk to queryAPI; queryAPI calls FastAPI and
persists the conversation in Postgres so future turns inherit the
prior context.
Who calls it:
- Client UIs as `client_user`: create sessions, post turns, list turns,
patch document/folder scope.
- Super-admins: list, get, soft-delete sessions across all users for a
given client.
What it does NOT do (out of scope per plan v10):
- Full-text search.
- GIN indexes.
- Costing / billing.
- Persisted structured data or result rows from the agent.
- User-initiated session deletes (only super-admin DELETE).
## Architecture
### Data model (`bot_*` tables, migration 131)
| Table | Purpose |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| `bot_sessions` | Owner-scoped chatbot conversations. Soft-deletable via `is_deleted`. Derives `last_terminal_ordinal` and `last_seen_ordinal` from `bot_turns`. |
| `bot_session_documents` | Per-session document scope. Empty set means "all documents for the client" (plan §4 fallback). |
| `bot_session_folders` | Per-session folder scope. Folder ids preserved in the FastAPI payload; expansion to documents happens in M3 payload assembly. |
| `bot_turns` | Append-only turn log. Status is one of `in_flight`, `completed`, `errored`, `abandoned`, `session_deleted`. |
Two CHECK constraints govern `bot_turns`:
1. `bot_turns_status_values` enforces the enum set.
2. `bot_turns_status_fields_consistent` enforces the (status, completion,
error) consistency matrix from plan §4 plus a guard clause for
unknown statuses. The guard clause `OR status NOT IN (...)` lets
`bot_turns_status_values` own the rejection of unknown values
without `_status_fields_consistent` firing first under Postgres's
alphabetical-by-name CHECK evaluation order. The plan §4 literal
three-clause matrix is preserved verbatim for documented statuses.
The partial unique index `bot_turns_one_inflight_per_session` enforces
"at most one in-flight turn per session". This is the primary
mechanism for serializing concurrent submissions.
### AddTurn flow (plan §6)
```text
AddTurn(input):
validate prompt (1 <= rune count <= MaxTurnChars)
attemptID = uuid.New()
tx1:
LockBotSessionForOwner(...)
AbandonExpiredInflightForOwner(grace = max(2 * RequestTimeoutSeconds, 60s))
re-derive last_seen_ordinal / last_terminal_ordinal
GetBotTurn(input.Ordinal)
if existing exists:
classify by status; reset on errored/abandoned (prompt must match,
ordinal must equal last_terminal_ordinal); reject on others.
else:
check ordinal matches natural target (last_seen+1 if has_inflight,
else last_terminal+1); InsertBotTurnPrompt; partial unique index
may reject -> ErrPriorTurnInFlight.
if input.Ordinal == 1:
SetBotSessionTitle(first 120 runes of prompt) where title=''
end tx1
Build conversation_history (last RecentTurns completed turns x 2 messages, asc).
Build chat_scope (stored docs + folder-expanded docs, dedup; folder ids preserved).
Call FastAPI /agent/chat OUTSIDE any DB tx (network round-trip).
tx2:
LockBotSessionForOwner(...)
if no row -> session was soft-deleted mid-call:
MarkBotTurnSessionDeleted(...) with attempt_id compare-and-set
return ErrSessionDeletedDuringCall (HTTP 410)
if FastAPI returned a typed error:
FailBotTurn(...) with error JSON containing attempt count
return mapped agent_* sentinel (HTTP 502)
if FastAPI succeeded but answer.text is empty/oversize:
FailBotTurn(...) with agent_invalid_response error JSON
return ErrAgentInvalidResponse (HTTP 502)
success path:
CompleteBotTurn(...) with attempt_id compare-and-set
summarize metadata (latency, tokens)
apply state update: null -> preserve prior; non-null -> strip
cached_results, write if under 64KB cap, else log warn + preserve
end tx2
```
The two-transaction model is deliberate: no DB connection is held
while calling FastAPI (which can take tens of seconds), and tx2's
attempt_id compare-and-set rejects stale callbacks (`zero rows
affected -> ErrTurnSuperseded`). Plan §6 + §11.
### State management
`bot_sessions.state jsonb` mirrors the agent's session state across
turns. On a successful turn:
- FastAPI `updated_session_state == null` -> preserve prior state. The
service does NOT call `UpdateBotSessionState`.
- `updated_session_state` non-null -> `StripCachedResults` removes the
top-level `cached_results` key (a hash-keyed MAP, often the largest
field). Then `SessionStateOversize` checks the result against
`chatbot.StateMaxBytes = 64 * 1024`. Under cap -> persist; over cap
-> log warn and preserve prior state.
The canonical example of a `session_state` payload is
`plans/chatbot.stuff/sample.response.json`. It has 10 top-level keys:
`turn`, `resolved_entities`, `last_scope`, `last_intent`,
`last_query_sql`, `last_query_plan`, `last_result_hash`,
`cached_results`, `last_scope_signature`, `validator_retry_count`.
After strip, the persisted payload has the remaining 9 top-level keys.
### Retry policy (FastAPI client, plan §7)
- Up to 3 attempts per HTTP call.
- Retry on HTTP 429, 500, 502, 503, 504, and transport timeouts.
- Do NOT retry other 4xx, malformed JSON, HTTP 200 with `status:"error"`,
or HTTP 200 with empty/null `answer.text`.
- Reuse the same `request_id` (`{sessionID}:{ordinal}`) on every
attempt. The caller mints it once; the client must not regenerate
per-attempt.
- One parent context enforces total wall time. Per-attempt timeout =
`cfg.RequestTimeoutSeconds` via `context.WithTimeout` derived from
the parent.
- Header literal: `X-API-Key: <cfg.APIKey verbatim>`. Production
values are JSON-encoded strings (`{"AGENT_SERVICE_API_KEY":"value"}`)
per plan §11.
### Error JSON shape
All `bot_turns.error` writes use the plan §6 canonical shape:
```json
{
"code": "<sentinel-string>",
"message": "<human-readable>",
"attempt": <int>
}
```
The `attempt` field carries the FastAPI client's `AttemptCount` so
operators can distinguish single-failure from exhausted-retry scenarios.
## Configuration reference
Five env vars, plan §8. Validated at startup via
`chatbot.ChatbotConfig.Validate` (called between
`serviceconfig.InitializeConfig` and `cfg.InitializeAuthConfig`).
| Variable | Default | Validation | Notes |
| --------------------------------- | ---------------------- | ----------------------------------- | ---------------------------------------------------- |
| `CHATBOT_SERVICE_URL` | (required, notEmpty) | env-loader rejects empty | FastAPI base URL. |
| `CHATBOT_API_KEY` | (required, notEmpty) | env-loader rejects empty | Sent verbatim as `X-API-Key` header. |
| `CHATBOT_REQUEST_TIMEOUT_SECONDS` | 60 | `>= 1` | Per-attempt FastAPI timeout. |
| `CHATBOT_MAX_TURN_CHARS` | 2000 | `1 <= x <= 65536` | Caps prompt and `answer.text` rune count. Upper bound (`chatbot.MaxTurnCharsCeiling`) mirrors the OpenAPI `prompt.maxLength`. |
| `CHATBOT_RECENT_TURNS` | 5 | `1 <= x <= 5` | Conversation-history length and session-list preview cap. |
Derived values (constants/helpers, not env vars):
- `chatbot.StateMaxBytes = 64 * 1024` — session-state cap (plan §8).
- `chatbot.MaxTurnCharsCeiling = 65536` — upper bound for `CHATBOT_MAX_TURN_CHARS`, locked to the OpenAPI `BotTurnAddRequest.prompt.maxLength`. Validate() rejects any value above this.
- `chatbot.ChatbotConfig.InflightGraceSeconds() = 2 * RequestTimeoutSeconds`
per plan §8 literal. The service uses
`effectiveGraceSeconds(cfg) = max(2 * RequestTimeoutSeconds, 60)` as
the actual abandonment threshold to keep small `RequestTimeoutSeconds`
in test fixtures from prematurely abandoning legitimately in-flight
turns. Production `RequestTimeoutSeconds=60` already exceeds the
60-second floor.
## API reference
### User routes (`/client/{clientId}/...`)
All routes are singular `/client/...` per plan §3 so Permit.io's
existing `client_user` policy applies.
| Method | Path | OperationId | Description |
| ------ | ----------------------------------------------------------------- | ------------------------- | -------------------------------------------------------- |
| POST | `/client/{clientId}/bot/sessions` | `createBotSession` | Create a new owner-scoped session. |
| GET | `/client/{clientId}/bot/sessions` | `listBotSessions` | List the caller's sessions; bounds: `limit in [1,200]`, `offset >= 0`. |
| GET | `/client/{clientId}/bot/sessions/{sessionId}` | `getBotSession` | Read one owner-scoped session. |
| PATCH | `/client/{clientId}/bot/sessions/{sessionId}/scope` | `patchBotSessionScope` | Add/remove documents and folders from session scope. |
| POST | `/client/{clientId}/bot/sessions/{sessionId}/turns` | `createBotTurn` | Submit a new turn. Plan §6 algorithm. |
| GET | `/client/{clientId}/bot/sessions/{sessionId}/turns` | `listBotTurns` | List all turns for an owner-scoped session, ASC. |
| GET | `/client/{clientId}/documents/{documentId}/schema` | `getDocumentSchema` | Return the document's bound JSON Schema or `null` (omitted in JSON when unbound). |
| GET | `/client/{clientId}/documents/{documentId}/all-metadata` | `getDocumentAllMetadata` | Bundle of (document, labels, customMetadata) for one doc. |
### Super-admin routes
| Method | Path | OperationId | Description |
| ------ | ----------------------------------------------------- | --------------------------------- | -------------------------------------------- |
| GET | `/super-admin/bot/sessions?clientId=...` | `listBotSessionsAsSuperAdmin` | List all sessions for a client (cross-user). |
| GET | `/super-admin/bot/sessions/{sessionId}?clientId=...` | `getBotSessionAsSuperAdmin` | Read one session by id (cross-user). |
| DELETE | `/super-admin/bot/sessions/{sessionId}?clientId=...` | `deleteBotSessionAsSuperAdmin` | Soft-delete; transitions any in-flight turns to `session_deleted`. |
### `BotSessionResponse.lastTurn`
`lastTurn` returns `last_terminal_ordinal` (plan §4 derived note), NOT
`last_seen_ordinal`. The latter is computed from `bot_turns` but used
only internally by `AddTurn` for ordinal classification. Clients should
display `lastTurn` as "the last completed turn this session has".
### `DocumentSchemaResponse.schema`
When the document has no `custom_schema_id` binding, the response is
HTTP 200 with the `schema` field omitted (the field is `*map[...]` with
`omitempty`, so JSON does NOT emit `"schema": null`; the key is
absent). Clients should treat both "key absent" and "key present and
null" as the unbound case.
### `BotLabelRecord` vs `LabelRecord`
The all-metadata endpoint uses `BotLabelRecord` (plain-string
`appliedBy`) instead of the existing `LabelRecord` (email-validated
`appliedBy`). Both shapes wrap the same `documentLabels.appliedBy
varchar` column; the divergence exists because the email-regex Marshal
validator on `LabelRecord` rejects non-email seed values that pre-date
the email-typing convention. Long-term cleanup is to relax
`LabelRecord.AppliedBy` to plain string. Until then, the chatbot
endpoints emit `BotLabelRecord`.
### HTTP status mapping
| Status | Sentinels / cases |
| ------ | -------------------------------------------------------------------------------------------- |
| 400 | `prompt_empty`, `prompt_too_long`, `add_remove_conflict`, `document_cross_client`, `folder_cross_client`, `clientId is required` (super-admin), invalid `limit`/`offset` |
| 401 | Missing Cognito subject |
| 404 | Cross-client / cross-user / soft-deleted session; document not found in client scope |
| 409 | `turn_in_flight`, `prior_turn_in_flight`, `already_complete`, `turn_session_deleted`, `ordinal_out_of_range`, `turn_superseded` (legacy: `prompt_mismatch` — service-layer assertion only; not reachable from the wire as of 2026-05-07) |
| 410 | `session_deleted_during_call` |
| 502 | `agent_application_error`, `agent_missing_answer`, `agent_invalid_response`, `agent_transport_error` |
| 500 | Unexpected backend errors |
`mapBotTurnError` dispatch order matters because `ErrAlreadyComplete`
chains to `ErrOrdinalOutOfRange` via a custom `Is()` method. The
sentinels are matched in declaration order; `ErrAlreadyComplete` must
be checked before `ErrOrdinalOutOfRange` so the message string
"already_complete" is returned in the HTTP retry-of-completed scenario.
The chain exists so plan §11's concurrency-test loss set
(`ErrTurnInFlight | ErrPriorTurnInFlight | ErrOrdinalOutOfRange`) covers
the case where a concurrent submitter's ordinal lands on the row the
winner just completed.
### Test-pattern note
Test assertions on HTTP error message bodies use substring matches.
`turn_in_flight` matches both `turn_in_flight` and `prior_turn_in_flight`
(the latter contains the former). M4's `TestAddTurn_SameOrdinalStillInflight_409`
exploits this: the test name says "same ordinal", but the algorithm
returns `prior_turn_in_flight` because the natural target is `last_seen+1`
when has_inflight; the substring assertion still passes. Both 409s
have the same operator semantics ("can't submit while a turn is in
flight").
## Operator runbook
### Failed-turn classifications
When a `bot_turns` row has a non-`completed` status, operators consult
`bot_turns.error` for the canonical error JSON and pick the recovery
path below.
#### `errored`
The FastAPI agent returned a typed error or the response failed
validation. The error JSON includes `code`, `message`, and `attempt`.
Common codes:
| Code | Meaning | Recovery |
| -------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `agent_application_error` | FastAPI returned 200 with `status:"error"`. Code/message in the inner `error` field. | Client may resubmit the same prompt at the same ordinal; the algorithm reset path runs. |
| `agent_missing_answer` | FastAPI returned 200 with empty/null/whitespace `answer.text`. | Same as above. May indicate a prompt the agent cannot answer; client UX should suggest rephrasing. |
| `agent_invalid_response` | FastAPI returned 200 but body failed structural validation (malformed JSON or oversize completion). | Same as above. May indicate an upstream agent change; check upstream logs. |
| `agent_transport_error` | FastAPI exhausted its 3-attempt retry budget on transport-level failures (429/5xx/timeouts). | Same as above. May indicate FastAPI overload; check upstream health and rate-limit. |
To retry: client POSTs to the same `/turns` route with the same prompt.
The handler picks the `errored` row's ordinal via prompt-match;
`AddTurn` runs the existing-row reset path; a new `attempt_id` is
minted; tx2 records the new outcome.
If the client posts a *different* prompt instead of retrying, the
handler advances to `last_terminal_ordinal+1` (a fresh `in_flight` at
ordinal N+1) rather than rejecting with 409 `prompt_mismatch`. The
errored row at ordinal N stays in history. See
`plans/chatbot.stuff/fix.chat.bugs.1.md` §3 Option A for the rationale.
#### `abandoned`
The grace window expired without a terminal status. Most often this
means the queryAPI process died mid-call and the next caller's
`AbandonExpiredInflightForOwner` swept the stale row. The error JSON
carries:
```json
{"code":"turn_abandoned_grace_expired","message":"no terminal status reached within grace window"}
```
Recovery is identical to `errored`: client resubmits the same prompt.
The reset path applies.
#### `session_deleted`
A super-admin DELETEd the session while the turn was in flight, OR an
older retry path hit the session-deleted-during-call branch. The error
JSON carries:
```json
{"code":"session_deleted_by_admin","message":"session deleted while turn in flight","attempt":<int>}
```
OR (from the AddTurn tx2 path):
```json
{"code":"session_deleted_during_call","message":"session was soft-deleted while FastAPI call was in flight","attempt":<int>}
```
Recovery: NONE. The session is permanent. The client must create a new
session and resubmit. The historical `session_deleted` row remains for
audit; LIST turns surfaces it with the original prompt and the error
JSON.
### Performance notes
`decorateSessionList` (the M2 list-sessions handler path) does N+1
scope reads: one `loadScope` round-trip per session in the list.
Default `limit=20`, hard cap `limit=200`. Under default load the
overhead is negligible; if a deployment surfaces latency on the list
endpoint, consider a single batch read of all scope rows for the
listed session ids. Plan v10 marked this as acceptable for v1.
### Rate-limit overrides
`RATE_LIMIT_ENDPOINT_OVERRIDES` is a JSON object keyed by
`METHOD /path` using **Echo's path template form** (`:clientId`,
`:sessionId`), NOT the OpenAPI `{clientId}` form. The middleware uses
`c.Path()` which returns Echo's registered path string; an override
keyed by `{clientId}` will silently fail to match.
Example for the chatbot turn endpoint:
```bash
RATE_LIMIT_ENDPOINT_OVERRIDES='{"POST /client/:clientId/bot/sessions/:sessionId/turns":{"global_rate":50,"global_burst":100,"rate":2,"burst":4}}'
```
**M6 operator-facing behavior change**: setting an override here NOW
throttles the actual request rate (per-(IP, route) budget). Before M6,
overrides only affected the `RateLimit` and `Retry-After` headers; the
Allow() decision used the default rate/burst. Plan §10 M6 + plan §11.
## References
- Plan: `plans/chatbot_plan_codex.v10.md` (design rationale and
authoritative spec).
- FastAPI swagger: `plans/chatbot.stuff/openapi.chatbot.swagger.json`.
- Sample `session_state` fixture: `plans/chatbot.stuff/sample.response.json`.
- Cloud FastAPI deployment (dev): see plan §11 for the URL and the
literal `X-API-Key` value.
## Known warts
These are not bugs; they are conscious tradeoffs documented for
maintainers.
1. **CHECK guard clause** in `bot_turns_status_fields_consistent`. The
plan §4 literal three-clause matrix is preserved verbatim; the
trailing `OR status NOT IN (...)` is an evaluation-order guard so
`bot_turns_status_values` owns the rejection of unknown enum values.
2. **`ErrAlreadyComplete` chain to `ErrOrdinalOutOfRange`** via custom
`Is()` so plan §11's concurrency-test loss set covers the case
where a loser's ordinal lands on the winner's just-completed row.
3. **`BotLabelRecord` vs `LabelRecord`** divergence (above).
4. **`schema=null`** uses field omission (omitempty) rather than
explicit JSON null. Clients should accept both.
5. **`M5DocumentID` alias** is cosmetically distinct from `DocumentID`
in the generated types. Both are `openapi_types.UUID`. Cosmetic only.
6. **Rate-limit override `:param` template** form, not OpenAPI
`{param}` form (above).
7. **N+1 scope reads** in session list (above).
8. **Grace-window floor of 60s** in `effectiveGraceSeconds`; the plan
§8 literal `2 * RequestTimeoutSeconds` is preserved on the chatbot
config method, but the service applies the floor at the call site
so test fixtures with `RequestTimeoutSeconds=1` (2s grace) do not
prematurely abandon legitimately in-flight turns.