docling-studio/document-parser/api/document_chunks.py
Pier-Jean Malandrino ef80e22342 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-11 15:00:57 +02:00

212 lines
6.9 KiB
Python

"""Document chunks API router (#256).
Exposes the canonical doc-centric chunkset (CRUD + tree + rechunk + diff
+ push). Distinct from `/api/analyses/{id}/...` which operates on
ephemeral analysis runs (StudioPage / OCR Debug).
All routes mount under `/api/documents/{doc_id}/...`. The router
delegates to `ChunkService` — no direct repo / persistence access.
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from api.schemas import (
AddChunkRequest,
ChunkDiffResponse,
DocChunkResponse,
DocRechunkRequest,
DocTreeNodeResponse,
MergeChunksRequest,
PushChunksRequest,
PushChunksResponse,
SplitChunkRequest,
UpdateChunkRequest,
)
from services.chunk_service import (
ChunkService,
ChunkServiceError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["documents"])
def _get_service(request: Request) -> ChunkService:
svc = getattr(request.app.state, "chunk_service", None)
if svc is None:
raise HTTPException(status_code=503, detail="Chunk service not available")
return svc
ServiceDep = Annotated[ChunkService, Depends(_get_service)]
def _to_response(chunk) -> DocChunkResponse:
return DocChunkResponse(
id=chunk.id,
doc_id=chunk.document_id,
sequence=chunk.sequence,
text=chunk.text,
headings=list(chunk.headings),
source_page=chunk.source_page,
token_count=chunk.token_count,
created_at=str(chunk.created_at),
updated_at=str(chunk.updated_at),
)
def _raise_for(error: ChunkServiceError) -> None:
raise HTTPException(status_code=error.http_status, detail=str(error))
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get("/{doc_id}/chunks", response_model=list[DocChunkResponse])
async def list_chunks(doc_id: str, service: ServiceDep) -> list[DocChunkResponse]:
"""List the canonical chunkset for a document, ordered by sequence."""
try:
chunks = await service.list_chunks(doc_id)
except ChunkServiceError as e:
_raise_for(e)
return [_to_response(c) for c in chunks]
@router.post("/{doc_id}/chunks", response_model=DocChunkResponse, status_code=201)
async def add_chunk(doc_id: str, body: AddChunkRequest, service: ServiceDep) -> DocChunkResponse:
"""Insert a new chunk (optionally after `afterId`)."""
if not body.text:
raise HTTPException(status_code=400, detail="text is required")
try:
chunk = await service.add_chunk(doc_id, text=body.text, after_id=body.after_id)
except ChunkServiceError as e:
_raise_for(e)
return _to_response(chunk)
@router.patch("/{doc_id}/chunks/{chunk_id}", response_model=DocChunkResponse)
async def update_chunk(
doc_id: str,
chunk_id: str,
body: UpdateChunkRequest,
service: ServiceDep,
) -> DocChunkResponse:
"""Update a chunk's text or title (mapped to first heading)."""
if body.text is None and body.title is None:
raise HTTPException(status_code=400, detail="text or title is required")
headings: list[str] | None = None
if body.title is not None:
headings = [body.title] if body.title else []
try:
chunk = await service.update_chunk(doc_id, chunk_id, text=body.text, headings=headings)
except ChunkServiceError as e:
_raise_for(e)
return _to_response(chunk)
@router.delete("/{doc_id}/chunks/{chunk_id}", status_code=204, response_model=None)
async def delete_chunk(doc_id: str, chunk_id: str, service: ServiceDep) -> None:
"""Soft-delete a chunk."""
try:
await service.delete_chunk(doc_id, chunk_id)
except ChunkServiceError as e:
_raise_for(e)
@router.post("/{doc_id}/chunks/{chunk_id}/split", response_model=list[DocChunkResponse])
async def split_chunk(
doc_id: str,
chunk_id: str,
body: SplitChunkRequest,
service: ServiceDep,
) -> list[DocChunkResponse]:
"""Split a chunk in two at `cursorOffset` characters."""
try:
chunks = await service.split_chunk(doc_id, chunk_id, body.cursor_offset)
except ChunkServiceError as e:
_raise_for(e)
return [_to_response(c) for c in chunks]
@router.post("/{doc_id}/chunks/merge", response_model=DocChunkResponse)
async def merge_chunks(
doc_id: str, body: MergeChunksRequest, service: ServiceDep
) -> DocChunkResponse:
"""Merge contiguous chunks into one. Order in `ids` is irrelevant — the
service sorts by sequence."""
try:
merged = await service.merge_chunks(doc_id, body.ids)
except ChunkServiceError as e:
_raise_for(e)
return _to_response(merged)
# ---------------------------------------------------------------------------
# Rechunk + tree + diff + push
# ---------------------------------------------------------------------------
@router.post("/{doc_id}/rechunk", response_model=list[DocChunkResponse])
async def rechunk_document(
doc_id: str,
body: DocRechunkRequest | None,
service: ServiceDep,
) -> list[DocChunkResponse]:
"""Re-run the chunker on the latest analysis JSON; replace canonical."""
options = body.chunking_options.model_dump() if body and body.chunking_options else None
try:
chunks = await service.rechunk_document(doc_id, options)
except ChunkServiceError as e:
_raise_for(e)
return [_to_response(c) for c in chunks]
@router.get("/{doc_id}/tree", response_model=list[DocTreeNodeResponse])
async def get_tree(doc_id: str, service: ServiceDep) -> list[DocTreeNodeResponse]:
"""Outline of the document built from the latest completed analysis.
Returns `[]` if no analysis is available yet.
"""
try:
tree = await service.get_tree(doc_id)
except ChunkServiceError as e:
_raise_for(e)
return [DocTreeNodeResponse(**node) for node in tree]
@router.get("/{doc_id}/diff", response_model=list[ChunkDiffResponse])
async def diff_against_store(
doc_id: str,
service: ServiceDep,
store: str = Query(..., min_length=1),
) -> list[ChunkDiffResponse]:
"""Diff the canonical chunkset against the last push to `store`."""
try:
diffs = await service.diff_against_store(doc_id, store)
except ChunkServiceError as e:
_raise_for(e)
return [ChunkDiffResponse(**d) for d in diffs]
@router.post("/{doc_id}/chunks/push", response_model=PushChunksResponse)
async def push_chunks(
doc_id: str,
body: PushChunksRequest,
service: ServiceDep,
) -> PushChunksResponse:
"""Push the canonical chunkset to a store and record a `ChunkPush`."""
try:
result = await service.push_to_store(doc_id, body.store)
except ChunkServiceError as e:
_raise_for(e)
return PushChunksResponse(
job_id=result["jobId"],
summary=result["summary"],
)