"""Pydantic request/response schemas (API contract).""" from __future__ import annotations import uuid from datetime import datetime from typing import Literal from pydantic import BaseModel, Field, field_validator 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: one repo URL (phase 35, task 02). Mirrors :class:`SteeringNoteIn` — the URL is 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@``) happens in the API layer so the 422 detail can be one generic string that never echoes the input. """ url: str = Field(min_length=1, 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 class GitSourceOut(BaseModel): """One git source as returned by the API (phase 35, task 02). ``id`` / ``added_at`` are nullable: env-fallback rows (table empty → the list comes from ``BOR_GIT_SOURCES``) carry neither, only a URL. """ id: uuid.UUID | None url: str 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); once the table has rows the env var is ignored and ``from_env`` is False — the UI is the source of truth. """ sources: list[GitSourceOut] from_env: bool