Grounded chat turns now run the agent loop (app/rag/agent.py) instead
of a bare chat_stream: while the per-turn budgets last
(BOR_AGENT_LIST_CALLS / BOR_AGENT_READ_CALLS, default 1 each) the model
gets list_documents (the indexed catalog, /api/docs order) and
read_document (full text, never truncated — A7-revised contract); once
both budgets are spent the tools key is dropped from the request and
the model must answer. Rejected calls (unknown tool, unknown/missing
path, document already in context, spent budget) consume no budget.
Budgets 0/0 make exactly one tools=None request — byte-identical to
the pre-phase path (budgets-as-kill-switch). Deflected turns keep the
direct chat_stream (A8 unchanged; the LOW prompt never carries the
<tools> section).
SSE contract gains {"type":"tool","name":...,"argument":
"source/path"|null} frames ahead of the answer deltas (PLAN §4
extension, owner permission 2026-08-26); done.sources, query_log.sources
and the per-turn log line (gains tool_calls=N) report the retrieval
docs + read docs, deduped. The UI shows a "calling tool"
button/label state and one visible .tool-call line per call above the
answer; the lines persist with the chat record and re-render on
reload. chat_stream passes tools through and accumulates streaming
tool_calls deltas into ToolCallPiece (tools=None stays byte-identical).
E2E: deterministic mock tool flow ("use your tools" + <tools> marker:
list -> read first catalog line -> quoted answer) plus the story suite
(marker flow, reload re-render, plain/deflected no-tool regressions).
Docs: .env.example + README (the two tools, the budgets, the SSE tool
frame, the "calling tool" UI state).
probe: turbo tool_calls=supported 2026-08-26 (uv run python -m
scripts.llm_probe --tools — non-streaming + streaming
finish_reason=tool_calls, indexed delta.tool_calls partials)
224 lines
6.4 KiB
Python
224 lines
6.4 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: 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
|