Files
brain-of-reese/app/schemas.py
T
ducoterra 94d7228510 feat(admin): local directory sources — kind/path on git_sources, combined sync + import, page form + badges
An existing, non-git directory is now a first-class source alongside
the git repos: one table (git_sources + kind discriminator — A13
reversible migration), one admin page, one Sync button (phase locked
decisions; the phase-35 table is extended, not duplicated). The DB is
the local-source registry — no env var for local paths;
BOR_GIT_SOURCES stays a git-only empty-table fallback.

Migration 0007 (reversible, up/down integration-tested):
git_sources.kind TEXT NOT NULL DEFAULT 'git' + ck_git_sources_kind
(kind IN ('git','local')); git_sources.path TEXT NULL +
uq_git_sources_path (mirrors 0006's uq_git_sources_url). Existing rows
read kind='git', path=NULL.

API (phase-35 contract extended, git byte-identical): POST kind=local
requires path — trimmed, ~-expanded, absolute + an existing server
directory, else 422 naming the path (fail loud at add-time); duplicate
path 409 (named); wrong field combos 422. GET rows carry kind + path
(git and env rows: path null); anonymous still 403 on every route (A10).

Sync + import_docs resolve DB git + local rows together: git →
clone_or_pull (unchanged); local → re-verified .is_dir() AT SYNC TIME
(it may have moved/deleted since add-time) — a missing dir raises
"local source missing: <path>" (sanitized) before anything imports;
one import_sources(..., prune=True) over the single combined list
(pruning covers the union). Both-empty fails loudly ("no sources
configured (git or local)"); --source still wins; the env fallback
stays git-only.

Page: second "Add a local directory" form (the same §7.4 never-stale
button + inline-error lifecycle as the git form; 422/409 details name
the path), Git/Local badges on rows (text + color, never color alone —
WCAG), updated hint (git + local together, union prune); the
anonymous sign-in gate is unchanged.

Tests: 0007 up/down; the API local-kind matrix (403/201/422/409) with
the git-kind suite green unchanged; the sync pipeline local/git/
mixed/missing against a host temp dir (the KB actually updated);
import_docs DB resolution + --source precedence. Story E2E (isolated,
deterministic across runs): add (Local badge) → missing path inline
422 naming it / duplicate 409 → the real Sync button imports the
fixture file (GET /api/docs + sentinel in its content) → file deleted
+ sync prunes it (union prune) → row removed; anonymous gate + 403s
(phase-35 regression). test_git_sources_admin.py (phase 35) green
UNCHANGED — no selector collision with the new form;
test_sync_button.py green.

Docs: README — the two managed kinds (git = clone/pull mirror; local =
direct in-place walk), add-time validation, union pruning, "the DB is
the local-source registry (no env var for local paths)";
.env.example — the env fallback is git-only.
2026-08-27 01:04:16 -04:00

263 lines
8.1 KiB
Python

"""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 (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