200 lines
5.3 KiB
Python
200 lines
5.3 KiB
Python
"""Pydantic request/response schemas (API contract)."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
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 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
|
|
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
|