patristic-be / src /api /dto /query.py
Mario33333's picture
deploy: feature/audiobook@f1c0d35 (fix batch — models/providers, TTS $0, grants, DB admins, delete-hardening)
833ba13 verified
Raw
History Blame Contribute Delete
11.7 kB
"""DTOs for ``/query/*`` endpoints (Stage 3 covers the read side only).
Stage 3 exposes ``GET /query/runs/{runId}`` — the persisted envelope for
a committed run. The ``POST /query`` request shape + live SSE land in
Stage 4. This module models the response envelope as a **discriminated
union by ``mode``** so the FE's typed codegen produces three concrete TS
types (``LookupEnvelope`` / ``ComparisonEnvelope`` / ``ConceptualEnvelope``)
the user can switch over.
Why ``mode: RouterModeEnum`` is repeated on each leaf rather than living
on ``_Base``: Pydantic v2's discriminated unions need the discriminator
to be a literal-typed field on every member so the OpenAPI ``oneOf``
emits a ``discriminator.mapping`` block the codegen can consume. We
declare it on the leaves with a default = the right enum value.
The ``run_id`` and ``cost`` fields are required because Stage-3 reads
only return persisted (committed) runs — the cost is known by the time
the envelope is materialised. During live runs the cost streams via SSE
``progress.costSoFarUsd`` (CONTRACT.md §5).
"""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Union
from pydantic import Field, model_validator
from .common import ApiModel
# Server-side bounds on the cost amplifiers. These cap how much a single
# request can fan out to paid providers — the judge flagged top_k,
# comparison_spec fan-out, and query length as unbounded. The daily spend cap
# (assert_within_daily_cap) is the real backstop on cost, so this ceiling is
# generous (up to 1k results for deep research; default stays low).
_MAX_TOP_K = 1000
_MAX_GROUPS = 10
_MAX_BOOKS_PER_GROUP = 50
_MAX_COMPARISON_BOOKS = 60 # union across groups (per_book = 1 paid call each)
_MAX_FILTER_IDS = 500
_MAX_QUERY_CHARS = 4000
# ---------- Enums ---------------------------------------------------------
class RouterModeEnum(str, Enum):
"""Final mode the router classified the query into.
LOOKUP = single citation-grounded answer.
COMPARISON = side-by-side across groups (per_book / per_group / head_to_head).
CONCEPTUAL = open-ended exploration with per-candidate judging.
Locked vocabulary; matches the strings stored in
``query_history.mode`` so the wire round-trips persisted runs without
translation.
"""
LOOKUP = "LOOKUP"
COMPARISON = "COMPARISON"
CONCEPTUAL = "CONCEPTUAL"
class ComparisonStrategyEnum(str, Enum):
"""How COMPARISON mode partitions retrieval across books/groups."""
per_book = "per_book"
per_group = "per_group"
head_to_head = "head_to_head"
# ---------- Shared shapes -------------------------------------------------
class Citation(ApiModel):
"""One retrieved-and-cited chunk projected onto the wire.
``page_label`` is **server-formatted** (e.g. ``"p. 42 (PDF p. 51)"``)
so the FE never has to know about printed-page mapping. Matches the
behavior of :func:`src.stage9_ui.shared.format_page_label`.
"""
book_id: str
book_title: str
author: str | None = None
pdf_page: int
printed_page: int | None = None
page_label: str
excerpt: str
score: float | None = None
class CostBreakdown(ApiModel):
"""Aggregate cost for one query run.
``total_usd`` is the sum of every ``llm_calls.cost_usd`` row that
falls within the run's ``llm_call_id_range`` (CONTRACT.md §12).
"""
total_usd: float
duration_ms: int
n_calls: int
by_stage: dict[str, float]
class LlmCallIdRange(ApiModel):
"""Inclusive id range of ``llm_calls`` rows spanned by this run.
Backs the cost panel's ``GET /costs/llm-calls?sinceId=`` query —
the FE asks for everything billed strictly after ``firstId`` (or
inclusive of it; both ends survive the wire as separate fields so
the UI can pick).
"""
first_id: int | None = None
last_id: int | None = None
class ConsultedBook(ApiModel):
"""LOOKUP mode: one book that contributed citations."""
book_id: str
title: str
n_citations: int
class ComparisonRow(ApiModel):
"""One row of a COMPARISON envelope.
Shape stays free-form per ``BACKEND_BUILD.md §8.2``; the Stage-4
query router will populate concrete fields. Stage 3 only persists
what the existing pipeline emits.
"""
group_id: str | None = None
group_label: str | None = None
book_id: str | None = None
book_title: str | None = None
answer: str | None = None
citations: list[Citation] | None = None
cost_usd: float | None = None
class AllusionMatch(ApiModel):
"""One per-candidate verdict from CONCEPTUAL mode's judging stage."""
book_id: str
book_title: str
pdf_page: int
printed_page: int | None = None
page_label: str
excerpt: str
verdict: str
reasoning: str
score: float | None = None
# ---------- Envelopes (discriminated by mode) -----------------------------
class _BaseEnvelope(ApiModel):
"""Fields shared by every mode's envelope.
``router`` carries the classifier's reasoning blob (kept as ``dict``
because the pipeline emits free-form JSON here and we don't want to
couple the wire DTO to internal classifier evolution).
"""
run_id: int
query: str
router: dict
cost: CostBreakdown
llm_call_id_range: LlmCallIdRange
created_at: str
class LookupEnvelope(_BaseEnvelope):
"""LOOKUP mode response: one grounded answer + citations."""
mode: RouterModeEnum = RouterModeEnum.LOOKUP
answer: str
citations: list[Citation]
n_retrieved: int
books_consulted: list[ConsultedBook]
confidence: float | None = None
ungrounded_claims: list[str] | None = None
class ComparisonEnvelope(_BaseEnvelope):
"""COMPARISON mode: per-row partition with optional cross-cutting synthesis."""
mode: RouterModeEnum = RouterModeEnum.COMPARISON
strategy: ComparisonStrategyEnum
groups: list[dict] | None = None
rows: list[ComparisonRow]
cross_cutting: str | None = None
class ConceptualEnvelope(_BaseEnvelope):
"""CONCEPTUAL mode: open-ended exploration with per-candidate verdicts."""
mode: RouterModeEnum = RouterModeEnum.CONCEPTUAL
matches: list[AllusionMatch]
n_candidates_judged: int
# Pydantic v2 discriminated union — the OpenAPI ``oneOf`` emits a
# ``discriminator.mapping`` so codegen produces three concrete TS types.
QueryResponseEnvelope = Annotated[
Union[LookupEnvelope, ComparisonEnvelope, ConceptualEnvelope],
Field(discriminator="mode"),
]
# ---------- Stage 4: POST /query request shape ----------------------------
# Locked field names per CONTRACT.md §11. The FE references these by name
# in its OpenAPI codegen; renaming any of them breaks the wire silently.
class PageEnum(str, Enum):
"""Which research page kicked off the query.
Tagged onto ``query_history.page`` so the history list can scope by
page. ``search`` and ``compare`` are user-facing; ``allusions`` runs
CONCEPTUAL mode under the hood.
"""
search = "search"
compare = "compare"
allusions = "allusions"
class Filters(ApiModel):
"""Optional retrieval scope (CONTRACT.md §11).
All fields nullable; omitted fields mean "no scope constraint on that
axis". The router treats an all-null filter set as the unrestricted
corpus (matches Streamlit parity).
"""
tradition: str | None = None
era: str | None = None
language: str | None = None
author_id: str | None = None
book_ids: list[str] | None = Field(default=None, max_length=_MAX_FILTER_IDS)
label_ids: list[str] | None = Field(default=None, max_length=_MAX_FILTER_IDS)
class ComparisonGroup(ApiModel):
"""One named group of books used by COMPARISON mode."""
id: str
label: str
book_ids: list[str] = Field(..., min_length=1, max_length=_MAX_BOOKS_PER_GROUP)
class ComparisonSpec(ApiModel):
"""How COMPARISON mode partitions retrieval across books/groups.
``per_book`` ignores ``groups``; ``per_group`` requires it; ``head_to_head``
expects exactly two groups. Validation happens server-side in the router.
"""
strategy: ComparisonStrategyEnum
top_k_per_book: int | None = Field(default=None, ge=1, le=_MAX_TOP_K)
groups: list[ComparisonGroup] | None = Field(default=None, max_length=_MAX_GROUPS)
@model_validator(mode="after")
def _bound_total_books(self) -> "ComparisonSpec":
"""Cap the union of books across groups.
``per_book`` strategy issues one paid generation call per unique
book in the union, so an unbounded group list is a cost amplifier.
Per-group and per-request bounds alone (10 × 50) would still allow
500 calls; this caps the total that actually fans out.
"""
if self.groups:
total = sum(len(g.book_ids) for g in self.groups)
if total > _MAX_COMPARISON_BOOKS:
raise ValueError(
f"comparison_spec spans {total} books; the maximum is "
f"{_MAX_COMPARISON_BOOKS} (each book is a separate paid "
f"generation call)."
)
return self
class QueryModelOverrides(ApiModel):
"""Per-stage model overrides from the AdvancedModels expander.
Each field is the model id (``"gemini-2.5-flash"``, ``"claude-haiku-4-5"``,
etc.) — the FE picks them from ``GET /tools?category=llm``. None on
any field falls back to the config default for that stage.
"""
generation: str | None = None
classify: str | None = None
judge: str | None = None
class QueryRequest(ApiModel):
"""``POST /query`` body (BACKEND_BUILD.md §8.1b).
Field names are LOCKED — the FE typed client references them by name.
The runner strips snake_case off the dict on its way into
``src.stage8_router.router.answer``; that function takes Python
snake_case kwargs (which is why ``populate_by_name=True`` on the
:class:`ApiModel` config matters).
"""
query: str = Field(..., min_length=1, max_length=_MAX_QUERY_CHARS)
page: PageEnum
force_mode: RouterModeEnum | None = None
top_k: int | None = Field(default=None, ge=1, le=_MAX_TOP_K)
filters: Filters | None = None
comparison_spec: ComparisonSpec | None = None
models: QueryModelOverrides | None = None
use_premium: bool = False
# ---------- History row ---------------------------------------------------
class HistoryRowDTO(ApiModel):
"""Compact projection used by ``GET /history``.
The full envelope is fetched on-demand via ``GET /query/runs/{runId}``.
This row is what fits in the recent-queries table without overloading
the wire — the ``query_preview`` is server-trimmed to 120 chars.
"""
id: int
timestamp: str
page: str
mode: RouterModeEnum | None = None
query: str
query_preview: str
cost_usd: float
duration_ms: int
n_calls: int
pinned: bool
note: str | None = None
username: str | None = None
class HistoryListResponse(ApiModel):
"""Paginated wrapper for ``GET /history``.
``cursor`` is the smallest ``id`` returned in this page — the FE
passes it back as ``?cursor=`` to fetch older rows. ``null`` means
"no more pages".
"""
items: list[HistoryRowDTO]
next_cursor: int | None = None