"""Pydantic request/response schemas (API contract).""" from __future__ import annotations import uuid from datetime import datetime from typing import 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 ChatRequest(BaseModel): message: str = Field(min_length=1, max_length=4000) 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) — drives all UI gating.""" authenticated: bool role: str # "admin" | "anonymous" class SourceRef(BaseModel): source: str path: str title: str 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 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 indexed_at: str class DocList(BaseModel): """Response of ``GET /api/docs`` (empty list → designed empty state).""" documents: list[DocSummary] 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 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 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``. """ 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) @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``. """ id: uuid.UUID | None url: str added_at: datetime | None 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. """ id: uuid.UUID | None kind: Literal["git", "local"] url: str path: str | None added_at: datetime | 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 UploadOut(BaseModel): """The upload run's result fields (phase 49, task 02; phase 64, task 03). Phase 64 (task 03): ``POST /api/git-sources/upload`` answers 202 the moment the archive is on disk; these fields become the shape of ``GET /api/git-sources/upload/status`` ``detail`` on ``success`` — the uploaded source's name (filename minus the archive suffix) plus the SAME count keys as the admin sync's success ``detail`` (``files``, ``added``, ``updated``, ``unchanged``, ``pruned``, ``errors``, ``chunks`` — ``app.api.sync._run_sync``) and the ``overview`` flag: the Sources page renders the same "N added · N pruned" result line for both. """ source: str files: int added: int updated: int unchanged: int pruned: int errors: int chunks: int overview: 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 (owner-locked A2). The scan itself (unpack → swap → row upsert → model check → import → overview) runs in a background task behind ``GET /api/git-sources/upload/status``, whose ``success`` ``detail`` carries the :class:`UploadOut` fields. """ 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). """ name: str argument: str | None = None 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 ````-ish extra key) at the boundary with a 422, so nothing outside this shape can poison a restored conversation. """ model_config = ConfigDict(extra="forbid") who: Literal["user", "brain"] text: str = Field(min_length=1) sources: list[SourceRef] | None = None deflected: bool | None = None suggestions: list[str] | None = None thinking: str | None = None tools: list[ToolCall] | None = None 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. ``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) 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). ``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) 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/"`` 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/``) 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/.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=`` — 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 `` 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