Merged in feature/chatbot1 (pull request #223)

Chatbot functionality

* baseline working

* missing test file

* more tests
This commit is contained in:
Jay Brown
2026-05-07 20:56:18 +00:00
parent 17fc813823
commit 72de690894
62 changed files with 19150 additions and 445 deletions
+55 -1
View File
@@ -31,6 +31,47 @@ The API is organized into the following service groups:
| UISettingsService | Operations related to UI-scoped key-value settings (per user, client, or global) |
| SuperAdminSchemaService | Operations related to custom schema management (super_admin only) |
| CustomMetadataService | Operations related to per-document custom metadata (client_user and above) |
| BotService | Chatbot session/scope/turn endpoints plus read-only document schema and all-metadata bundles for `client_user`. See [Chatbot Guide](12-chatbot-guide.md) for the complete reference. |
| SuperAdminBotService | Super-admin chatbot session list/get/soft-delete across all users in a client. See [Chatbot Guide](12-chatbot-guide.md). |
## Chatbot Endpoints
The chatbot endpoints expose a synchronous facade over the Aaria FastAPI
agent service. Plan §3 mandates the singular `/client/...` form so the
existing Permit.io `client_user` policy applies. See
[Chatbot Guide](12-chatbot-guide.md) for the operator runbook,
configuration reference, AddTurn algorithm, and HTTP status mapping.
| Method | Path | OperationId | Auth |
| ------ | --------------------------------------------------------------------- | --------------------------------- | --------------------- |
| POST | `/client/{clientId}/bot/sessions` | `createBotSession` | client_user |
| GET | `/client/{clientId}/bot/sessions` | `listBotSessions` | client_user |
| GET | `/client/{clientId}/bot/sessions/{sessionId}` | `getBotSession` | client_user (owner) |
| PATCH | `/client/{clientId}/bot/sessions/{sessionId}/scope` | `patchBotSessionScope` | client_user (owner) |
| POST | `/client/{clientId}/bot/sessions/{sessionId}/turns` | `createBotTurn` | client_user (owner) |
| GET | `/client/{clientId}/bot/sessions/{sessionId}/turns` | `listBotTurns` | client_user (owner) |
| GET | `/client/{clientId}/documents/{documentId}/schema` | `getDocumentSchema` | client_user |
| GET | `/client/{clientId}/documents/{documentId}/all-metadata` | `getDocumentAllMetadata` | client_user |
| GET | `/super-admin/bot/sessions?clientId=...` | `listBotSessionsAsSuperAdmin` | super_admin |
| GET | `/super-admin/bot/sessions/{sessionId}?clientId=...` | `getBotSessionAsSuperAdmin` | super_admin |
| DELETE | `/super-admin/bot/sessions/{sessionId}?clientId=...` | `deleteBotSessionAsSuperAdmin` | super_admin |
`BotSessionResponse.lastTurn` is the derived `last_terminal_ordinal`
from `bot_turns` (highest ordinal whose status is not `in_flight`),
NOT the highest seen ordinal. `last_seen_ordinal` is computed but used
only internally by `AddTurn` for ordinal classification.
`DocumentSchemaResponse.schema` is omitted from the JSON wire response
when the document has no `custom_schema_id` binding (the field is
declared `omitempty`); the document still exists, only the optional
binding is absent. Plan §10 M5: HTTP 200 in this case, NOT 404.
The all-metadata endpoint emits `BotLabelRecord` (plain-string
`appliedBy`) instead of the existing `LabelRecord` (email-validated)
for the embedded labels list. Both wrap the same
`documentLabels.appliedBy varchar` column; the chatbot variant tolerates
non-email values that pre-date the email-typing convention. Long-term
cleanup is to relax `LabelRecord.AppliedBy` to plain string.
## Authentication and Authorization
@@ -148,7 +189,20 @@ RATE_LIMIT_DEFAULT_RATE=5 # req/s per IP
RATE_LIMIT_DEFAULT_BURST=10 # burst per IP
RATE_LIMIT_EXPIRES_IN=180 # seconds (3 minutes)
# Per-endpoint overrides (JSON format)
# Per-endpoint overrides (JSON format).
# Plan §10 M6 + §11: setting an override here NOW throttles the actual
# per-(IP, route) request rate (Allow() decision), not just the
# RateLimit / Retry-After response headers. Operators upgrading from
# pre-M6 deployments should review existing overrides for unintended
# tightening.
#
# Keys use Echo's path template form (`:clientId`, `:sessionId`), NOT
# the OpenAPI `{clientId}` form. The middleware uses c.Path() which
# returns Echo's registered path string.
#
# Example for the chatbot turn endpoint (illustrative — adjust before
# production rollout):
# RATE_LIMIT_ENDPOINT_OVERRIDES='{"POST /client/:clientId/bot/sessions/:sessionId/turns":{"global_rate":50,"global_burst":100,"rate":2,"burst":4}}'
RATE_LIMIT_ENDPOINT_OVERRIDES={}
```
+145
View File
@@ -878,6 +878,151 @@ CREATE TABLE results (
);
```
## Chatbot System (`bot_*` tables, migration 131)
Migration 131 adds four tables under the `bot_*` namespace for the
Aaria FastAPI agent service integration. New `bot_*` tables use
snake_case columns (matching migrations 127-130); existing tables
retain their camelCase columns (`documents.clientId`, `folders.parentId`,
etc.) and the chatbot queries reference them unchanged.
See [Chatbot Guide](12-chatbot-guide.md) for the operator runbook,
configuration reference, and API surface; this section covers the
schema only.
### `bot_sessions`
Owner-scoped chatbot conversations. Soft-deletable via `is_deleted`.
```sql
CREATE TABLE bot_sessions (
id uuid NOT NULL DEFAULT uuid_generate_v7(),
client_id varchar(255) NOT NULL REFERENCES clients(clientId) ON DELETE CASCADE,
created_by varchar(255) NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
title text NOT NULL DEFAULT '',
state jsonb NOT NULL DEFAULT '{}'::jsonb,
is_deleted boolean NOT NULL DEFAULT false,
CONSTRAINT pk_bot_sessions PRIMARY KEY (id)
);
CREATE INDEX idx_bot_sessions_client_user_active
ON bot_sessions (client_id, created_by, updated_at DESC)
WHERE is_deleted = false;
CREATE INDEX idx_bot_sessions_client_active
ON bot_sessions (client_id, updated_at DESC)
WHERE is_deleted = false;
```
The two partial indexes back the M2 owner-list and super-admin-list
queries respectively. The `WHERE is_deleted = false` clause keeps
soft-deleted rows out of the index so the planner satisfies the
list query without a sort.
`title` is auto-derived from the first turn's prompt (first 120 runes,
plan §6). The empty-title guard `WHERE title = ''` in `SetBotSessionTitle`
prevents clobbering on retry.
`state jsonb` mirrors the Aaria agent's session state. Plan §7 strip
rules apply on every successful turn: `cached_results` is removed
before persist; oversized payloads (>64 KiB) preserve the prior state.
There is no `last_turn` column. Queries derive:
- `last_seen_ordinal = COALESCE(MAX(ordinal), 0)`.
- `last_terminal_ordinal = COALESCE(MAX(ordinal) FILTER (WHERE status <> 'in_flight'), 0)`.
- API `lastTurn` returns `last_terminal_ordinal`.
### `bot_session_documents` and `bot_session_folders`
Per-session document/folder scope.
```sql
CREATE TABLE bot_session_documents (
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
CONSTRAINT pk_bot_session_documents PRIMARY KEY (session_id, document_id)
);
CREATE INDEX idx_bsd_document ON bot_session_documents(document_id);
CREATE TABLE bot_session_folders (
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
folder_id uuid NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
CONSTRAINT pk_bot_session_folders PRIMARY KEY (session_id, folder_id)
);
CREATE INDEX idx_bsf_folder ON bot_session_folders(folder_id);
```
The PATCH-scope handler validates that every scoped document and
folder belongs to the session client (M2). Empty scope tables mean
"all documents for the client" (plan §4 fallback).
### `bot_turns`
Append-only turn log. Status is one of five values; the partial unique
index serializes concurrent submissions.
```sql
CREATE TABLE bot_turns (
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
ordinal int NOT NULL CHECK (ordinal > 0),
prompt text NOT NULL,
completion text,
status text NOT NULL DEFAULT 'in_flight',
attempt_id uuid NOT NULL,
attempt_started_at timestamptz NOT NULL DEFAULT NOW(),
created_at timestamptz NOT NULL DEFAULT NOW(),
latency_ms int,
tokens_in int,
tokens_out int,
error jsonb,
CONSTRAINT pk_bot_turns PRIMARY KEY (session_id, ordinal),
CONSTRAINT bot_turns_status_values CHECK (
status IN ('in_flight', 'completed', 'errored', 'abandoned', 'session_deleted')
),
CONSTRAINT bot_turns_status_fields_consistent CHECK (
(status = 'in_flight' AND completion IS NULL AND error IS NULL)
OR (status = 'completed' AND completion IS NOT NULL AND error IS NULL)
OR (status IN ('errored', 'abandoned', 'session_deleted') AND completion IS NULL AND error IS NOT NULL)
OR status NOT IN ('in_flight', 'completed', 'errored', 'abandoned', 'session_deleted')
)
);
CREATE UNIQUE INDEX bot_turns_one_inflight_per_session
ON bot_turns (session_id)
WHERE status = 'in_flight';
CREATE INDEX idx_bot_turns_session_completed
ON bot_turns (session_id, ordinal)
WHERE status = 'completed';
```
**CHECK guard clause.** The `bot_turns_status_fields_consistent` CHECK
has a fourth OR clause `OR status NOT IN (...)` that is NOT in the
plan §4 literal. This is an evaluation-order guard: Postgres evaluates
table-level CHECK constraints in alphabetical order by constraint
name, so `bot_turns_status_fields_consistent` runs before
`bot_turns_status_values` for any INSERT/UPDATE. Without the guard,
an INSERT with an unknown status (e.g., `'frobnicated'`) would trip
`_status_fields_consistent` first (every documented branch evaluates
false), masking the cleaner `_status_values` rejection. With the
guard, unknown statuses pass `_status_fields_consistent` and
`_status_values` owns the rejection. For every documented status
value the plan §4 three-clause matrix still holds verbatim.
**Partial unique index `bot_turns_one_inflight_per_session`** enforces
"at most one in-flight turn per session" at the database layer. This
is the primary serialization mechanism for concurrent AddTurn submits;
the `AddTurn` algorithm catches the unique-violation pgconn.PgError
(SQLSTATE 23505) and maps to `ErrPriorTurnInFlight` (HTTP 409). Plan §6.
**`attempt_id`** is the compare-and-set token for tx2. The visible
retry count returned to clients comes from the FastAPI client's
`AttemptCount` field, not from `attempt_id`.
## Field Extractions System
The field extractions system stores structured data extracted from documents, supporting both single-value fields (1:1 relationship) and array fields (1:N relationship). This system was added in migrations 112-115.
+14
View File
@@ -103,8 +103,22 @@ AWS_S3_USE_PATH_STYLE=true
DISABLE_AUTH=true
LOG_LEVEL=DEBUG
BUCKET=<your-bucket-name>
# Chatbot (required+notEmpty for queryAPI to start). See
# `.env.example` and the Chatbot Guide for the full reference.
CHATBOT_SERVICE_URL=http://chatbot.local:8000
CHATBOT_API_KEY=replace_me
# Optional with defaults:
# CHATBOT_REQUEST_TIMEOUT_SECONDS=60
# CHATBOT_MAX_TURN_CHARS=2000
# CHATBOT_RECENT_TURNS=5
```
To enable the chatbot endpoints, set `CHATBOT_SERVICE_URL` and
`CHATBOT_API_KEY` in your environment; the queryAPI process will fail
to start without them. See [Chatbot Guide](12-chatbot-guide.md) for
the full operator reference.
## Troubleshooting
```bash
@@ -105,6 +105,41 @@ Defaults:
- Global: `500 rps`, burst `1000`
- Per-IP: `5 rps`, burst `10`
**M6 behavior change (plan §10 M6 + §11):** `RATE_LIMIT_ENDPOINT_OVERRIDES`
now changes ACTUAL throttling behavior, not only the `RateLimit` and
`Retry-After` response headers. Each (IP, route) tuple gets its own
budget when the route has an override entry. Operators upgrading from
pre-M6 deployments should review existing overrides for unintended
tightening. Override map keys use Echo's path template form
(`:clientId`, `:sessionId`), NOT the OpenAPI `{clientId}` form. See
[Chatbot Guide → Rate-limit overrides](12-chatbot-guide.md#rate-limit-overrides)
for the chatbot turn-endpoint example.
## Chatbot (FastAPI agent service)
From `internal/serviceconfig/chatbot/config.go`. Plan §8.
| Variable | Default | Validation | Purpose |
| --------------------------------- | --------------------- | ------------------------- | -------------------------------------------------------- |
| `CHATBOT_SERVICE_URL` | (required, notEmpty) | env-loader rejects empty | FastAPI base URL. |
| `CHATBOT_API_KEY` | (required, notEmpty) | env-loader rejects empty | Sent verbatim as the `X-API-Key` header. |
| `CHATBOT_REQUEST_TIMEOUT_SECONDS` | `60` | `>= 1` | Per-attempt FastAPI HTTP timeout. |
| `CHATBOT_MAX_TURN_CHARS` | `2000` | `>= 1` | Caps prompt and `answer.text` rune count. |
| `CHATBOT_RECENT_TURNS` | `5` | `1 <= x <= 5` | Conversation-history length and session-list preview cap. |
Validated at startup via `chatbot.ChatbotConfig.Validate()`, called
between `serviceconfig.InitializeConfig` and `cfg.InitializeAuthConfig`
in `cmd/queryAPI/main.go`. The process exits with status 1 on
validation failure.
Derived constants (not env-driven):
- `chatbot.StateMaxBytes = 64 * 1024` — session-state cap on persist.
- `chatbot.ChatbotConfig.InflightGraceSeconds() = 2 * RequestTimeoutSeconds`
per plan §8 literal.
See [Chatbot Guide](12-chatbot-guide.md) for the complete configuration
reference, including the grace-window floor.
## Logging and Observability
- `LOG_LEVEL` (default `INFO`)
+393
View File
@@ -0,0 +1,393 @@
# 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` | Caps prompt and `answer.text` rune count. |
| `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.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.
+9
View File
@@ -87,6 +87,15 @@ The diagram below shows the full API layer architecture — from HTTP endpoints
- Cascade deletion behavior (no special steps needed)
- Permit.io deployment note
12. **[Chatbot Guide](12-chatbot-guide.md)**
- Overview: queryAPI's facade over the Aaria FastAPI agent service
- Architecture: `bot_*` data model, AddTurn tx1+FastAPI+tx2 flow, state stripping
- Configuration reference: 5 `CHATBOT_*` env vars, derived values, validation
- API reference: 8 user routes + 3 super-admin routes; HTTP status mapping
- Operator runbook: failed-turn classifications (`errored`, `abandoned`, `session_deleted`) and recovery paths
- Rate-limit overrides: M6 behavior change and Echo `:param` template form
- Known warts and tradeoffs (CHECK guard, error chaining, label-record divergence, etc.)
## Quick Start
### For New Developers