docling-studio/document-parser/api/schemas.py
Pier-Jean Malandrino 70972e807a feat(#256): add /api/documents/{id}/chunks routes + ChunkService
The frontend was wired against /api/documents/{id}/chunks/* (canonical
doc-centric chunkset) but the backend never exposed those routes — the
chunk tab in the doc workspace 404'd. The domain entities (Chunk,
ChunkEdit, ChunkPush) and persistence repos already existed since #205;
what was missing was the service + API layer that connects them.

ChunkService owns all canonical chunkset invariants (sequence ordering,
soft-delete + audit log atomicity) and shares the chunker port with
AnalysisService so chunking strategy stays a single implementation.

AnalysisService grew a duck-typed promoter hook that copies the chunks
of the first successful analysis into the canonical chunkset. The hook
is idempotent so subsequent ad-hoc analyses (Studio / OCR Debug) never
overwrite hand-edited state.

Routes added (all additive, /api/documents prefix):
  GET    /{id}/chunks
  POST   /{id}/chunks
  PATCH  /{id}/chunks/{chunkId}
  DELETE /{id}/chunks/{chunkId}
  POST   /{id}/chunks/{chunkId}/split
  POST   /{id}/chunks/merge
  POST   /{id}/rechunk
  GET    /{id}/tree
  GET    /{id}/diff?store=...
  POST   /{id}/chunks/push
2026-05-07 11:09:04 +02:00

371 lines
10 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.0 — Doc workspace mode flags (#210). Default true so existing
# frontends without the new keys (legacy backend image rolling forward)
# see the same behaviour they had.
inspect_mode_enabled: bool = True
chunks_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