Merged in feature/chatbot1 (pull request #223)
Chatbot functionality * baseline working * missing test file * more tests
This commit is contained in:
@@ -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={}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user