"""DTOs for the Add Book wizard — uploads + probe. The wizard's three-step flow (per ``BACKEND_BUILD.md §13 Step 6`` + ``FRONTEND_BUILD.md`` Add Book page): 1. ``POST /uploads`` (multipart) → :class:`UploadResponse` — bytes land in a temp dir, the FE gets back a stable ``uploadId`` it can hand to the probe + create endpoints. 2. ``POST /books/probe`` body :class:`ProbeRequest` → :class:`ProbeResponse` — wraps ``src/lib/metadata_probe.py`` and returns enough metadata for the wizard to prefill the create form (title, author, tradition…) plus a sample page rendering + ingest cost estimate. 3. ``POST /books`` body :class:`CreateBookRequest` (in ``dto/books.py``) → :class:`BookDTO` — commits the row and moves the upload bytes into the storage backend at ``data/raw/{bookId}.pdf``. Every DTO inherits :class:`ApiModel`. Enums (extraction mode, era, tradition, language) reuse :mod:`src.api.dto.books` so the same TS union types the rest of the library uses are reused on the FE. """ from __future__ import annotations from pydantic import Field from .books import ( EraEnum, ExtractionModeEnum, LanguageEnum, ReligionEnum, TraditionEnum, ) from .common import ApiModel class UploadResponse(ApiModel): """Body for ``POST /uploads`` — multipart-PDF upload. The temp file lands at ``data/uploads/{uploadId}.pdf`` keyed by a fresh uuid (``upload_id``). The SHA-256 is computed server-side as a side effect of writing the bytes — the FE uses it as the cache key for probe results so re-uploading the same file doesn't re-bill the Gemini probe call. """ upload_id: str sha256: str size_bytes: int class ProbeRequest(ApiModel): """Body for ``POST /books/probe`` — at least one of the two must be set. The router validates "at least one" at runtime (raises ``BAD_REQUEST`` otherwise) so the wire schema can stay simple — both fields being optional matches the FE's typical flow where the user is editing either a URL or a freshly uploaded file. """ source_url: str | None = None upload_id: str | None = None class GuessedMetadata(ApiModel): """The metadata fields the probe attempts to fill in from the PDF. Every field is ``Optional`` — when Gemini can't extract a confident value, the field stays ``None`` and the wizard renders the input empty. Religion fields default to ``None`` because the probe is most confident about title/author and least confident about religion of the author (which sometimes requires external knowledge the model doesn't have). """ title_ar: str | None = None title_en: str | None = None author: str | None = None author_id: str | None = None era: EraEnum | None = None tradition: TraditionEnum | None = None language: LanguageEnum | None = None book_religion: ReligionEnum | None = None author_religion: ReligionEnum | None = None confidence: str | None = None rationale: str | None = None class SuggestedLabel(ApiModel): """One label the probe thinks the user might want to apply. The ``id`` is the slug Gemini returned (canonicalised to lowercase and trimmed to <=64 chars in :func:`src.lib.metadata_probe._clean_suggested_labels`). The wizard shows them as togglable chips; ticked chips become ``CreateBookRequest.label_ids`` on submit. The label rows themselves are created lazily by the create endpoint (or by ``POST /labels/seed-from-derived``). """ id: str reason: str | None = None class SamplePage(ApiModel): """One PDF page rendered to text for the wizard's "sanity check" pane. For native-text PDFs ``ocr_text`` is the PyMuPDF extraction (free, no API call). For scanned PDFs the value is whatever the probe surfaced — which may be empty if the probe didn't sample image pages. ``clean_text`` is unset on probe (Stage 4 hasn't run); included in the DTO so the same shape can be reused for ``GET /books/.../pages`` later. """ pdf_page: int ocr_text: str clean_text: str | None = None class ProbeResponse(ApiModel): """The Add Book wizard's pre-flight result. The FE uses this to render Step 2 of the wizard: prefilled form + suggested labels + sample pages + a hard cost number for the "Run ingest" CTA. ``estimated_ingest_cost_usd`` is the counterfactual cost of running the full pipeline at the suggested extraction mode, computed against ``tools_registry.yaml`` so it moves with pricing updates. """ pages_total: int has_text_layer: bool suggested_extraction_mode: ExtractionModeEnum guessed_metadata: GuessedMetadata suggested_labels: list[SuggestedLabel] = Field(default_factory=list) sample_pages: list[SamplePage] = Field(default_factory=list) estimated_ingest_cost_usd: float probe_error: str | None = None