docling-studio/document-parser/api/schemas.py
Pier-Jean Malandrino d1bf23b1a2 feat(#257): surface gating via STUDIO_MODE + RAG_PIPELINE master flags
Introduces two master feature flags that select which UI surface is
exposed, replacing the previous "delete legacy pages" approach with a
softer isolation:

- STUDIO_MODE_ENABLED  (default false) — legacy OCR-debug surface
- RAG_PIPELINE_ENABLED (default true)  — new doc-centric ingestion + viz

At least one master must be enabled (validated server-side at startup).
Sub-flags (inspect / linked / ask) are effective only when the RAG
pipeline master is on.

CHUNKS_MODE_ENABLED renamed to LINKED_MODE_ENABLED in anticipation of
T3 (Linked view replaces the Chunks tab). The DocMode union value
'chunks' is preserved for now and will be renamed in T3 alongside the
route segment, to keep this PR scoped.

Router-level guard added: requests to a route whose surface is disabled
are redirected to the other surface's landing page (or /home as a
defensive fallback). Logic extracted into a pure resolveSurface helper
with full test coverage.

i18n strings that pointed users to "Studio" rewritten to be surface-
agnostic ("from the library" / "depuis la bibliothèque") since Studio
is hidden by default in 0.6.1.

Backend:
- infra/settings.py: add studio_mode_enabled + rag_pipeline_enabled;
  rename chunks_mode_enabled → linked_mode_enabled; add at-least-one
  master validation in __post_init__
- api/schemas.py: HealthResponse exposes both master flags + renamed
  sub-flag
- main.py: health endpoint wires the new fields
- tests: surface-flag + renamed sub-flag assertions

Frontend:
- features/feature-flags/store: add studioMode + ragPipeline registry
  entries; rename chunksMode → linkedMode; sub-flags now require
  ragPipeline enabled; modeFlags() maps linkedModeEnabled → key 'chunks'
  (transitional)
- shared/routing/resolveSurface: pure helper + tests
- app/router: beforeEach guard consumes resolveSurface
- shared/i18n: Studio-pointing strings rewritten (en + fr) + test sync
- features/reasoning: stale "from StudioPage" comment generalized
2026-05-11 15:52:29 +02:00

374 lines
11 KiB
Python

