"""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): a grounded turn may call the server-side document tools (``list_documents`` / ``read_document``, 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 read document's ``"source/path"`` for ``read_document`` and null otherwise. 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. """ type: Literal["tool"] = "tool" name: str # "list_documents" | "read_document" argument: str | None = None # "source/path" for read_document 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 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 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): """``POST /api/git-sources/upload`` response (phase 49, task 02). 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 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): ``argument`` is the read document's ``"source/path"`` for ``read_document`` and null otherwise. Stored inside :class:`ChatMessage.tools` so a saved chat restores the "calling tool" lines pixel-identical (phase 50). """ 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). ``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). """ id: uuid.UUID title: str created_at: datetime updated_at: datetime message_count: int messages: list[ChatMessage] share_url: str | None = None @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`). """ id: uuid.UUID title: str updated_at: datetime message_count: int share_url: str | None = None @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