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
+905
View File
@@ -50,6 +50,16 @@ tags:
(auditor is read-only). Requires the owning document to first be
bound to a client_metadata_schemas row via PATCH
/super-admin/documents/{id}/schema.
- name: BotService
description: |
Owner-scoped chatbot session/scope/turn endpoints for client_user.
Sessions are visible only to their creator within a client.
Routes use the singular `/client/{clientId}/...` form per plan §3.
- name: SuperAdminBotService
description: |
Super-admin-only chatbot session endpoints. Lists, reads, and
soft-deletes bot sessions across all users within a client.
Restricted to the super_admin Permit.io role.
paths:
@@ -3245,6 +3255,496 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/client/{clientId}/bot/sessions:
parameters:
- $ref: "#/components/parameters/BotClientID"
post:
operationId: createBotSession
tags:
- BotService
summary: Create a new chatbot session
description: >-
Creates an owner-scoped chatbot session for the authenticated
client_user. The Cognito subject (JWT sub) becomes createdBy.
Title defaults to empty until the first turn arrives.
security:
- jwtAuth: []
requestBody:
description: Empty body — accepted for forward compatibility.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBotSessionRequest"
responses:
"201":
description: Session created.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
get:
operationId: listBotSessions
tags:
- BotService
summary: List the caller's chatbot sessions for a client
description: >-
Returns sessions owned by the authenticated user under this
clientId, newest-updated first. Each session carries a recentTurns
preview whose length is min(request.limit, cfg.RecentTurns)
per plan §3. Plan §3 bounds: limit in [1, 200] and offset >= 0.
security:
- jwtAuth: []
parameters:
- name: limit
in: query
required: false
description: Maximum number of sessions to return (1-200, default 20).
schema:
type: integer
format: int32
minimum: 1
maximum: 200
default: 20
- name: offset
in: query
required: false
description: Number of sessions to skip for pagination (default 0).
schema:
type: integer
format: int32
minimum: 0
maximum: 2147483647
default: 0
responses:
"200":
description: Session list with embedded recent-turn previews.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionListResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/client/{clientId}/bot/sessions/{sessionId}:
parameters:
- $ref: "#/components/parameters/BotClientID"
- $ref: "#/components/parameters/BotSessionID"
get:
operationId: getBotSession
tags:
- BotService
summary: Get one chatbot session by id
description: >-
Owner-scoped read. Returns 404 when the session belongs to a
different client, a different user, or has been soft-deleted.
security:
- jwtAuth: []
responses:
"200":
description: Session details.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/client/{clientId}/bot/sessions/{sessionId}/turns:
parameters:
- $ref: "#/components/parameters/BotClientID"
- $ref: "#/components/parameters/BotSessionID"
post:
operationId: createBotTurn
tags:
- BotService
summary: Submit a new chatbot turn
description: >-
Posts a new turn to the chatbot session. Plan §6 algorithm: tx1
locks the session, abandons grace-expired in_flight rows, and
either inserts a fresh in_flight row at the next ordinal or
resets a terminal-failure row for retry. The FastAPI agent is
called outside any DB transaction, then tx2 records the
outcome via compare-and-set on attempt_id. Returns 201 on
success with the persisted turn body. FastAPI failures persist
an errored row and return 502 with the error body.
security:
- jwtAuth: []
requestBody:
description: Prompt to submit.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateBotTurnRequest"
responses:
"201":
description: Turn completed.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotTurnResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
"410":
description: Session was deleted while the agent call was in flight.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorMessage"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
"502":
description: FastAPI agent failed; persisted errored row in body.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorMessage"
get:
operationId: listBotTurns
tags:
- BotService
summary: List all turns for a chatbot session
description: >-
Returns every persisted turn for the owner-scoped session,
ordered ASC by ordinal. Plan §11.
security:
- jwtAuth: []
responses:
"200":
description: Ordered list of turns.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotTurnListResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/client/{clientId}/bot/sessions/{sessionId}/scope:
parameters:
- $ref: "#/components/parameters/BotClientID"
- $ref: "#/components/parameters/BotSessionID"
patch:
operationId: patchBotSessionScope
tags:
- BotService
summary: Patch a session's document/folder scope
description: >-
Adds or removes documents and folders from a session's scope.
Duplicate ids inside add/remove are deduplicated. An id appearing
in both add and remove returns 400 add_remove_conflict. A document
or folder belonging to a different client returns 400
document_cross_client / folder_cross_client. The persisted scope
is the union of (existing - remove) add.
security:
- jwtAuth: []
requestBody:
description: Add/remove sets for documents and folders.
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionScopePatchRequest"
responses:
"200":
description: Updated session, including the new scope.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/client/{clientId}/documents/{documentId}/schema:
parameters:
- $ref: "#/components/parameters/BotClientID"
- $ref: "#/components/parameters/M5DocumentID"
get:
operationId: getDocumentSchema
tags:
- BotService
summary: Get the bound custom schema for a document
description: >-
Returns the JSON Schema body bound to the document via
documents.custom_schema_id. The composite FK in migration 128
guarantees the schema row's client_id matches the document's
clientId, so the M1 GetDocumentEnrichedForClient query is
sufficient for cross-client rejection. When the document has no
schema bound, the endpoint returns 200 with `schema: null`. Plan
§3 + §10 M5.
security:
- jwtAuth: []
responses:
"200":
description: Schema body or null when unbound.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentSchemaResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/client/{clientId}/documents/{documentId}/all-metadata:
parameters:
- $ref: "#/components/parameters/BotClientID"
- $ref: "#/components/parameters/M5DocumentID"
get:
operationId: getDocumentAllMetadata
tags:
- BotService
summary: Get document metadata bundle (document + labels + customMetadata)
description: >-
Composes three M1 client-scoped reads
(GetDocumentEnrichedForClient, GetDocumentLabelsForClient,
GetCurrentDocumentCustomMetadataForClient) into a single bundle
for the chatbot UI. labels is always a non-null array; when the
document has no schema bound or no metadata rows, customMetadata
is null. Plan §3 + §10 M5.
security:
- jwtAuth: []
responses:
"200":
description: Document, labels, and customMetadata.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentAllMetadataResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/super-admin/bot/sessions:
get:
operationId: listBotSessionsAsSuperAdmin
tags:
- SuperAdminBotService
summary: List chatbot sessions for a client (super-admin)
description: >-
Super-admin client-scoped list. Filters on clientId and
is_deleted=false; no created_by predicate. limit in [1, 200],
offset >= 0.
security:
- jwtAuth: []
parameters:
- name: clientId
in: query
required: true
description: The client whose sessions to list.
schema:
type: string
maxLength: 255
- name: limit
in: query
required: false
description: Maximum number of sessions to return (1-200, default 20).
schema:
type: integer
format: int32
minimum: 1
maximum: 200
default: 20
- name: offset
in: query
required: false
description: Number of sessions to skip for pagination (default 0).
schema:
type: integer
format: int32
minimum: 0
maximum: 2147483647
default: 0
responses:
"200":
description: Session list across all users in the client.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionListResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
/super-admin/bot/sessions/{sessionId}:
parameters:
- $ref: "#/components/parameters/BotSessionID"
get:
operationId: getBotSessionAsSuperAdmin
tags:
- SuperAdminBotService
summary: Get one chatbot session by id (super-admin)
description: >-
Super-admin read. Filters on clientId (query param) and
is_deleted=false. Returns 404 when the session does not match.
security:
- jwtAuth: []
parameters:
- name: clientId
in: query
required: true
description: The client that owns the session.
schema:
type: string
maxLength: 255
responses:
"200":
description: Session details.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/json:
schema:
$ref: "#/components/schemas/BotSessionResponse"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
delete:
operationId: deleteBotSessionAsSuperAdmin
tags:
- SuperAdminBotService
summary: Soft-delete a chatbot session (super-admin)
description: >-
Sets is_deleted=true on the session. Any in-flight turns on the
session transition to status='session_deleted' so the M4 AddTurn
tx2 callback finds a terminal row.
security:
- jwtAuth: []
parameters:
- name: clientId
in: query
required: true
description: The client that owns the session.
schema:
type: string
maxLength: 255
responses:
"204":
description: Session soft-deleted.
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
"400":
$ref: "#/components/responses/InvalidRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalError"
components:
parameters:
ClientID:
@@ -3311,6 +3811,35 @@ components:
maxLength: 36
description: The UI setting ID.
BotClientID:
in: path
name: clientId
required: true
schema:
type: string
maxLength: 255
description: The owning client ID for chatbot routes.
BotSessionID:
in: path
name: sessionId
required: true
schema:
type: string
format: uuid
maxLength: 36
description: The chatbot session ID.
M5DocumentID:
in: path
name: documentId
required: true
schema:
type: string
format: uuid
maxLength: 36
description: The document ID for the M5 schema/all-metadata routes.
headers:
RateLimit:
schema:
@@ -6373,3 +6902,379 @@ components:
type: string
maxLength: 1024
description: Full S3 path (s3://bucket/key) of the orphaned source file
# ---------- Bot (chatbot) M2 schemas ----------
BotSessionScope:
description: |
The persisted document/folder scope of a chatbot session.
Empty arrays mean "all documents for the client" per plan §4.
Folder ids are returned as-is; folder expansion to documents
happens only in the M3 FastAPI payload assembly path, not here.
type: object
required:
- documentIds
- folderIds
properties:
documentIds:
type: array
maxItems: 10000
items:
type: string
format: uuid
maxLength: 36
description: Document ids in the session scope.
folderIds:
type: array
maxItems: 10000
items:
type: string
format: uuid
maxLength: 36
description: Folder ids in the session scope.
BotTurnPreview:
description: |
A single turn surfaced inside a session list response. M2 returns
only the columns the UI needs to render preview cards: ordinal,
prompt, optional completion, status, createdAt. Plan §5
ListLastTurnsForSessions returns the full row column set; the
response trims it to the preview view here.
type: object
required:
- ordinal
- prompt
- status
- createdAt
properties:
ordinal:
type: integer
format: int32
minimum: 1
maximum: 2147483647
description: 1-based turn ordinal within the session.
prompt:
type: string
maxLength: 65536
description: User prompt text.
completion:
type: string
maxLength: 65536
nullable: true
description: Assistant completion (markdown) when status=completed.
status:
type: string
enum:
- in_flight
- completed
- errored
- abandoned
- session_deleted
description: Turn status per plan §4.
createdAt:
type: string
format: date-time
maxLength: 50
description: When the turn was inserted.
BotSessionResponse:
description: |
The canonical chatbot session entity. Used as the response body
for create / get / patch and as the element type of the list
response. lastTurn is the derived last_terminal_ordinal from
plan §4 — never the in-flight ordinal.
type: object
required:
- id
- clientId
- createdBy
- title
- state
- lastTurn
- createdAt
- updatedAt
- scope
- recentTurns
properties:
id:
type: string
format: uuid
maxLength: 36
clientId:
type: string
maxLength: 255
createdBy:
type: string
maxLength: 255
description: Cognito subject (JWT sub) of the creator.
title:
type: string
maxLength: 255
description: Empty until the first turn arrives (plan §6).
state:
type: object
additionalProperties: true
maxProperties: 1024
description: Session state object persisted as jsonb.
lastTurn:
type: integer
format: int32
minimum: 0
maximum: 2147483647
description: |
Derived last_terminal_ordinal per plan §4. 0 when the session
has no terminal turns yet.
createdAt:
type: string
format: date-time
maxLength: 50
updatedAt:
type: string
format: date-time
maxLength: 50
scope:
$ref: "#/components/schemas/BotSessionScope"
recentTurns:
type: array
maxItems: 5
description: |
Most recent terminal turns. Populated by the list endpoint
with min(request.limit, cfg.RecentTurns) entries per plan §3.
Single-session reads (GET / POST / PATCH) emit an empty array.
items:
$ref: "#/components/schemas/BotTurnPreview"
BotSessionListResponse:
description: Paged list of chatbot sessions.
type: object
required:
- sessions
properties:
sessions:
type: array
maxItems: 200
items:
$ref: "#/components/schemas/BotSessionResponse"
CreateBotSessionRequest:
description: |
Empty body for now — accepted as JSON object to leave room for
future fields (title hint, initial scope) without a breaking
change. M2 ignores any properties the caller supplies.
type: object
additionalProperties: true
maxProperties: 32
BotSessionScopePatchRequest:
description: |
Add/remove sets for documents and folders. Each array is
deduplicated server-side. An id appearing in both add and remove
sets is rejected with 400 add_remove_conflict. Cross-client ids
are rejected with 400 document_cross_client / folder_cross_client.
type: object
properties:
addDocuments:
type: array
maxItems: 10000
items:
type: string
format: uuid
maxLength: 36
removeDocuments:
type: array
maxItems: 10000
items:
type: string
format: uuid
maxLength: 36
addFolders:
type: array
maxItems: 10000
items:
type: string
format: uuid
maxLength: 36
removeFolders:
type: array
maxItems: 10000
items:
type: string
format: uuid
maxLength: 36
# ---------- M4: bot turn schemas ----------
CreateBotTurnRequest:
description: |
Body for POST /client/{clientId}/bot/sessions/{sessionId}/turns.
The handler computes the target ordinal from session state via
prompt-match semantics (a retry of an existing terminal-failure
or completed turn reuses that row's ordinal). The wire body
therefore carries only the prompt; plan §6.
type: object
required:
- prompt
properties:
prompt:
type: string
maxLength: 65536
description: User prompt. Plan §6 mandates 1 <= rune count <= MaxTurnChars.
BotTurnResponse:
description: |
One persisted bot_turns row exposed on the wire. Status enum
mirrors the bot_turns_status_values CHECK constraint from
migration 131. error is the persisted JSON when status is
errored / abandoned / session_deleted, omitted otherwise.
type: object
required:
- ordinal
- prompt
- status
- createdAt
properties:
ordinal:
type: integer
format: int32
minimum: 1
maximum: 2147483647
prompt:
type: string
maxLength: 65536
completion:
type: string
maxLength: 65536
status:
type: string
enum:
- completed
- errored
- abandoned
- session_deleted
- in_flight
latencyMs:
type: integer
format: int32
minimum: 0
maximum: 2147483647
tokensIn:
type: integer
format: int32
minimum: 0
maximum: 2147483647
tokensOut:
type: integer
format: int32
minimum: 0
maximum: 2147483647
error:
type: object
additionalProperties: true
maxProperties: 32
createdAt:
type: string
format: date-time
maxLength: 50
BotTurnListResponse:
description: |
GET turns response. Turns are ordered ASC by ordinal.
type: object
required:
- turns
properties:
turns:
type: array
maxItems: 10000
items:
$ref: "#/components/schemas/BotTurnResponse"
# ---------- M5: read-only document endpoints ----------
DocumentSchemaResponse:
description: |
Body returned by GET /client/{clientId}/documents/{documentId}/schema.
schema is the JSON Schema body bound to the document, or null
when the document has no custom_schema_id binding. The
clientId / documentId echo the URL parameters so the caller can
reuse the response without parsing the path.
type: object
required:
- documentId
- clientId
properties:
documentId:
type: string
format: uuid
maxLength: 36
clientId:
type: string
maxLength: 255
schema:
type: object
additionalProperties: true
maxProperties: 1024
description: |
JSON Schema body, or null when the document has no schema
bound. The body round-trips verbatim from
client_metadata_schemas.schema_def.
DocumentAllMetadataResponse:
description: |
Bundle returned by GET /client/{clientId}/documents/{documentId}/all-metadata.
document is the M1 enriched-document shape; labels is always a
non-null array (empty when no labels are applied); customMetadata
is null when no schema is bound or no metadata rows exist.
type: object
required:
- document
- labels
properties:
document:
$ref: "#/components/schemas/DocumentEnriched"
labels:
type: array
maxItems: 10000
items:
$ref: "#/components/schemas/BotLabelRecord"
customMetadata:
type: object
additionalProperties: true
maxProperties: 1024
description: |
Latest custom metadata payload for the document, or null
when no schema is bound / no metadata rows exist.
BotLabelRecord:
description: |
Label record exposed by the M5 all-metadata endpoint. Mirrors
LabelRecord but treats appliedBy as a plain string so the
bundle survives non-email AppliedBy values (the underlying
documentLabels.appliedBy is varchar(255), not email-validated
at the database layer).
type: object
required:
- id
- documentId
- label
- appliedBy
- appliedAt
properties:
id:
type: string
format: uuid
maxLength: 36
documentId:
type: string
format: uuid
maxLength: 36
label:
type: string
maxLength: 255
appliedBy:
type: string
maxLength: 320
appliedAt:
type: string
format: date-time
maxLength: 50