All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
1397 lines
58 KiB
Python
1397 lines
58 KiB
Python
"""Pydantic request/response schemas (API contract)."""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import uuid
|
||
from datetime import datetime
|
||
from typing import Annotated, Any, Literal
|
||
|
||
from pydantic import (
|
||
BaseModel,
|
||
ConfigDict,
|
||
Field,
|
||
SerializerFunctionWrapHandler,
|
||
field_validator,
|
||
model_serializer,
|
||
)
|
||
|
||
|
||
class HealthResponse(BaseModel):
|
||
status: str
|
||
db: str
|
||
version: str
|
||
environment: str
|
||
|
||
|
||
class SuggestionList(BaseModel):
|
||
suggestions: list[str]
|
||
|
||
|
||
class HistoryTurn(BaseModel):
|
||
"""One prior chat turn the client sends with ``POST /api/chat``
|
||
(phase 74, TODO L4).
|
||
|
||
The endpoint stays stateless (A10): the client's ``bor.chat.v1``
|
||
conversation record (minus the question about to be asked) is
|
||
provided in the request body as ``history`` so a follow-up question
|
||
reaches the model together with the exchange so far — and, for
|
||
preserve-thinking models, with the prior brain turns' thinking (the
|
||
record has carried the ``thinking`` key since phase 17).
|
||
|
||
``thinking`` travels to the model as ``reasoning_content`` on the
|
||
assistant message (the wire convention :mod:`app.rag.llm` already
|
||
documents for the response side) — only when non-empty (A4).
|
||
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long
|
||
answers (and the scratchpads that ride along as ``thinking``) both
|
||
run to tens of kilobytes of text, so both share the same loose
|
||
boundary cap. These are boundary sanity caps only — the real
|
||
trimming budget is the settings pair ``history_max_turns`` /
|
||
``history_max_chars`` (``app.config``, A3: a capped-out turn is
|
||
dropped whole, never truncated). The old 4000-char text cap was
|
||
stricter than the 24_000-char default total budget and rejected
|
||
any second turn in a chat whose history held a long answer (422 —
|
||
found by the phase-42 E2E suite on the phase-76 shell).
|
||
"""
|
||
|
||
who: Literal["user", "brain"]
|
||
text: str = Field(min_length=1, max_length=32_000)
|
||
thinking: str | None = Field(default=None, max_length=32000)
|
||
|
||
|
||
class ChatRequest(BaseModel):
|
||
"""``POST /api/chat`` body: the current question plus the optional
|
||
prior turns (phase 74 — the client-provided history, stateless per
|
||
A10) and, since phase 123, the question's attached image (the
|
||
stored path — :attr:`image`).
|
||
|
||
``history`` is the client's earlier turns, oldest first (the
|
||
``bor.chat.v1`` record minus the current question); the mapper
|
||
(:func:`app.rag.prompts.history_to_messages`) trims it newest-first
|
||
against the settings budgets and maps it to model messages. The
|
||
schema-level ``max_length=100`` is a DoS sanity ceiling only — the
|
||
config budgets do the real trimming (A3). Absent or empty keeps the
|
||
request byte-identical to pre-phase-74: the model sees exactly the
|
||
two-message ``[system, user]`` request.
|
||
"""
|
||
|
||
message: str = Field(min_length=1, max_length=4000)
|
||
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
|
||
#: Phase 123 (TODO L6, LOCKED A5): the STORED PATH of the question's
|
||
#: attached image — ``/api/chat-images/<uuid4-hex>.<ext>`` exactly as
|
||
#: ``POST /api/chat-images`` returns it. NEVER a raw data URL: the
|
||
#: upload endpoint already did the size/mime enforcement, and
|
||
#: re-validating a 10 MB base64 string at the schema would be the
|
||
#: anti-pattern. ``None`` (the default, every text-only question)
|
||
#: keeps the turn byte-identical to pre-phase-123.
|
||
image: str | None = Field(default=None, max_length=500)
|
||
|
||
@field_validator("image")
|
||
@classmethod
|
||
def _image_is_stored_path(cls, v: str | None) -> str | None:
|
||
"""A set ``image`` must be the upload endpoint's stored-path
|
||
shape — ``/api/chat-images/<uuid4().hex>.<ext>`` (32 hex chars,
|
||
the six image extensions; the pattern is pinned in the
|
||
phase-123 design and mirrored by the serve route's filename
|
||
guard, ``app.api.chat_images``). A data URL, a bare filename, a
|
||
traversal, a wrong extension, or a mistyped uuid all 422 with
|
||
ONE fixed detail — no echo of the input."""
|
||
if v is None:
|
||
return v
|
||
if re.fullmatch(r"/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)", v) is None:
|
||
raise ValueError("image must be an uploaded chat image path")
|
||
return v
|
||
|
||
class LoginRequest(BaseModel):
|
||
"""``POST /api/login`` body (phase 16): the single admin's password.
|
||
|
||
An empty or wrong password is a 401 with one generic detail — never a
|
||
422 that would hint at input-shape differences.
|
||
"""
|
||
|
||
password: str = ""
|
||
|
||
|
||
class WhoamiResponse(BaseModel):
|
||
"""``GET /api/whoami`` (phase 16; phase 79 added the ``user`` role)
|
||
— drives all UI gating. ``authenticated`` is true for admin AND
|
||
user; the UI's admin-only surfaces key off ``role === "admin"``.
|
||
"""
|
||
|
||
authenticated: bool
|
||
role: str # "admin" | "user" | "anonymous"
|
||
|
||
|
||
class SourceRef(BaseModel):
|
||
"""One indexed document as cited in an answer (the SSE ``done``
|
||
event's ``sources`` item — PLAN §4 — and, since phase 50, the
|
||
``sources`` list of a saved-chat message).
|
||
|
||
Phase 83 (SEC-05): the caps mirror the ``documents`` column lengths
|
||
(``source`` ``String(120)``, ``path`` ``String(1000)``, ``title``
|
||
``String(500)``). A :class:`SourceRef` is built from ``documents``
|
||
rows server-side, so every server-built SSE ref fits by construction
|
||
(A3: the SSE path is provably unaffected); the cap binds only
|
||
client-saved refs — bounded at the boundary with a 422.
|
||
|
||
Phase 122 (task 05): ``image_url`` — the image BYTES route
|
||
(``/api/documents/<id>/image``) for a ref whose document is a
|
||
standalone image: the chat's sources block renders the compact
|
||
inline image from it (the "shown in the chat nicely" contract, TODO
|
||
L6). It is the ONLY new frame field (the doc id rides the path —
|
||
the same way the document content endpoint's ``(source, path)``
|
||
lookup does). For a TEXT document the field stays ``None`` and is
|
||
DROPPED on serialization (never ``null`` — the :class:`DocContent`
|
||
omission precedent), so a text-doc frame is byte-identical to
|
||
pre-phase. Server-built refs go through the shared
|
||
:func:`app.rag.retriever.source_ref_with_image` (one shape, both
|
||
frame tiers); a client-saved ref without the field parses with the
|
||
``None`` default (pre-phase saved chats restore unchanged).
|
||
"""
|
||
|
||
source: str = Field(max_length=120)
|
||
path: str = Field(max_length=1000)
|
||
title: str = Field(max_length=500)
|
||
#: Phase 122 (task 05) — see the class docstring. ``None`` (every
|
||
#: text doc, and every pre-phase client-saved ref) is omitted on
|
||
#: serialization — the key is ABSENT, never ``null``.
|
||
image_url: str | None = None
|
||
|
||
@model_serializer(mode="wrap")
|
||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||
data = handler(self)
|
||
if self.image_url is None:
|
||
data.pop("image_url", None)
|
||
return data
|
||
|
||
|
||
class ChatThinkingEvent(BaseModel):
|
||
"""SSE thinking event: one chunk of the model's reasoning (phase 17).
|
||
|
||
PLAN §4 extension (A15, owner permission 2026-08-23): frames of the
|
||
shape ``{type: "thinking", text: str}`` stream ahead of the
|
||
``delta`` frames in practice (the model reasons before it answers). The
|
||
client renders them in a collapsible "Thinking" block; the ``done``
|
||
event shape is unchanged and thinking text never travels on it.
|
||
Sibling of :class:`ChatErrorEvent`.
|
||
"""
|
||
|
||
type: str = "thinking"
|
||
text: str
|
||
|
||
|
||
class ChatToolEvent(BaseModel):
|
||
"""SSE frame for one agent tool call (phase 37, PLAN §4 extension).
|
||
|
||
A15 extension (owner permission 2026-08-26; the grep added in phase
|
||
68; phase 70 aligned the surface to the harness-trained
|
||
``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): a
|
||
grounded turn may call the server-side document tools (``ls`` /
|
||
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
|
||
call streams as ``{type: "tool", name: str, argument: str | null}``
|
||
ahead of the answer's ``delta`` frames. ``argument`` is the single
|
||
string argument the model passed — ``read``'s ``path`` (the combined
|
||
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
|
||
null (a non-string value, a model error the backend refuses, and an
|
||
omitted argument both yield null). The client renders each frame as
|
||
a "calling tool" line/state (phase 37 task 05); the ``delta`` /
|
||
``done`` shapes are unchanged — the read document is reflected in
|
||
``done.sources`` instead (a grep adds no source: it is a locator,
|
||
locked A5).
|
||
"""
|
||
|
||
type: Literal["tool"] = "tool"
|
||
name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names)
|
||
argument: str | None = None # the single string argument passed, or null
|
||
|
||
|
||
class ChatToolResultEvent(BaseModel):
|
||
"""SSE frame for one executed tool call whose result was truncated
|
||
(phase 95, ``TODO.md`` L5).
|
||
|
||
A15 extension (owner permission 2026-09-10 — recorded in the phase 95
|
||
overview ``00_phase.md``; PLAN.md is being redone by the owner): the
|
||
SSE event-type list grows from six to SEVEN — ``thinking``,
|
||
``tool``, ``retry``, ``delta``, ``done``, ``error`` and this
|
||
optional ``tool_result``. The frame is strictly ADDITIVE: existing
|
||
frames and clients are untouched (a client that does not know the
|
||
type simply ignores it), and it is emitted ONLY for a truncated
|
||
``read`` — one frame per truncated read, carrying the counts the UI
|
||
renders as "(truncated — showing N of M chars)". It always follows
|
||
the matching :class:`ChatToolEvent` frame for the same call (the
|
||
line is already on screen; the marker lands a beat later — the
|
||
phase-37/48 tool-line lifecycle is untouched). ``argument`` is the
|
||
combined ``source/path`` the model passed (identical to the matching
|
||
``tool`` frame's argument, so the client can match the two); a
|
||
non-truncated read streams NO frame of this type.
|
||
"""
|
||
|
||
type: str = "tool_result"
|
||
name: str # the tool that was executed (always "read" today)
|
||
argument: str | None = None # the model's argument (combined source/path)
|
||
truncated: bool = True # always True on a sent frame (the emission trigger)
|
||
chars_shown: int = Field(ge=0) # the cap kept (settings.read_max_chars)
|
||
chars_total: int = Field(ge=0) # the document's true length
|
||
|
||
|
||
class ChatDoneEvent(BaseModel):
|
||
"""Final SSE event of a chat turn: metadata for the finished answer.
|
||
|
||
Phase 113: ``related`` — the secondary related-doc tier (documents
|
||
that scored but did not clear the usefulness bar, LOCKED A2/A4). The
|
||
UI renders it as the de-emphasized "nearby docs" row — never a
|
||
citation chip — while ``sources`` stays the citation surface. The
|
||
field is ADDITIVE: old clients ignore unknown fields (PLAN §4 house
|
||
contract) and old frames without it parse with the default ``[]``.
|
||
"""
|
||
|
||
type: str = "done"
|
||
deflected: bool
|
||
sources: list[SourceRef]
|
||
related: list[SourceRef] = []
|
||
suggestions: list[str] = []
|
||
|
||
|
||
class ChatErrorEvent(BaseModel):
|
||
"""SSE error event: a turn that cannot complete (PLAN §4).
|
||
|
||
The client's loading-feedback state machine (phase 06) keys off this
|
||
exact shape — ``{type: "error", detail: str}`` — to flip to the error
|
||
state and re-enable the send button.
|
||
|
||
``hint`` (phase 114, TODO L6): an optional one-line clarification the
|
||
client shows IN PLACE of its default reachability hint when present
|
||
(the "question too long" frame: the embedding model WAS reached —
|
||
only the question's length is the problem). Additive: frames without
|
||
it serialize ``"hint": null``, and old clients ignore the field
|
||
(PLAN §4 house contract — the ``ChatDoneEvent.related`` pattern).
|
||
"""
|
||
|
||
type: str = "error"
|
||
detail: str
|
||
hint: str | None = None
|
||
|
||
|
||
class ChatRetryEvent(BaseModel):
|
||
"""SSE retry event: an LLM request is restarted before the first token
|
||
(phase 67, owner-locked 2026-09-01).
|
||
|
||
Sibling of :class:`ChatErrorEvent`, but transient — the client shows a
|
||
live status on the existing ``#send-status`` line (locked A4:
|
||
"Communication interrupted — retrying (n of N)…") and the send button
|
||
stays the Stop control; it never flips the state machine to error. It
|
||
is only ever sent when the failed attempt had NOT streamed a single
|
||
output frame yet (locked A2: no thinking/tool/delta emitted) — once
|
||
tokens are flowing, a failure is terminal (the ``error`` frame) and
|
||
this event cannot appear.
|
||
|
||
``attempt`` is the 1-based number of the attempt the endpoint is about
|
||
to try next (what the endpoint sends — the first failure of a
|
||
4-attempt budget carries ``attempt=2``); ``max_attempts`` is the total
|
||
attempt budget (``llm_retries + 1``).
|
||
"""
|
||
|
||
type: Literal["retry"] = "retry"
|
||
attempt: int
|
||
max_attempts: int
|
||
|
||
|
||
class DocSummary(BaseModel):
|
||
"""One indexed document as shown on the Sources page / API."""
|
||
|
||
id: str
|
||
source: str
|
||
path: str
|
||
title: str
|
||
chunks: int
|
||
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
|
||
#: from the row (the ``indexed_at`` style); the RAG view's file table
|
||
#: renders it as the ``Created`` column (before ``Indexed``).
|
||
created_at: str
|
||
indexed_at: str
|
||
|
||
|
||
class DocList(BaseModel):
|
||
"""Response of ``GET /api/docs`` (empty list → designed empty state)."""
|
||
|
||
documents: list[DocSummary]
|
||
|
||
|
||
class KbTreeFile(BaseModel):
|
||
"""One file node of the KB drill-down tree (phase 97, task 02).
|
||
|
||
``kind`` is the wire discriminator (``"file"`` — the ``00_phase.md``
|
||
JSON shape is the contract). ``path`` is SOURCE-RELATIVE (the RAG
|
||
view prefixes the source in its breadcrumb); ``title`` /
|
||
``chunks`` (content + ``is_summary`` chunks — the same count
|
||
``GET /api/docs`` returns) / ``created_at`` (phase 106, D8 — the
|
||
document's creation date) / ``indexed_at`` (ISO-8601) are verbatim
|
||
from the catalogue row the endpoint reads.
|
||
|
||
Image affordance (phase 122, task 04): for an ``is_image`` file node
|
||
the three ``is_image`` / ``image_url`` / ``summary`` keys ride the
|
||
node (the RAG view's Path cell renders the 48px thumbnail from
|
||
``image_url`` with ``alt = summary``). For a TEXT file node all
|
||
three are OMITTED from the wire shape (the
|
||
:func:`_drop_image_fields` omission rule — a pre-phase KB, which has
|
||
no image rows, serializes byte-identically to pre-phase, and the
|
||
RAG view reads ``is_image === true`` — it never expects the keys on
|
||
a text node). ``image_url`` is ``None``-omitted even on an image
|
||
node (a row whose ``image_path`` was lost renders the glyph
|
||
fallback); ``summary`` stays ``null`` on an image node (the alt
|
||
falls back to the title client-side — the fail-soft backfill
|
||
corner).
|
||
"""
|
||
|
||
kind: Literal["file"] = "file"
|
||
path: str
|
||
title: str
|
||
chunks: int = Field(ge=0)
|
||
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
|
||
#: from the catalogue row; the RAG view's file table renders it as
|
||
#: the ``Created`` column (before ``Indexed``).
|
||
created_at: str
|
||
indexed_at: str
|
||
#: Phase 122 (task 04) — true iff the file is an image document
|
||
#: (LOCKED A3). Omitted from a text node's wire shape (see the class
|
||
#: docstring); the builder sets it only for a node whose
|
||
#: ``(source, path)`` is in the endpoint's image-docs map.
|
||
is_image: bool = False
|
||
#: Phase 122 (task 04) — the image bytes route
|
||
#: (``/api/documents/<id>/image``) for the RAG view's thumbnail;
|
||
#: ``None`` (→ absent) when the row has no servable copy.
|
||
image_url: str | None = None
|
||
#: Phase 122 (task 04) — the document's summary (for an image doc,
|
||
#: the vision description — the thumbnail's ``alt``); ``None`` for a
|
||
#: fail-soft row still awaiting the backfill.
|
||
summary: str | None = None
|
||
|
||
@model_serializer(mode="wrap")
|
||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||
return _drop_image_fields(self, handler)
|
||
|
||
|
||
class KbTreeFolder(BaseModel):
|
||
"""One folder node of the KB drill-down tree (phase 97, task 02).
|
||
|
||
``path`` is the source-relative folder (never ``""`` — the SOURCE
|
||
node IS the root); ``documents`` is the recursive subtree count
|
||
(the phase-94 ``ls`` count rule: every path equal to the folder or
|
||
starting with ``folder + "/"`` — the file sharing a folder's name
|
||
counts); ``updated_at`` (phase 106, D9) is the subtree's MAX
|
||
document ``created_at`` — DERIVED in the pure tree builder as it
|
||
recurses, never stored (``null`` for a node with no documents at
|
||
all — the ``summary: str | None`` shape); ``summary`` is the stored
|
||
``folder_summaries`` row (AI or manual — any row) or null;
|
||
``children`` are the direct subfolders (path order) followed by the
|
||
direct files (catalog order) — the recursive union (Pydantic v2
|
||
resolves it with ``from __future__ import annotations``).
|
||
|
||
``summary_pending`` (phase 98, D3 — ONE concept): true iff this
|
||
folder's recursive count ≥ ``MIN_DOCS_PER_FOLDER`` (1) AND it has
|
||
NO stored ``folder_summaries`` row (AI or manual — any row) —
|
||
exactly the candidate the sync-time gap-fill regenerates (the
|
||
:func:`app.rag.folder_summaries.missing_folder_summaries` set).
|
||
A 0-document folder cannot exist (a folder is a catalogue prefix
|
||
only), so every existing folder with no stored row is pending —
|
||
single-file folders included.
|
||
"""
|
||
|
||
kind: Literal["folder"] = "folder"
|
||
path: str
|
||
documents: int = Field(ge=0)
|
||
#: The subtree's MAX document ``created_at`` (phase 106, D9 —
|
||
#: derived in the pure builder, never stored); ISO-8601, ``null``
|
||
#: for a node with no documents at all.
|
||
updated_at: str | None = None
|
||
summary: str | None = None
|
||
summary_pending: bool = False
|
||
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
|
||
|
||
|
||
class KbTreeSource(BaseModel):
|
||
"""One source node of the KB drill-down tree (phase 97, task 02).
|
||
|
||
Sources list the REGISTERED names first (registry order — each
|
||
always present, a registered 0-document source lists with
|
||
``documents: 0`` and no children), then the indexed-only sources
|
||
(alphabetical) — the phase-97 superset rule. ``documents`` is the
|
||
source's whole recursive count; ``updated_at`` (phase 106, D9) is
|
||
the source's subtree MAX document ``created_at`` — DERIVED in the
|
||
pure tree builder, never stored (``null`` for a 0-document source —
|
||
the ``summary: str | None`` shape); ``summary`` is the stored
|
||
``(source, "")`` source-root row or null; ``children`` are the
|
||
source's direct subfolders + direct files (same shape as a folder
|
||
node's).
|
||
|
||
``summary_pending`` (phase 98, D3 — ONE concept): true iff the
|
||
source's recursive ``documents`` count ≥ ``MIN_DOCS_PER_FOLDER``
|
||
(1) AND no stored ``(source, "")`` row (AI or manual — any row) —
|
||
exactly the source-root candidate the sync-time gap-fill
|
||
regenerates (the
|
||
:func:`app.rag.folder_summaries.missing_folder_summaries` set).
|
||
A registered 0-document source is never pending (there is nothing
|
||
to summarize); a single-document source IS pending while its root
|
||
row is absent.
|
||
"""
|
||
|
||
name: str
|
||
documents: int = Field(ge=0)
|
||
#: The source's subtree MAX document ``created_at`` (phase 106,
|
||
#: D9 — derived in the pure builder, never stored); ISO-8601,
|
||
#: ``null`` for a 0-document source.
|
||
updated_at: str | None = None
|
||
summary: str | None = None
|
||
summary_pending: bool = False
|
||
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
|
||
|
||
|
||
class KbTree(BaseModel):
|
||
"""Response of ``GET /api/docs/tree`` (phase 97, task 02).
|
||
|
||
The FULL recursive tree in ONE fetch — the RAG view (admin) drills
|
||
client-side, zero per-level fetches (the ``00_phase.md`` "The tree
|
||
endpoint" contract).
|
||
|
||
Every SOURCE and FOLDER node carries ``summary_pending`` (phase
|
||
98, D3): true iff its recursive document count ≥
|
||
``MIN_DOCS_PER_FOLDER`` (1) AND it has no stored ``folder_summaries``
|
||
row — exactly ``missing_folder_summaries``'s candidate set (the
|
||
marker never drifts from the gap-fill). FILE nodes carry no pending
|
||
flag (the file table has no description column) — but, since phase
|
||
122 (task 04), an image FILE node carries the thumbnail affordance
|
||
keys (``is_image`` / ``image_url`` / ``summary`` — omitted on text
|
||
nodes, see :class:`KbTreeFile`).
|
||
"""
|
||
|
||
sources: list[KbTreeSource]
|
||
|
||
|
||
class DocContent(BaseModel):
|
||
"""One indexed document's full content (feeds the viewer page, phase 10)."""
|
||
|
||
source: str
|
||
path: str
|
||
title: str
|
||
format: str
|
||
#: Lite-model summary (phase 30) — every A9 doc, markdown included
|
||
#: since phase 118 A2; None for pre-phase-30 rows, the fail-soft path
|
||
#: where summary generation failed, and until the phase-118 backfill
|
||
#: stores one on the next sync.
|
||
summary: str | None = None
|
||
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
|
||
#: from the row; the viewer's top meta row renders the ``Created``
|
||
#: badge from it (before the ``Indexed`` badge).
|
||
created_at: str
|
||
content: str
|
||
indexed_at: str
|
||
chunks: int
|
||
#: Phase 122 (task 04) — true iff the document is a standalone
|
||
#: image (LOCKED A3: ``content`` is the vision description, the
|
||
#: bytes live behind :attr:`image_url`). ALWAYS present on the wire
|
||
#: (text docs: ``false`` — the wire-additive key, the phase-106
|
||
#: ``created_at`` pattern); the viewer renders the ``<img>`` block
|
||
#: only when true.
|
||
is_image: bool = False
|
||
#: Phase 122 (task 04) — the image bytes route
|
||
#: (``/api/documents/<id>/image``) for the viewer's ``<img>``.
|
||
#: ABSENT from the wire for text docs (``None`` → dropped by the
|
||
#: serializer — never ``null``, the :func:`_drop_absent_share_url`
|
||
#: omission precedent); also absent for an image row whose
|
||
#: ``image_path`` is NULL (the viewer's onerror fallback covers it).
|
||
image_url: str | None = None
|
||
|
||
@model_serializer(mode="wrap")
|
||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||
data = handler(self)
|
||
if self.image_url is None:
|
||
data.pop("image_url", None)
|
||
return data
|
||
|
||
|
||
class SummaryUpdate(BaseModel):
|
||
"""``PATCH /api/documents/summary`` body (phase 57, task 01).
|
||
|
||
``source`` / ``path`` name the indexed document (the same pair the
|
||
public ``GET /api/documents/content`` looks up); ``summary`` is the
|
||
raw new text. The API strips it before storing — an
|
||
empty/whitespace-only value is the *clear* operation (a first-class
|
||
action, phase 57 D4), not a 422. Unconstrained on purpose: unknown
|
||
pairs must 404 as "document not found" (row-lookup semantics),
|
||
exactly like the public content endpoint.
|
||
"""
|
||
|
||
source: str
|
||
path: str
|
||
summary: str
|
||
|
||
|
||
class SummaryResult(BaseModel):
|
||
"""``PATCH /api/documents/summary`` response (phase 57, task 01).
|
||
|
||
``summary`` is the stored text after the change (``null`` after a
|
||
clear — the viewer's summary box hides on null) and ``chunks`` the
|
||
document's post-change total chunk count: an update leaves the
|
||
content chunks untouched (the count is unchanged — only the single
|
||
``is_summary`` chunk is replaced), a clear drops one (the
|
||
``is_summary`` chunk is deleted).
|
||
"""
|
||
|
||
source: str
|
||
path: str
|
||
summary: str | None
|
||
chunks: int
|
||
|
||
|
||
class DateUpdate(BaseModel):
|
||
"""``PATCH /api/documents/date`` body (phase 106, task 05, D7).
|
||
|
||
``source`` / ``path`` name the indexed document (the same pair the
|
||
public ``GET /api/documents/content`` looks up); ``date`` is the
|
||
owner's corrected creation date — an ISO date (``YYYY-MM-DD``) or a
|
||
full ISO datetime. **Null/absent is the CLEAR** (the "revert to
|
||
sync" operation, D7): the ``created_at_manual`` flag is dropped and
|
||
the stored date stands until the next sync refreshes it (the API is
|
||
DB-only — it cannot re-read the source). Unconstrained
|
||
``str | None`` on purpose: a MALFORMED non-null value 422s in the
|
||
handler (``datetime.fromisoformat``), so the error detail can name
|
||
the field; an unknown pair must 404 as "document not found"
|
||
(row-lookup semantics), exactly like the public content endpoint.
|
||
"""
|
||
|
||
source: str
|
||
path: str
|
||
date: str | None = None
|
||
|
||
|
||
class DateResult(BaseModel):
|
||
"""``PATCH /api/documents/date`` response (phase 106, task 05, D7).
|
||
|
||
Echoes the STORED state after the change: ``created_at`` is the
|
||
stored ISO-8601 value (a set stores the normalized parse — a
|
||
manually set FUTURE date folds to today, D3; a clear leaves the
|
||
stored date standing) and ``created_at_manual`` the flag (true
|
||
after a set, false after a clear). The viewer re-renders its
|
||
Created badge from exactly this echo — no second fetch.
|
||
"""
|
||
|
||
source: str
|
||
path: str
|
||
created_at: str
|
||
created_at_manual: bool
|
||
|
||
|
||
class FolderSummaryUpdate(BaseModel):
|
||
"""``PATCH /api/folders/summary`` body (phase 97, task 03).
|
||
|
||
``source`` / ``folder_path`` name the folder whose stored
|
||
description is edited — ``folder_path = ""`` is the SOURCE ROOT
|
||
(the phase-94 ``folder_summaries`` convention). ``summary`` is the
|
||
raw new text: the API strips it before storing, and an
|
||
empty/whitespace-only value is the *clear* operation (row deleted
|
||
— the reset path, the phase-57 analog), not a 422. Unconstrained on
|
||
purpose: an unknown source or a folder with no indexed descendant
|
||
must 404 (``source not found`` / ``folder not found`` —
|
||
registry/row-lookup semantics), and traversal strings such as
|
||
``../../etc`` are simply not prefixes of any indexed path (the
|
||
DB-only rule of the ``/documents/content`` lookup — no filesystem
|
||
access).
|
||
"""
|
||
|
||
source: str
|
||
folder_path: str
|
||
summary: str
|
||
|
||
|
||
class FolderSummaryResult(BaseModel):
|
||
"""``PATCH /api/folders/summary`` response (phase 97, task 03).
|
||
|
||
``summary`` is the stored text after the change — ``null`` after a
|
||
clear (the RAG view hides the level block / empties the description
|
||
cell on null). Every non-empty save was stored with
|
||
``manually_edited = true`` (the flag itself is not echoed — the
|
||
response mirrors the phase-57 ``SummaryResult`` shape minus the
|
||
chunk count, which a folder description has no role in).
|
||
"""
|
||
|
||
source: str
|
||
folder_path: str
|
||
summary: str | None
|
||
|
||
|
||
class SteeringNoteIn(BaseModel):
|
||
"""``POST /api/steering`` body: one tuning instruction (phase 15).
|
||
|
||
The note is trimmed *before* the length constraints run, so a
|
||
whitespace-only body is a 422 and a 2000-char note with surrounding
|
||
spaces still passes.
|
||
"""
|
||
|
||
note: str = Field(min_length=1, max_length=2000)
|
||
|
||
@field_validator("note", mode="before")
|
||
@classmethod
|
||
def _trim_note(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
|
||
class SteeringNoteUpdate(BaseModel):
|
||
"""``PUT /api/steering/{id}`` body: a new tuning instruction (phase 27).
|
||
|
||
Mirrors :class:`SteeringNoteIn` — the note is trimmed *before* the
|
||
length constraints run, so an empty/whitespace body is a 422 and a
|
||
full replacement that is ≤2000 chars after the trim still passes.
|
||
"""
|
||
|
||
note: str = Field(min_length=1, max_length=2000)
|
||
|
||
@field_validator("note", mode="before")
|
||
@classmethod
|
||
def _trim_note(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
|
||
class SteeringNote(BaseModel):
|
||
"""One stored steering note (API shape — ISO-8601 ``created_at``)."""
|
||
|
||
id: uuid.UUID
|
||
note: str
|
||
created_at: datetime
|
||
|
||
|
||
class SteeringNoteList(BaseModel):
|
||
"""``GET /api/steering`` response: all notes, newest first."""
|
||
|
||
notes: list[SteeringNote]
|
||
|
||
|
||
class GitSourceIn(BaseModel):
|
||
"""``POST /api/git-sources`` body (phase 35, task 02; ``kind``, phase 38).
|
||
|
||
``kind`` selects the source kind and which field carries its location:
|
||
|
||
* ``"git"`` (default) — ``url`` is the repo URL. Mirrors the
|
||
phase-35 contract: trimmed *before* the length constraints run, so a
|
||
whitespace-only body is a 422 and a URL with surrounding spaces is
|
||
stored clean. Shape validation (``https://``, ``ssh://``, ``git@``)
|
||
and the kind-field rules (url present, no path) happen in the API
|
||
layer so the 422/409 details stay fixed strings that never echo the
|
||
input (credential safety).
|
||
* ``"local"`` — ``path`` is an existing directory on the server.
|
||
Trimmed here; the API layer then ``expanduser()``s it and requires an
|
||
absolute existing directory (else 422 naming the path — the path is
|
||
not a secret, unlike a git URL) and no ``url``.
|
||
|
||
``ignore_paths`` (phase 89) is optional at create time (absent →
|
||
``[]``) and carries the RAW box lines — trimming/normalization happens
|
||
in the API layer, not the schema, so the A4 422 details stay fixed
|
||
strings (the router's credential-safety discipline, applied for
|
||
consistency).
|
||
|
||
``include_hidden`` (phase 105) is optional at create time (absent →
|
||
stored ``False`` — A4).
|
||
|
||
``token`` (phase 121, LOCKED A2) is the masked private-repo
|
||
credential from the Sources page: optional at create time (absent/
|
||
None = no credential — public repo), trimmed *before* the length
|
||
constraints run (the ``_trim_url`` precedent), max 500. It is a
|
||
WRITE-ONLY field — stored in the dedicated ``git_sources.token``
|
||
column and NEVER echoed back by any output shape (``GitSourceOut``
|
||
/ ``GitSourceRow`` carry no token field by contract).
|
||
"""
|
||
|
||
kind: Literal["git", "local"] = "git"
|
||
url: str | None = Field(default=None, min_length=1, max_length=500)
|
||
path: str | None = Field(default=None, min_length=1, max_length=2000)
|
||
ignore_paths: list[str] | None = Field(default=None)
|
||
include_hidden: bool | None = Field(default=None)
|
||
token: str | None = Field(default=None, max_length=500)
|
||
|
||
@field_validator("url", mode="before")
|
||
@classmethod
|
||
def _trim_url(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
@field_validator("path", mode="before")
|
||
@classmethod
|
||
def _trim_path(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
@field_validator("token", mode="before")
|
||
@classmethod
|
||
def _trim_token(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
|
||
class GitSourceOut(BaseModel):
|
||
"""One created git source as returned by ``POST`` (phase 35, task 02).
|
||
|
||
``id`` / ``added_at`` are non-null for a stored row. ``url`` is the
|
||
row's location column: the repo URL for ``kind=git`` rows and, for
|
||
``kind=local`` rows, the stored (expanded) directory path — the
|
||
phase-35 response shape is unchanged by phase 38, so a local 201
|
||
reports its path in ``url`` and the full row (``kind`` + ``path``)
|
||
via ``GET``. ``ignore_paths`` (phase 89) is the stored, normalized
|
||
list — non-null (a row created without it reports ``[]``).
|
||
``include_hidden`` (phase 105) is the stored flag — a row created
|
||
without it reports ``False`` (A4).
|
||
|
||
There is deliberately NO ``token`` field (phase 121, LOCKED A2):
|
||
the private-repo credential is stored in the dedicated
|
||
``git_sources.token`` column and is NEVER a response field — it
|
||
never reaches the UI or any API output. ``extra="forbid"`` makes
|
||
the omission a structural contract, not an accident: constructing
|
||
this model with a ``token`` key raises, so a regression that tries
|
||
to echo the credential back cannot even build the shape.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
id: uuid.UUID | None
|
||
url: str
|
||
added_at: datetime | None
|
||
ignore_paths: list[str]
|
||
include_hidden: bool
|
||
|
||
|
||
class GitSourceRow(BaseModel):
|
||
"""One row of ``GET /api/git-sources`` (phase 35; ``kind``/``path``,
|
||
phase 38, task 02).
|
||
|
||
``kind`` discriminates the row: git rows (and the git-only
|
||
``BOR_GIT_SOURCES`` env-fallback rows) carry ``url`` and
|
||
``path: null``; local rows carry ``path`` (the absolute directory,
|
||
expanded) and the same string in ``url`` (the table's NOT-NULL
|
||
location column). ``id`` / ``added_at`` are nullable: env-fallback
|
||
rows (table empty) carry neither. ``ignore_paths`` (phase 89) is the
|
||
row's stored, normalized list — env-fallback rows (no DB row to
|
||
store a list on) report ``[]``. ``include_hidden`` (phase 105) is
|
||
the row's stored flag — env-fallback rows (no DB row to store a flag
|
||
on) report ``False`` (the ``ignore_paths: []`` precedent).
|
||
|
||
There is deliberately NO ``token`` field (phase 121, LOCKED A2):
|
||
same contract as :class:`GitSourceOut` — the credential never
|
||
reaches the UI or any API output, and ``extra="forbid"`` makes the
|
||
omission structural (constructing a row with a ``token`` key
|
||
raises).
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
id: uuid.UUID | None
|
||
kind: Literal["git", "local"]
|
||
url: str
|
||
path: str | None
|
||
added_at: datetime | None
|
||
ignore_paths: list[str]
|
||
include_hidden: bool
|
||
|
||
|
||
class GitSourcePatchIn(BaseModel):
|
||
"""``PATCH /api/git-sources/{source_id}`` body (phase 89 A5;
|
||
extended phase 105).
|
||
|
||
Each field is independent and OPTIONAL: absent/None leaves the
|
||
row's value unchanged; PRESENT applies. ``ignore_paths`` when
|
||
present keeps the phase-89 A5 REPLACE semantics (the body list,
|
||
normalized + A4-validated, becomes the row's whole list — empty
|
||
list clears all; every pre-phase-105 client always sends the
|
||
list, so their behavior is byte-identical). ``include_hidden``
|
||
(phase 105) when present sets the stored flag. ``token`` (phase
|
||
121, LOCKED A2) is TRI-STATE — the three-way semantics the masked
|
||
edit field depends on: **absent/None = no change** (keep the row's
|
||
stored credential), **non-empty = replace**, **empty string =
|
||
clear** (the UI offers replace; clear exists for API completeness).
|
||
Trimmed *before* the length constraints run (the ``GitSourceIn``
|
||
``_trim_token`` precedent — whitespace-only counts as a clear),
|
||
max 500. All absent → 200 no-op (the row is untouched).
|
||
"""
|
||
|
||
ignore_paths: list[str] | None = Field(default=None)
|
||
include_hidden: bool | None = Field(default=None)
|
||
token: str | None = Field(default=None, max_length=500)
|
||
|
||
@field_validator("token", mode="before")
|
||
@classmethod
|
||
def _trim_token(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
|
||
class GitSourceList(BaseModel):
|
||
"""``GET /api/git-sources`` response (phase 35, task 02).
|
||
|
||
``from_env`` is True only when the ``git_sources`` table is empty and
|
||
the list comes from ``BOR_GIT_SOURCES`` (the phase's locked fallback
|
||
— env rows are git-only and report ``kind: "git"``, ``path: null``);
|
||
once the table has rows the env var is ignored and ``from_env`` is
|
||
False — the UI is the source of truth.
|
||
"""
|
||
|
||
sources: list[GitSourceRow]
|
||
from_env: bool
|
||
|
||
|
||
class UploadAccepted(BaseModel):
|
||
"""``POST /api/git-sources/upload`` 202 response (phase 64, task 03).
|
||
|
||
The archive is **safely on disk** — this is the "successfully
|
||
uploaded" moment the Sources page toasts on (phase 64 A2). The rest
|
||
(unpack → swap → row upsert — and nothing else: no model check, no
|
||
import, no overview refresh, phase 90 A1 — the scan is the RAG
|
||
page's "Sync sources" button's job) runs in a background task behind
|
||
``GET /api/git-sources/upload/status``, whose ``success`` ``detail``
|
||
carries the no-count ``{"message": "uploaded"}`` payload (phase 90
|
||
A2 — the status key set is unchanged; the UI composes the user
|
||
copy).
|
||
"""
|
||
|
||
detail: str = "upload received"
|
||
name: str
|
||
|
||
|
||
class ToolCall(BaseModel):
|
||
"""One agent tool-call record (the phase-37 ``tools`` record shape).
|
||
|
||
Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry
|
||
(PLAN §4 extension; phase 70): ``argument`` is the single string
|
||
argument the model passed (``read``'s combined ``source/path``,
|
||
``grep``'s pattern, ``ls``'s scope) or null. Stored inside
|
||
:class:`ChatMessage.tools` so a saved chat restores the "calling
|
||
tool" lines pixel-identical (phase 50). Saved chats persisting the
|
||
pre-phase-70 tool names still validate — ``name`` is opaque
|
||
(no migration, locked).
|
||
|
||
Phase 83 (SEC-05) bounds the anonymous write surface: ``name`` ≤
|
||
100 (a tool name longer than that is not a real call — the
|
||
``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the
|
||
combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2×
|
||
headroom for a grep pattern).
|
||
|
||
Phase 95 (task 02): the truncation marker the UI renders next to the
|
||
Reading line rides the SAME record — ``truncated`` (default False:
|
||
the pre-phase-95 shape) + the two non-negative counts. Small
|
||
additive fields with defaults, no migration (``ChatMessage.tools``
|
||
is JSON) — a saved chat written before phase 95 (no fields) validates
|
||
UNCHANGED (the phase-50 backward-compat rule) and renders without
|
||
the marker.
|
||
"""
|
||
|
||
name: str = Field(max_length=100)
|
||
argument: str | None = Field(default=None, max_length=2000)
|
||
truncated: bool = False
|
||
chars_shown: int | None = Field(default=None, ge=0)
|
||
chars_total: int | None = Field(default=None, ge=0)
|
||
|
||
|
||
#: One suggestion chip (phase 83, A1): a short deterministic string —
|
||
#: ``derive_suggestions``'s output runs to ~80 chars, so 200 chars is
|
||
#: the boundary sanity cap. Annotated alias: the JSON shape stays a
|
||
#: plain string (only the value bound is added).
|
||
_Chip = Annotated[str, Field(max_length=200)]
|
||
|
||
|
||
class ChatMessage(BaseModel):
|
||
"""One conversation record in the ``bor.chat.v1`` localStorage shape
|
||
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
|
||
|
||
``{who, text, sources?, related?, deflected?, suggestions?,
|
||
thinking?, tools?, stopped?, failed?, error?}`` — raw text, never
|
||
HTML, so a saved chat restores pixel-identical through the existing
|
||
``renderStoredMessage`` path.
|
||
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
|
||
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
|
||
422, so nothing outside this shape can poison a restored
|
||
conversation.
|
||
|
||
``related`` (phase 113, the related-doc tier the UI persists with
|
||
every grounded brain record — the restore path re-renders the
|
||
de-emphasized row from it): the same :class:`SourceRef` list shape
|
||
as ``sources``, the same cap. Its absence from this model was a
|
||
phase-113 omission — the ``extra="forbid"`` boundary 422'd every
|
||
done-time auto-save carrying the key (the A2 quiet-failure path
|
||
swallowed it), leaving grounded turns' brain messages unsaved;
|
||
pinned by ``tests/integration/test_chats_api.py`` (the full brain
|
||
record carries ``related``) and the E2E ``test_share_chat``
|
||
auto-save count.
|
||
|
||
Phase 83 (SEC-05) bounds the anonymous write surface (``POST/PUT
|
||
/api/chats`` is public — the row id is the credential, phase 55 A1):
|
||
``text`` / ``thinking`` carry :class:`HistoryTurn`'s 32 000 caps
|
||
(a single message longer than that is already rejected on the chat
|
||
path, so a saved chat can never legitimately carry more), and the
|
||
nested lists get length caps (``max_length``) sized to the realistic
|
||
``bor.chat.v1`` record the UI produces (``sources`` ≤ 20 — top-N docs
|
||
+ agent reads; ``related`` ≤ 20 — the related-doc tier, same ref
|
||
shape; ``suggestions`` ≤ 50 chips of ≤ 200 chars; ``tools``
|
||
≤ 50 — one entry per tool call, the round cap is 10). Only value
|
||
bounds were added — the accepted/rejected KEYS are unchanged.
|
||
|
||
Phase 120 (task 01, locked A1): a FAILED turn (network error, SSE
|
||
``error`` frame, stream drop — the client-side failure paths) stores
|
||
``{who: "brain", text: <detail or fallback>, failed: true,
|
||
error: <detail>}`` — the phase-48 ``stopped`` precedent: a marker +
|
||
the capped detail on the brain record itself, no separate error
|
||
table, no new API (``retryLastTurn``'s pop-the-last-brain-record
|
||
logic works on a failed record UNCHANGED — the question's user
|
||
record immediately precedes it). ``error`` is the persisted banner
|
||
detail, capped at 500 (the phase-83 value-bounds style). No
|
||
serializer change: ``None`` values flow as absent/None exactly like
|
||
``stopped`` today — the phase-50 byte-identical round-trip contract
|
||
covers the new keys automatically.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
who: Literal["user", "brain"]
|
||
text: str = Field(min_length=1, max_length=32_000)
|
||
sources: list[SourceRef] | None = Field(default=None, max_length=20)
|
||
related: list[SourceRef] | None = Field(default=None, max_length=20)
|
||
deflected: bool | None = None
|
||
suggestions: list[_Chip] | None = Field(default=None, max_length=50)
|
||
thinking: str | None = Field(default=None, max_length=32_000)
|
||
tools: list[ToolCall] | None = Field(default=None, max_length=50)
|
||
stopped: bool | None = None
|
||
# Phase 120 (task 01, locked A1): the failed-turn marker + the
|
||
# persisted error detail (the phase-48 ``stopped`` precedent).
|
||
failed: bool | None = None
|
||
error: str | None = Field(default=None, max_length=500)
|
||
# Phase 123 (task 01, LOCKED A5): the question's attached image —
|
||
# the STORED PATH (``/api/chat-images/<uuid>.<ext>``, from
|
||
# ``POST /api/chat-images``), on the USER record only: the
|
||
# attachment belongs to the question, so a brain record never
|
||
# carries it (the answer may cite the image doc's sources, but the
|
||
# attachment itself is the user's). The saved/shared shape gains
|
||
# this one optional key — omitted when ``None`` (the
|
||
# :meth:`_drop_image_when_absent` serializer below), so a text-only
|
||
# chat round-trips byte-identically to pre-phase-123 (the
|
||
# phase-50 contract; the phase-122 ``SourceRef.image_url``
|
||
# omission precedent). The path is ≤ 500 chars — no phase-83
|
||
# cap pressure (it is never a data URL, LOCKED A5).
|
||
image: str | None = Field(default=None, max_length=500)
|
||
|
||
@model_serializer(mode="wrap")
|
||
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||
"""The phase-123 image omission rule: ``image: None`` (every
|
||
text-only record, and every pre-phase-123 record) serializes
|
||
WITHOUT the key — ABSENT, never ``null`` — so the stored
|
||
``bor.chat.v1`` JSONB and the saved/shared wire shape stay
|
||
byte-identical to pre-phase for text-only chats (the phase-50
|
||
round-trip contract). A record WITH an image keeps the path —
|
||
the user bubble, the refreshed page, and the shared chat all
|
||
render it from the served route (the image is part of the
|
||
chat's content, so it rides the same public/credential-
|
||
is-the-id trust model)."""
|
||
data = handler(self)
|
||
if self.image is None:
|
||
data.pop("image", None)
|
||
return data
|
||
|
||
|
||
class SavedChatCreate(BaseModel):
|
||
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
|
||
task 02).
|
||
|
||
``title`` is optional: when absent or blank the API auto-titles the
|
||
row (the first user message's text, whitespace-collapsed, truncated
|
||
to 120 chars — the owner-locked convention). ``messages`` must be
|
||
non-empty — a saved chat with nothing to restore is meaningless —
|
||
and, since phase 83 (SEC-05), at most 200 items: well past any realistic
|
||
conversation (the chat history budget itself is 40 turns) and far
|
||
below a DoS-sized list on the anonymous write surface.
|
||
|
||
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
|
||
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
|
||
the fresh row before the INSERT, so one request saves AND shares
|
||
(the chat page's Share button on an unsaved conversation, the
|
||
save-then-share contract). The response then carries ``share_url``
|
||
(see :class:`SavedChatOut`). Default false — a plain Save is
|
||
unchanged by phase 51.
|
||
"""
|
||
|
||
title: str | None = Field(default=None, max_length=500)
|
||
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
|
||
share: bool = False
|
||
|
||
|
||
class SavedChatUpdate(BaseModel):
|
||
"""``PUT /api/chats/{chat_id}`` body (phase 50, task 02).
|
||
|
||
``messages`` is a full replacement (the re-Save upsert semantics —
|
||
re-Saving the same conversation updates the same row, never a new
|
||
one) and carries the same bounds as :class:`SavedChatCreate.messages`
|
||
(phase 83, SEC-05: non-empty, ≤ 200 items). ``title`` is replaced
|
||
only when supplied — an absent (or blank) ``title`` keeps the row's
|
||
current title.
|
||
"""
|
||
|
||
title: str | None = Field(default=None, max_length=500)
|
||
messages: list[ChatMessage] = Field(min_length=1, max_length=200)
|
||
|
||
|
||
def _drop_image_fields(model: KbTreeFile, handler: SerializerFunctionWrapHandler) -> Any:
|
||
"""The phase-122 (task 04) image-affordance omission rule for
|
||
:class:`KbTreeFile` file nodes: a TEXT node (``is_image`` false) drops
|
||
ALL three image keys — a pre-phase KB (no image rows) serializes
|
||
byte-identically to pre-phase, and the RAG view's file row stays the
|
||
pre-phase bare-link cell. An IMAGE node keeps ``is_image`` +
|
||
``summary`` (a ``null`` summary is meaningful — the alt falls back
|
||
client-side) and drops ``image_url`` only when ``None`` (the
|
||
row-without-a-copy corner — never a ``null`` on the wire, the
|
||
:func:`_drop_absent_share_url` precedent)."""
|
||
data = handler(model)
|
||
if not model.is_image:
|
||
for key in ("is_image", "image_url", "summary"):
|
||
data.pop(key, None)
|
||
elif data.get("image_url") is None:
|
||
data.pop("image_url", None)
|
||
return data
|
||
|
||
|
||
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
|
||
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
|
||
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
|
||
exposes no share surface at all, and the History column renders the
|
||
unshared state from the key's absence.
|
||
|
||
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
|
||
first, then only the TOP-LEVEL key is dropped when null. The
|
||
recursion matters — a route-level ``response_model_exclude_none``
|
||
would also drop the nested ``ChatMessage`` nulls (``sources: null``
|
||
and friends), which the byte-identical round-trip contract (phase
|
||
50) forbids.
|
||
"""
|
||
data = handler(model)
|
||
if data.get("share_url") is None:
|
||
data.pop("share_url", None)
|
||
return data
|
||
|
||
|
||
class SavedChatOut(BaseModel):
|
||
"""One saved chat, full payload (create/get/put response, phase 50;
|
||
``share_url``, phase 51 task 02; ``stale``, phase 53 task 03).
|
||
|
||
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
|
||
— the restore path is pixel-identical by construction.
|
||
|
||
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
|
||
shared, ABSENT from the JSON when unshared (``None`` → dropped by
|
||
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
|
||
|
||
``stale`` (phase 53): true iff the row's ``sources_version`` stamp
|
||
is behind the current ``sources_meta`` generation — the answer
|
||
predates the latest KB-changing sync. Computed server-side (the
|
||
client never does staleness math); ``SharedChatOut`` deliberately
|
||
carries no staleness surface (the public snapshot is frozen by
|
||
design, phase 51).
|
||
"""
|
||
|
||
id: uuid.UUID
|
||
title: str
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
message_count: int
|
||
messages: list[ChatMessage]
|
||
share_url: str | None = None
|
||
#: Required (no default): the API must always compute staleness
|
||
#: server-side — there is no wire shape without the flag.
|
||
stale: bool
|
||
|
||
@model_serializer(mode="wrap")
|
||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||
return _drop_absent_share_url(self, handler)
|
||
|
||
|
||
class SavedChatRow(BaseModel):
|
||
"""One row of ``GET /api/chats`` (the History page's list shape,
|
||
phase 50; ``share_url``, phase 51 task 02).
|
||
|
||
No payloads in the list — the row carries only what the table needs
|
||
(Title, Messages count, Updated). ``share_url`` is populated here so
|
||
the History page's Share column renders straight from ``GET
|
||
/api/chats`` — no second fetch per row (``None`` → absent, the same
|
||
omission rule as :class:`SavedChatOut`). ``stale`` (phase 53) feeds
|
||
the History page's Stale column the same way: one ``GET`` powers
|
||
every column.
|
||
"""
|
||
|
||
id: uuid.UUID
|
||
title: str
|
||
updated_at: datetime
|
||
message_count: int
|
||
share_url: str | None = None
|
||
#: Required (no default) — see :attr:`SavedChatOut.stale`.
|
||
stale: bool
|
||
|
||
@model_serializer(mode="wrap")
|
||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||
return _drop_absent_share_url(self, handler)
|
||
|
||
|
||
class SavedChatList(BaseModel):
|
||
"""``GET /api/chats`` response: saved chats, latest activity first
|
||
(``updated_at desc, id desc``)."""
|
||
|
||
chats: list[SavedChatRow]
|
||
|
||
|
||
class SharedChatOut(BaseModel):
|
||
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
|
||
read shape of a shared chat.
|
||
|
||
Deliberately minimal: ``title`` + ``messages`` only. No id, no
|
||
timestamps, no token, no ``message_count`` — a shared chat is a
|
||
content snapshot, not a handle: nothing in the body can be turned
|
||
back into an admin-surface request, and the token itself never
|
||
round-trips (it is the URL, not data).
|
||
"""
|
||
|
||
title: str
|
||
messages: list[ChatMessage]
|
||
|
||
|
||
class ShareOut(BaseModel):
|
||
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
|
||
|
||
``share_url`` is the path (``/shared/<token>``) the UI copies into
|
||
the clipboard — the owner's own origin supplies the scheme/host.
|
||
Idempotent: a re-share returns the existing, unchanged token.
|
||
"""
|
||
|
||
chat_id: uuid.UUID
|
||
share_url: str
|
||
|
||
|
||
class UnshareOut(BaseModel):
|
||
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
|
||
|
||
``shared: false`` is reported unconditionally — the endpoint is
|
||
idempotent, so an already-unshared chat unshares cleanly (200).
|
||
"""
|
||
|
||
chat_id: uuid.UUID
|
||
shared: bool
|
||
|
||
|
||
class DocDraftCreate(BaseModel):
|
||
"""``POST /api/doc-drafts`` body (phase 59, task 02): one completed
|
||
chat answer about to become documentation.
|
||
|
||
``title`` arrives client-side as the last user question
|
||
(whitespace-collapsed, ≤120 chars — the chat auto-title convention,
|
||
phase 50); ``path`` as ``docs/<slug>.md``; ``body`` is the answer's
|
||
raw markdown (never HTML — the ``bor.chat.v1`` record's ``text``,
|
||
the phase-50/51 round-trip convention). The path guard-rails (task
|
||
02 — repo-relative, no ``..``, no absolute path) run in the API
|
||
layer so the 422 details stay fixed strings; the max lengths mirror
|
||
the ``documents`` table (title 500, path 1000).
|
||
"""
|
||
|
||
title: str = Field(min_length=1, max_length=500)
|
||
path: str = Field(min_length=1, max_length=1000)
|
||
body: str = Field(min_length=1)
|
||
|
||
|
||
class DocDraftUpdate(BaseModel):
|
||
"""``PUT /api/doc-drafts/{token}`` body (phase 59, task 02): a
|
||
partial update — each field is replaced only when supplied (absent
|
||
keeps the row's current value; present must be non-empty — the
|
||
``SavedChatUpdate`` optional-title pattern, extended to all three
|
||
editable fields). The same path guard-rails as create run in the
|
||
API layer when ``path`` is supplied.
|
||
"""
|
||
|
||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||
path: str | None = Field(default=None, min_length=1, max_length=1000)
|
||
body: str | None = Field(default=None, min_length=1)
|
||
|
||
|
||
class DocDraft(BaseModel):
|
||
"""One draft row, full payload (create/get/put response, phase 59).
|
||
|
||
``token`` is the URL credential (``/doc-edit.html?draft=<token>``
|
||
— the unguessable ``uuid4``, the share-token trust model, phase
|
||
51). ``status`` is ``draft`` until the push endpoint commits +
|
||
pushes the file, then ``pushed`` with ``branch`` / ``commit_sha``
|
||
recorded (both NULL while still a draft). Datetimes serialize
|
||
ISO-8601 on the wire (pydantic default).
|
||
"""
|
||
|
||
token: uuid.UUID
|
||
title: str
|
||
path: str
|
||
body: str
|
||
status: str
|
||
branch: str | None = None
|
||
commit_sha: str | None = None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
class DocDraftPushed(BaseModel):
|
||
"""``POST /api/doc-drafts/{token}/push`` success response (phase 59,
|
||
task 04): the commit + ``git push --ff-only`` landed — ``branch``
|
||
is the ``BOR_DOCS_BRANCH`` name and ``commit_sha`` the pushed
|
||
branch's new HEAD (the edit screen's branch + sha feedback; it must
|
||
equal ``git rev-parse <branch>`` in the repo — the E2E source of
|
||
truth is the bare repo's state, not the UI alone).
|
||
"""
|
||
|
||
status: Literal["pushed"] = "pushed"
|
||
branch: str
|
||
commit_sha: str
|
||
|
||
|
||
class TokenCreateRequest(BaseModel):
|
||
"""``POST /api/tokens`` body (phase 79, task 02): one named token
|
||
to generate and hand out.
|
||
|
||
``label`` is the hand-out name (e.g. "alice") — display-only, NOT
|
||
unique (two tokens may share a label). Trimmed *before* the length
|
||
constraints run, so a whitespace-only body is a 422 and a label with
|
||
surrounding spaces is stored clean (the ``SteeringNoteIn`` house
|
||
``ValueError`` pattern — fail loud at the boundary).
|
||
"""
|
||
|
||
label: str = Field(min_length=1, max_length=120)
|
||
|
||
@field_validator("label", mode="before")
|
||
@classmethod
|
||
def _trim_label(cls, v: object) -> object:
|
||
return v.strip() if isinstance(v, str) else v
|
||
|
||
|
||
class TokenCreated(BaseModel):
|
||
"""``POST /api/tokens`` 201 response (phase 79, task 02).
|
||
|
||
The ONLY schema in the codebase that carries the plaintext ``token``
|
||
— the wire moment it exists exactly once (A4). Every other response
|
||
shape (the list row, whoami, …) exposes display fields only: the
|
||
stored credential is the hash, and the hash itself is never a wire
|
||
field either.
|
||
"""
|
||
|
||
id: uuid.UUID
|
||
label: str
|
||
token: str
|
||
created_at: datetime
|
||
|
||
|
||
class TokenListItem(BaseModel):
|
||
"""One row of ``GET /api/tokens`` (phase 79, task 02).
|
||
|
||
Deliberately secret-free: NO ``token`` field and NO ``token_hash``
|
||
field exist on this shape — the list never carries a credential in
|
||
either form (A4). ``revoked`` is derived server-side from
|
||
``revoked_at is not None`` (the UI renders the Active/Revoked state
|
||
from the flag, not the timestamp).
|
||
|
||
``revoked_at`` (phase 101, D5 — wire-additive, defaults null): null
|
||
while the token is active; once revoked, the ISO-8601 timestamp the
|
||
revoked table renders (the revocation date). The flag stays the
|
||
client's table-split key; this field is display data only.
|
||
"""
|
||
|
||
id: uuid.UUID
|
||
label: str
|
||
created_at: datetime
|
||
last_used_at: datetime | None
|
||
revoked: bool
|
||
revoked_at: datetime | None = None
|
||
|
||
|
||
class TokenList(BaseModel):
|
||
"""``GET /api/tokens`` response: all tokens, newest first
|
||
(``created_at desc, id desc``)."""
|
||
|
||
tokens: list[TokenListItem]
|
||
|
||
|
||
class TokenAuthRequest(BaseModel):
|
||
"""``POST /api/token-auth`` body (phase 79, task 03): the plaintext
|
||
token a handed-out user presents at the in-app gate.
|
||
|
||
Deliberately NO min-length validator: an empty/whitespace token is a
|
||
MALFORMED login attempt — the endpoint 401s ``invalid token`` (the
|
||
phase-16 generic-401 pattern, one message for every failure: no
|
||
enumeration). A 422 here would hint at input-shape differences on a
|
||
credential endpoint, so the shape is just ``str`` and the endpoint
|
||
owns the ``token.strip()`` check.
|
||
"""
|
||
|
||
token: str
|
||
|
||
|
||
class UiSettingsIn(BaseModel):
|
||
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
|
||
replacement of the single ``ui_settings`` row.
|
||
|
||
Every field is ``str | None`` — present = a new value (strings are
|
||
trimmed; empty after the trim is the CLEAR operation, stored as
|
||
NULL; colors must be ``#rrggbb`` and are lowercased on store),
|
||
``null``/absent = "back to the default" (stored as NULL — the Reset
|
||
button's all-null PUT is exactly the "defaults" operation). The
|
||
API layer runs the trim/length/hex validation so the 422 details
|
||
name the offending field (the house fixed-detail style); the
|
||
built-in→NULL normalization (a color equal to its built-in is
|
||
stored as NULL — "save the defaults" must leave the row empty, the
|
||
no-op injection contract) happens there too, next to the palette
|
||
it normalizes against.
|
||
"""
|
||
|
||
app_name: str | None = None
|
||
input_placeholder: str | None = None
|
||
footer_text: str | None = None
|
||
bg: str | None = None
|
||
surface: str | None = None
|
||
ink: str | None = None
|
||
ink_soft: str | None = None
|
||
line: str | None = None
|
||
grid_line: str | None = None
|
||
brand: str | None = None
|
||
brand_soft: str | None = None
|
||
brand_ink: str | None = None
|
||
# The 8 semantic state colors (phase 93 — B3 revised): the same
|
||
# contract as the 9 identity colors (``null`` = back to the
|
||
# built-in; ``#rrggbb`` is validated + lowercased by the API).
|
||
ok_bg: str | None = None
|
||
ok_ink: str | None = None
|
||
err_bg: str | None = None
|
||
err_ink: str | None = None
|
||
err_line: str | None = None
|
||
accent_bg: str | None = None
|
||
accent_ink: str | None = None
|
||
accent_line: str | None = None
|
||
|
||
|
||
class UiSettingsOut(BaseModel):
|
||
"""Effective UI settings (``GET``/``PUT /api/ui-settings`` response,
|
||
phase 91, task 01; 17 colors since phase 93).
|
||
|
||
All 20 values (3 strings + 17 palette colors — 9 identity + 8
|
||
semantic state), all non-null strings: the resolver's
|
||
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
|
||
the LIVE theme — a fresh (row-missing) deployment reports the env
|
||
strings and the built-in palette (the semantic colors report their
|
||
built-ins — the effective values, the NULL = built-in rule).
|
||
"""
|
||
|
||
app_name: str
|
||
input_placeholder: str
|
||
footer_text: str
|
||
bg: str
|
||
surface: str
|
||
ink: str
|
||
ink_soft: str
|
||
line: str
|
||
grid_line: str
|
||
brand: str
|
||
brand_soft: str
|
||
brand_ink: str
|
||
ok_bg: str
|
||
ok_ink: str
|
||
err_bg: str
|
||
err_ink: str
|
||
err_line: str
|
||
accent_bg: str
|
||
accent_ink: str
|
||
accent_line: str
|