Relax the phase-94 folder-summary scope rule from ≥ 2 documents to ≥ 1: a folder (or source root) is a candidate while ANY document lives under it, so single-file folders and single-file source roots get their own lite-written description. A row is now pruned only when its folder loses its last document (vanishes from the catalogue). The constant is the single source of truth, so the flip propagates to the generator's candidate set, the prune pass, the missing_folder_summaries gap probe (the next sync self-heals the new gaps), and the KB-tree summary_pending markers (1-doc folders / sources now read "Summary pending" until their row lands). Docstrings/comments across app/, scripts/import_docs.py, and the E2E fixtures updated to the ≥ 1 wording. Unit + integration tests updated to the new semantics (the pruned-below-minimum scenario is now a folder losing its LAST doc; single-doc folders are pinned as candidates/pending). Full suite: 2314 passed, app coverage 99%; ruff + pyright clean; folder-summary E2E stories pass in isolation (ls_tree_drilldown, sync_summary_visibility, kb_tree, kb_tree_nav, document_dates, oneshot_llm_retry).
1149 lines
44 KiB
Python
1149 lines
44 KiB
Python
"""Pydantic request/response schemas (API contract)."""
|
||
from __future__ import annotations
|
||
|
||
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).
|
||
|
||
``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)
|
||
|
||
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.
|
||
"""
|
||
|
||
source: str = Field(max_length=120)
|
||
path: str = Field(max_length=1000)
|
||
title: str = Field(max_length=500)
|
||
|
||
|
||
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."""
|
||
|
||
type: str = "done"
|
||
deflected: bool
|
||
sources: 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.
|
||
"""
|
||
|
||
type: str = "error"
|
||
detail: str
|
||
|
||
|
||
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.
|
||
"""
|
||
|
||
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
|
||
|
||
|
||
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 flag
|
||
(the file table has no description column).
|
||
"""
|
||
|
||
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) — non-markdown A9 docs only; None for
|
||
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
|
||
#: summary generation failed but the document was still indexed.
|
||
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
|
||
|
||
|
||
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).
|
||
"""
|
||
|
||
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)
|
||
|
||
@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
|
||
|
||
|
||
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).
|
||
"""
|
||
|
||
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).
|
||
"""
|
||
|
||
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. Both absent →
|
||
200 no-op (the row is untouched).
|
||
"""
|
||
|
||
ignore_paths: list[str] | None = Field(default=None)
|
||
include_hidden: bool | None = Field(default=None)
|
||
|
||
|
||
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?, deflected?, suggestions?, thinking?, tools?,
|
||
stopped?}`` — 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.
|
||
|
||
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; ``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.
|
||
"""
|
||
|
||
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)
|
||
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
|
||
|
||
|
||
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_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
|