"""Pydantic schemas — API request/response DTOs.
All responses use camelCase serialization to match the existing frontend contract
(originally served by the Spring Boot backend).
"""
from __future__ import annotations
from datetime import datetime
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
# Document lifecycle status — currently single-state (uploaded). Kept as a
# constant so future statuses (e.g. "archived", "deleted") can extend the
# vocabulary without hunting magic strings across the codebase.
DOCUMENT_STATUS_UPLOADED = "uploaded"
def _to_camel(name: str) -> str:
parts = name.split("_")
return parts[0] + "".join(w.capitalize() for w in parts[1:])
class _CamelModel(BaseModel):
"""Base model that serializes field names to camelCase."""
model_config = ConfigDict(
alias_generator=_to_camel,
populate_by_name=True,
serialize_by_alias=True,
)
class HealthResponse(_CamelModel):
status: str
version: str
engine: str
deployment_mode: str
database: str
max_page_count: int | None = None
max_file_size_mb: int | None = None
max_paste_image_size_mb: int | None = None
paste_allowed_image_types: list[str] = Field(default_factory=list)
ingestion_available: bool = False
# True when the live-reasoning runner (docling-agent + Ollama) is
# available: REASONING_ENABLED=true AND deps importable. Doesn't imply
# Ollama itself is reachable — that's checked per-call.
reasoning_available: bool = False
# 0.6.1 — Surface flags (#257). Master flags select which surface(s)
# the frontend exposes. Defaults match the production target (RAG only).
studio_mode_enabled: bool = False
rag_pipeline_enabled: bool = True
# 0.6.0 — RAG-pipeline sub-flags (#210, renamed in #257). Default true
# so frontends pointed at an older backend keep every mode visible.
inspect_mode_enabled: bool = True
linked_mode_enabled: bool = True
ask_mode_enabled: bool = True
class DocumentResponse(_CamelModel):
id: str
filename: str
status: str = DOCUMENT_STATUS_UPLOADED
content_type: str | None = None
file_size: int | None = None
page_count: int | None = None
created_at: str | datetime
# 0.6.0 — Document lifecycle state machine (#202). The lifecycle
# describes the document as a whole; `status` above is kept for
# backwards compat and currently still maps to `DOCUMENT_STATUS_UPLOADED`.
lifecycle_state: str = "Uploaded"
lifecycle_state_at: str | datetime | None = None
class AnalysisResponse(_CamelModel):
id: str
document_id: str = ""
document_filename: str | None = None
status: str
content_markdown: str | None = None
content_html: str | None = None
pages_json: str | None = None
chunks_json: str | None = None
has_document_json: bool = False
error_message: str | None = None
progress_current: int | None = None
progress_total: int | None = None
started_at: str | datetime | None = None
completed_at: str | datetime | None = None
created_at: str | datetime
class PipelineOptionsRequest(BaseModel):
"""Docling pipeline configuration options."""
model_config = ConfigDict(populate_by_name=True)
do_ocr: bool = Field(default=True, validation_alias=AliasChoices("do_ocr", "doOcr"))
do_table_structure: bool = Field(
default=True, validation_alias=AliasChoices("do_table_structure", "doTableStructure")
)
table_mode: str = Field(
default="accurate", validation_alias=AliasChoices("table_mode", "tableMode")
)
do_code_enrichment: bool = Field(
default=False, validation_alias=AliasChoices("do_code_enrichment", "doCodeEnrichment")
)
do_formula_enrichment: bool = Field(
default=False, validation_alias=AliasChoices("do_formula_enrichment", "doFormulaEnrichment")
)
do_picture_classification: bool = Field(
default=False,
validation_alias=AliasChoices("do_picture_classification", "doPictureClassification"),
)
do_picture_description: bool = Field(
default=False,
validation_alias=AliasChoices("do_picture_description", "doPictureDescription"),
)
generate_picture_images: bool = Field(
default=False,
validation_alias=AliasChoices("generate_picture_images", "generatePictureImages"),
)
generate_page_images: bool = Field(
default=False, validation_alias=AliasChoices("generate_page_images", "generatePageImages")
)
images_scale: float = Field(
default=1.0, validation_alias=AliasChoices("images_scale", "imagesScale")
)
@field_validator("table_mode")
@classmethod
def validate_table_mode(cls, v: str) -> str:
if v not in ("accurate", "fast"):
raise ValueError('table_mode must be "accurate" or "fast"')
return v
@field_validator("images_scale")
@classmethod
def validate_images_scale(cls, v: float) -> float:
if v <= 0 or v > 10:
raise ValueError("images_scale must be between 0 (exclusive) and 10")
return v
class ChunkingOptionsRequest(BaseModel):
"""Docling chunking configuration options."""
model_config = ConfigDict(populate_by_name=True)
chunker_type: str = Field(
default="hybrid", validation_alias=AliasChoices("chunker_type", "chunkerType")
)
max_tokens: int = Field(default=512, validation_alias=AliasChoices("max_tokens", "maxTokens"))
merge_peers: bool = Field(
default=True, validation_alias=AliasChoices("merge_peers", "mergePeers")
)
repeat_table_header: bool = Field(
default=True, validation_alias=AliasChoices("repeat_table_header", "repeatTableHeader")
)
@field_validator("chunker_type")
@classmethod
def validate_chunker_type(cls, v: str) -> str:
if v not in ("hybrid", "hierarchical"):
raise ValueError('chunker_type must be "hybrid" or "hierarchical"')
return v
@field_validator("max_tokens")
@classmethod
def validate_max_tokens(cls, v: int) -> int:
if v < 64 or v > 8192:
raise ValueError("max_tokens must be between 64 and 8192")
return v
class ChunkBboxResponse(_CamelModel):
page: int
bbox: list[float]
class ChunkResponse(_CamelModel):
text: str
headings: list[str] = []
source_page: int | None = None
token_count: int = 0
bboxes: list[ChunkBboxResponse] = []
modified: bool = False
deleted: bool = False
class UpdateChunkTextRequest(BaseModel):
text: str
class CreateAnalysisRequest(BaseModel):
documentId: str = Field(validation_alias=AliasChoices("documentId", "document_id"))
pipelineOptions: PipelineOptionsRequest | None = Field(
default=None, validation_alias=AliasChoices("pipelineOptions", "pipeline_options")
)
chunkingOptions: ChunkingOptionsRequest | None = Field(
default=None, validation_alias=AliasChoices("chunkingOptions", "chunking_options")
)
class RechunkRequest(BaseModel):
chunkingOptions: ChunkingOptionsRequest = Field(
validation_alias=AliasChoices("chunkingOptions", "chunking_options")
)
class IngestionResponse(_CamelModel):
doc_id: str
chunks_indexed: int
embedding_dimension: int
class IngestionStatusResponse(_CamelModel):
available: bool
opensearch_connected: bool = False
class SearchResultItem(_CamelModel):
"""A single search result with content and metadata."""
doc_id: str
filename: str
content: str
chunk_index: int
page_number: int
score: float
headings: list[str] = []
highlights: list[str] = []
class SearchResponse(_CamelModel):
results: list[SearchResultItem]
total: int
query: str
# ---------------------------------------------------------------------------
# Stores (#251)
# ---------------------------------------------------------------------------
class StoreInfoResponse(_CamelModel):
"""Read model for `GET /api/stores`."""
name: str
slug: str
type: str
embedder: str
is_default: bool
document_count: int
chunk_count: int
connected: bool
error_message: str | None = None
class StoreResponse(_CamelModel):
"""Detailed read model for `GET /api/stores/{slug}`."""
id: str
name: str
slug: str
kind: str
embedder: str
is_default: bool
config: dict
created_at: str | datetime
class StoreCreateRequest(_CamelModel):
name: str
slug: str
kind: str
embedder: str
config: dict = Field(default_factory=dict)
is_default: bool = False
class StoreUpdateRequest(_CamelModel):
"""Partial update — every field is optional. Use `slug` to rename."""
name: str | None = None
slug: str | None = None
kind: str | None = None
embedder: str | None = None
config: dict | None = None
is_default: bool | None = None
class StoreDocEntryResponse(_CamelModel):
doc_id: str
filename: str
state: str
chunk_count: int
pushed_at: str | None = None
# ---------------------------------------------------------------------------
# Doc-centric chunks (#256) — canonical chunkset, distinct from analysis chunks
# ---------------------------------------------------------------------------
class DocChunkResponse(_CamelModel):
"""Canonical doc chunk — wire shape consumed by `features/chunks` on the front."""
id: str
doc_id: str
sequence: int
text: str
headings: list[str] = []
source_page: int | None = None
token_count: int | None = None
created_at: str | datetime
updated_at: str | datetime
class AddChunkRequest(_CamelModel):
text: str
after_id: str | None = None
class UpdateChunkRequest(_CamelModel):
"""Either or both fields. Empty body is a 400 — handled in the router."""
text: str | None = None
title: str | None = None # surfaced as first heading; future: dedicated field
class SplitChunkRequest(_CamelModel):
cursor_offset: int
class MergeChunksRequest(_CamelModel):
ids: list[str]
class DocRechunkRequest(_CamelModel):
"""Optional chunking options. Empty body uses defaults."""
chunking_options: ChunkingOptionsRequest | None = None
class DocTreeNodeResponse(_CamelModel):
ref: str
type: str
label: str
children: list[DocTreeNodeResponse] = []
# Forward-ref resolution (children references the same class).
DocTreeNodeResponse.model_rebuild()
class ChunkDiffResponse(_CamelModel):
chunk_id: str
status: str # 'added' | 'modified' | 'removed' | 'unchanged'
text_diff: str | None = None
class PushSummaryResponse(_CamelModel):
embeds: int
tokens: int
class PushChunksResponse(_CamelModel):
job_id: str
summary: PushSummaryResponse
class PushChunksRequest(_CamelModel):
store: str