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
374 lines
11 KiB
Python
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
|