Merge pull request #32 from scub-france/feature/include-chunking

Feature/include chunking
This commit is contained in:
Pier-Jean Malandrino 2026-04-02 12:38:08 +02:00 committed by GitHub
commit e74c788ad4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1593 additions and 139 deletions

View file

@ -7,7 +7,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from api.schemas import AnalysisResponse, CreateAnalysisRequest from api.schemas import AnalysisResponse, ChunkResponse, CreateAnalysisRequest, RechunkRequest
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,6 +30,8 @@ def _to_response(job) -> AnalysisResponse:
content_markdown=job.content_markdown, content_markdown=job.content_markdown,
content_html=job.content_html, content_html=job.content_html,
pages_json=job.pages_json, pages_json=job.pages_json,
chunks_json=job.chunks_json,
has_document_json=job.document_json is not None,
error_message=job.error_message, error_message=job.error_message,
started_at=str(job.started_at) if job.started_at else None, started_at=str(job.started_at) if job.started_at else None,
completed_at=str(job.completed_at) if job.completed_at else None, completed_at=str(job.completed_at) if job.completed_at else None,
@ -47,8 +49,16 @@ async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep):
if body.pipelineOptions: if body.pipelineOptions:
pipeline_opts = body.pipelineOptions.model_dump() pipeline_opts = body.pipelineOptions.model_dump()
chunking_opts = None
if body.chunkingOptions:
chunking_opts = body.chunkingOptions.model_dump()
try: try:
job = await service.create(body.documentId, pipeline_options=pipeline_opts) job = await service.create(
body.documentId,
pipeline_options=pipeline_opts,
chunking_options=chunking_opts,
)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
@ -71,6 +81,24 @@ async def get_analysis(job_id: str, service: ServiceDep):
return _to_response(job) return _to_response(job)
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
async def rechunk_analysis(job_id: str, body: RechunkRequest, service: ServiceDep):
"""Re-chunk a completed analysis with new chunking options."""
try:
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [
ChunkResponse(
text=c.text,
headings=c.headings,
source_page=c.source_page,
token_count=c.token_count,
)
for c in chunks
]
@router.delete("/{job_id}", status_code=204) @router.delete("/{job_id}", status_code=204)
async def delete_analysis(job_id: str, service: ServiceDep): async def delete_analysis(job_id: str, service: ServiceDep):
"""Delete an analysis job.""" """Delete an analysis job."""

View file

@ -18,6 +18,7 @@ def _to_camel(name: str) -> str:
class _CamelModel(BaseModel): class _CamelModel(BaseModel):
"""Base model that serializes field names to camelCase.""" """Base model that serializes field names to camelCase."""
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=_to_camel, alias_generator=_to_camel,
populate_by_name=True, populate_by_name=True,
@ -42,6 +43,8 @@ class AnalysisResponse(_CamelModel):
content_markdown: str | None = None content_markdown: str | None = None
content_html: str | None = None content_html: str | None = None
pages_json: str | None = None pages_json: str | None = None
chunks_json: str | None = None
has_document_json: bool = False
error_message: str | None = None error_message: str | None = None
started_at: str | datetime | None = None started_at: str | datetime | None = None
completed_at: str | datetime | None = None completed_at: str | datetime | None = None
@ -50,6 +53,7 @@ class AnalysisResponse(_CamelModel):
class PipelineOptionsRequest(BaseModel): class PipelineOptionsRequest(BaseModel):
"""Docling pipeline configuration options.""" """Docling pipeline configuration options."""
do_ocr: bool = True do_ocr: bool = True
do_table_structure: bool = True do_table_structure: bool = True
table_mode: str = "accurate" # "accurate" or "fast" table_mode: str = "accurate" # "accurate" or "fast"
@ -76,6 +80,41 @@ class PipelineOptionsRequest(BaseModel):
return v return v
class ChunkingOptionsRequest(BaseModel):
"""Docling chunking configuration options."""
chunker_type: str = "hybrid" # "hybrid", "hierarchical"
max_tokens: int = 512
merge_peers: bool = True
repeat_table_header: bool = True
@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 ChunkResponse(_CamelModel):
text: str
headings: list[str] = []
source_page: int | None = None
token_count: int = 0
class CreateAnalysisRequest(BaseModel): class CreateAnalysisRequest(BaseModel):
documentId: str # camelCase to match existing frontend contract documentId: str # camelCase to match existing frontend contract
pipelineOptions: PipelineOptionsRequest | None = None pipelineOptions: PipelineOptionsRequest | None = None
chunkingOptions: ChunkingOptionsRequest | None = None
class RechunkRequest(BaseModel):
chunkingOptions: ChunkingOptionsRequest

View file

@ -1,3 +1 @@
pytest_plugins = ["pytest_asyncio"] pytest_plugins = ["pytest_asyncio"]

View file

@ -43,7 +43,11 @@ def to_topleft_list(bbox: BoundingBox, page_height: float) -> list[float]:
if right <= left or bottom <= top: if right <= left or bottom <= top:
logger.debug( logger.debug(
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)", "Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
left, top, right, bottom, page_height, left,
top,
right,
bottom,
page_height,
) )
return list(EMPTY_BBOX) return list(EMPTY_BBOX)

View file

@ -42,6 +42,8 @@ class AnalysisJob:
content_markdown: str | None = None content_markdown: str | None = None
content_html: str | None = None content_html: str | None = None
pages_json: str | None = None pages_json: str | None = None
document_json: str | None = None
chunks_json: str | None = None
error_message: str | None = None error_message: str | None = None
started_at: datetime | None = None started_at: datetime | None = None
completed_at: datetime | None = None completed_at: datetime | None = None
@ -55,12 +57,19 @@ class AnalysisJob:
self.started_at = _utcnow() self.started_at = _utcnow()
def mark_completed( def mark_completed(
self, markdown: str, html: str, pages_json: str, self,
markdown: str,
html: str,
pages_json: str,
document_json: str | None = None,
chunks_json: str | None = None,
) -> None: ) -> None:
self.status = AnalysisStatus.COMPLETED self.status = AnalysisStatus.COMPLETED
self.content_markdown = markdown self.content_markdown = markdown
self.content_html = html self.content_html = html
self.pages_json = pages_json self.pages_json = pages_json
self.document_json = document_json
self.chunks_json = chunks_json
self.completed_at = _utcnow() self.completed_at = _utcnow()
def mark_failed(self, error: str) -> None: def mark_failed(self, error: str) -> None:

View file

@ -9,7 +9,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING: if TYPE_CHECKING:
from domain.value_objects import ConversionOptions, ConversionResult from domain.value_objects import (
ChunkingOptions,
ChunkResult,
ConversionOptions,
ConversionResult,
)
class DocumentConverter(Protocol): class DocumentConverter(Protocol):
@ -20,5 +25,20 @@ class DocumentConverter(Protocol):
""" """
async def convert( async def convert(
self, file_path: str, options: ConversionOptions, self,
file_path: str,
options: ConversionOptions,
) -> ConversionResult: ... ) -> ConversionResult: ...
class DocumentChunker(Protocol):
"""Port for document chunking.
Takes a serialized DoclingDocument (JSON) and returns chunks.
"""
async def chunk(
self,
document_json: str,
options: ChunkingOptions,
) -> list[ChunkResult]: ...

View file

@ -49,3 +49,23 @@ class ConversionResult:
content_html: str content_html: str
pages: list[PageDetail] pages: list[PageDetail]
skipped_items: int = 0 skipped_items: int = 0
document_json: str | None = None
@dataclass
class ChunkingOptions:
chunker_type: str = "hybrid" # "hybrid", "hierarchical", "page"
max_tokens: int = 512
merge_peers: bool = True
repeat_table_header: bool = True
def is_default(self) -> bool:
return self == ChunkingOptions()
@dataclass
class ChunkResult:
text: str
headings: list[str] = field(default_factory=list)
source_page: int | None = None
token_count: int = 0

View file

@ -0,0 +1,84 @@
"""Local Docling chunker — runs chunking in-process using docling-core.
This adapter implements the DocumentChunker port. It deserializes a
DoclingDocument from JSON, applies the requested chunker, and returns
domain ChunkResult objects.
"""
from __future__ import annotations
import asyncio
import json
import logging
from docling_core.transforms.chunker import HierarchicalChunker
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
from docling_core.types.doc.document import DoclingDocument
from domain.value_objects import ChunkingOptions, ChunkResult
logger = logging.getLogger(__name__)
def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResult]:
if not document_json or not document_json.strip():
raise ValueError("Empty document JSON — nothing to chunk")
try:
doc_data = json.loads(document_json)
except json.JSONDecodeError as e:
raise ValueError(f"Malformed document JSON: {e}") from e
doc = DoclingDocument.model_validate(doc_data)
chunker = _build_chunker(options)
results: list[ChunkResult] = []
for chunk in chunker.chunk(doc):
source_page = None
token_count = 0
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
for doc_item in chunk.meta.doc_items:
if hasattr(doc_item, "prov") and doc_item.prov:
source_page = doc_item.prov[0].page_no
break
if hasattr(chunker, "tokenizer") and chunker.tokenizer:
token_count = chunker.tokenizer.count_tokens(chunk.text)
headings = list(chunk.meta.headings) if chunk.meta and chunk.meta.headings else []
results.append(
ChunkResult(
text=chunk.text,
headings=headings,
source_page=source_page,
token_count=token_count,
)
)
logger.info("Chunked document into %d chunks (chunker=%s)", len(results), options.chunker_type)
return results
def _build_chunker(options: ChunkingOptions) -> HierarchicalChunker | HybridChunker:
if options.chunker_type == "hierarchical":
return HierarchicalChunker()
return HybridChunker(
max_tokens=options.max_tokens,
merge_peers=options.merge_peers,
repeat_table_header=options.repeat_table_header,
)
class LocalChunker:
"""Adapter that runs docling-core chunking locally."""
async def chunk(
self,
document_json: str,
options: ChunkingOptions,
) -> list[ChunkResult]:
return await asyncio.to_thread(_chunk_sync, document_json, options)

View file

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import json
import logging import logging
import threading import threading
@ -83,6 +84,7 @@ def _get_element_type(item: DocItem) -> str:
# Pipeline factory # Pipeline factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter: def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
table_options = TableStructureOptions( table_options = TableStructureOptions(
do_cell_matching=True, do_cell_matching=True,
@ -126,6 +128,7 @@ def _select_converter(options: ConversionOptions) -> DoclingConverter:
# Page extraction # Page extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]: def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
pages: dict[int, PageDetail] = {} pages: dict[int, PageDetail] = {}
document = doc_result.document document = doc_result.document
@ -149,7 +152,9 @@ def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
def _process_content_item( def _process_content_item(
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail], item: DocItem | GroupItem,
level: int,
pages: dict[int, PageDetail],
) -> bool: ) -> bool:
if isinstance(item, GroupItem): if isinstance(item, GroupItem):
return True return True
@ -163,9 +168,13 @@ def _process_content_item(
if page_no not in pages: if page_no not in pages:
logger.warning( logger.warning(
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)", "Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT, page_no,
_DEFAULT_PAGE_WIDTH,
_DEFAULT_PAGE_HEIGHT,
)
pages[page_no] = PageDetail(
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
) )
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
page_height = pages[page_no].height page_height = pages[page_no].height
@ -199,6 +208,7 @@ def _process_content_item(
# Synchronous conversion (called via asyncio.to_thread) # Synchronous conversion (called via asyncio.to_thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult: def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
with _converter_lock: with _converter_lock:
conv = _select_converter(options) conv = _select_converter(options)
@ -213,7 +223,9 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
PageDetail( PageDetail(
page_number=i + 1, page_number=i + 1,
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH, width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT, height=doc.pages[i + 1].size.height
if (i + 1) in doc.pages
else _DEFAULT_PAGE_HEIGHT,
) )
for i in range(page_count) for i in range(page_count)
] ]
@ -227,6 +239,7 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
content_html=doc.export_to_html(), content_html=doc.export_to_html(),
pages=pages_detail, pages=pages_detail,
skipped_items=skipped, skipped_items=skipped,
document_json=json.dumps(doc.export_to_dict()),
) )
@ -234,10 +247,13 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
# Public adapter class # Public adapter class
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class LocalConverter: class LocalConverter:
"""Adapter that runs Docling locally as a Python library.""" """Adapter that runs Docling locally as a Python library."""
async def convert( async def convert(
self, file_path: str, options: ConversionOptions, self,
file_path: str,
options: ConversionOptions,
) -> ConversionResult: ) -> ConversionResult:
return await asyncio.to_thread(_convert_sync, file_path, options) return await asyncio.to_thread(_convert_sync, file_path, options)

View file

@ -71,7 +71,9 @@ class ServeConverter:
return headers return headers
async def convert( async def convert(
self, file_path: str, options: ConversionOptions, self,
file_path: str,
options: ConversionOptions,
) -> ConversionResult: ) -> ConversionResult:
"""Convert a document by uploading it to Docling Serve.""" """Convert a document by uploading it to Docling Serve."""
path = Path(file_path) path = Path(file_path)
@ -202,7 +204,9 @@ def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
page_no = prov.get("page_no", 1) page_no = prov.get("page_no", 1)
if page_no not in pages: if page_no not in pages:
pages[page_no] = PageDetail( pages[page_no] = PageDetail(
page_number=page_no, width=612.0, height=792.0, page_number=page_no,
width=612.0,
height=792.0,
) )
bbox_data = prov.get("bbox", {}) bbox_data = prov.get("bbox", {})

View file

@ -14,7 +14,9 @@ class Settings:
conversion_timeout: int = 600 conversion_timeout: int = 600
upload_dir: str = "./uploads" upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db" db_path: str = "./data/docling_studio.db"
cors_origins: list[str] = field(default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]) cors_origins: list[str] = field(
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
)
@classmethod @classmethod
def from_env(cls) -> Settings: def from_env(cls) -> Settings:

View file

@ -40,6 +40,7 @@ def _build_converter():
"""Build the converter adapter based on configuration.""" """Build the converter adapter based on configuration."""
if settings.conversion_engine == "remote": if settings.conversion_engine == "remote":
from infra.serve_converter import ServeConverter from infra.serve_converter import ServeConverter
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url) logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
return ServeConverter( return ServeConverter(
base_url=settings.docling_serve_url, base_url=settings.docling_serve_url,
@ -47,14 +48,26 @@ def _build_converter():
) )
else: else:
from infra.local_converter import LocalConverter from infra.local_converter import LocalConverter
logger.info("Using local Docling converter") logger.info("Using local Docling converter")
return LocalConverter() return LocalConverter()
def _build_chunker():
"""Build the chunker adapter — only available in local mode."""
if settings.conversion_engine == "local":
from infra.local_chunker import LocalChunker
return LocalChunker()
return None
def _build_analysis_service() -> AnalysisService: def _build_analysis_service() -> AnalysisService:
converter = _build_converter() converter = _build_converter()
chunker = _build_chunker()
return AnalysisService( return AnalysisService(
converter=converter, converter=converter,
chunker=chunker,
conversion_timeout=settings.conversion_timeout, conversion_timeout=settings.conversion_timeout,
) )
@ -63,6 +76,7 @@ def _build_analysis_service() -> AnalysisService:
# FastAPI app # FastAPI app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()

View file

@ -16,6 +16,7 @@ def _parse_dt(value: str | None) -> datetime | None:
def _row_to_job(row) -> AnalysisJob: def _row_to_job(row) -> AnalysisJob:
keys = row.keys()
return AnalysisJob( return AnalysisJob(
id=row["id"], id=row["id"],
document_id=row["document_id"], document_id=row["document_id"],
@ -23,11 +24,13 @@ def _row_to_job(row) -> AnalysisJob:
content_markdown=row["content_markdown"], content_markdown=row["content_markdown"],
content_html=row["content_html"], content_html=row["content_html"],
pages_json=row["pages_json"], pages_json=row["pages_json"],
document_json=row["document_json"] if "document_json" in keys else None,
chunks_json=row["chunks_json"] if "chunks_json" in keys else None,
error_message=row["error_message"], error_message=row["error_message"],
started_at=_parse_dt(row["started_at"]), started_at=_parse_dt(row["started_at"]),
completed_at=_parse_dt(row["completed_at"]), completed_at=_parse_dt(row["completed_at"]),
created_at=_parse_dt(row["created_at"]) or datetime.now(), created_at=_parse_dt(row["created_at"]) or datetime.now(),
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118 document_filename=row["filename"] if "filename" in keys else None,
) )
@ -60,9 +63,7 @@ async def find_all(*, limit: int = 200, offset: int = 0) -> list[AnalysisJob]:
async def find_by_id(job_id: str) -> AnalysisJob | None: async def find_by_id(job_id: str) -> AnalysisJob | None:
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute( cursor = await db.execute(f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,))
f"{_SELECT_WITH_DOC} WHERE aj.id = ?", (job_id,)
)
row = await cursor.fetchone() row = await cursor.fetchone()
return _row_to_job(row) if row else None return _row_to_job(row) if row else None
@ -72,17 +73,36 @@ async def update_status(job: AnalysisJob) -> None:
await db.execute( await db.execute(
"""UPDATE analysis_jobs """UPDATE analysis_jobs
SET status = ?, content_markdown = ?, content_html = ?, SET status = ?, content_markdown = ?, content_html = ?,
pages_json = ?, error_message = ?, started_at = ?, completed_at = ? pages_json = ?, document_json = ?, chunks_json = ?,
error_message = ?, started_at = ?, completed_at = ?
WHERE id = ?""", WHERE id = ?""",
(job.status.value, job.content_markdown, job.content_html, (
job.pages_json, job.error_message, job.status.value,
str(job.started_at) if job.started_at else None, job.content_markdown,
str(job.completed_at) if job.completed_at else None, job.content_html,
job.id), job.pages_json,
job.document_json,
job.chunks_json,
job.error_message,
str(job.started_at) if job.started_at else None,
str(job.completed_at) if job.completed_at else None,
job.id,
),
) )
await db.commit() await db.commit()
async def update_chunks(job_id: str, chunks_json: str) -> bool:
"""Update only the chunks_json column for a completed analysis."""
async with get_connection() as db:
cursor = await db.execute(
"UPDATE analysis_jobs SET chunks_json = ? WHERE id = ?",
(chunks_json, job_id),
)
await db.commit()
return cursor.rowcount > 0
async def delete(job_id: str) -> bool: async def delete(job_id: str) -> bool:
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,)) cursor = await db.execute("DELETE FROM analysis_jobs WHERE id = ?", (job_id,))
@ -93,8 +113,6 @@ async def delete(job_id: str) -> bool:
async def delete_by_document(document_id: str) -> int: async def delete_by_document(document_id: str) -> int:
"""Delete all analysis jobs for a given document. Returns count deleted.""" """Delete all analysis jobs for a given document. Returns count deleted."""
async with get_connection() as db: async with get_connection() as db:
cursor = await db.execute( cursor = await db.execute("DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,))
"DELETE FROM analysis_jobs WHERE document_id = ?", (document_id,)
)
await db.commit() await db.commit()
return cursor.rowcount return cursor.rowcount

View file

@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS analysis_jobs (
content_markdown TEXT, content_markdown TEXT,
content_html TEXT, content_html TEXT,
pages_json TEXT, pages_json TEXT,
document_json TEXT,
chunks_json TEXT,
error_message TEXT, error_message TEXT,
started_at TEXT, started_at TEXT,
completed_at TEXT, completed_at TEXT,
@ -42,11 +44,29 @@ CREATE INDEX IF NOT EXISTS idx_documents_created_at ON documents(created_at);
""" """
_MIGRATIONS = [
("document_json", "ALTER TABLE analysis_jobs ADD COLUMN document_json TEXT"),
("chunks_json", "ALTER TABLE analysis_jobs ADD COLUMN chunks_json TEXT"),
]
async def _run_migrations(db: aiosqlite.Connection) -> None:
"""Add columns that may be missing in older databases."""
cursor = await db.execute("PRAGMA table_info(analysis_jobs)")
existing = {row[1] for row in await cursor.fetchall()}
for col_name, ddl in _MIGRATIONS:
if col_name not in existing:
await db.execute(ddl)
logger.info("Migration: added column %s to analysis_jobs", col_name)
await db.commit()
async def init_db() -> None: async def init_db() -> None:
"""Create database file and tables if they don't exist.""" """Create database file and tables if they don't exist."""
os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.executescript(_SCHEMA) await db.executescript(_SCHEMA)
await _run_migrations(db)
await db.commit() await db.commit()
logger.info("Database initialized at %s", DB_PATH) logger.info("Database initialized at %s", DB_PATH)

View file

@ -28,8 +28,15 @@ async def insert(doc: Document) -> None:
await db.execute( await db.execute(
"""INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at) """INSERT INTO documents (id, filename, content_type, file_size, page_count, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(doc.id, doc.filename, doc.content_type, doc.file_size, (
doc.page_count, doc.storage_path, str(doc.created_at)), doc.id,
doc.filename,
doc.content_type,
doc.file_size,
doc.page_count,
doc.storage_path,
str(doc.created_at),
),
) )
await db.commit() await db.commit()

View file

@ -13,24 +13,46 @@ import logging
from dataclasses import asdict from dataclasses import asdict
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from domain.models import AnalysisJob from domain.models import AnalysisJob, AnalysisStatus
from domain.value_objects import ConversionOptions, ConversionResult from domain.value_objects import ChunkingOptions, ChunkResult, ConversionOptions, ConversionResult
if TYPE_CHECKING: if TYPE_CHECKING:
from domain.ports import DocumentConverter from domain.ports import DocumentChunker, DocumentConverter
from persistence import analysis_repo, document_repo from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _chunk_to_dict(c: ChunkResult) -> dict:
"""Serialize ChunkResult to a camelCase dict matching the frontend API contract."""
return {
"text": c.text,
"headings": c.headings,
"sourcePage": c.source_page,
"tokenCount": c.token_count,
}
class AnalysisService: class AnalysisService:
"""Orchestrates document analysis using an injected converter.""" """Orchestrates document analysis using an injected converter."""
def __init__(self, converter: DocumentConverter, conversion_timeout: int = 600): def __init__(
self,
converter: DocumentConverter,
chunker: DocumentChunker | None = None,
conversion_timeout: int = 600,
):
self._converter = converter self._converter = converter
self._chunker = chunker
self._conversion_timeout = conversion_timeout self._conversion_timeout = conversion_timeout
async def create(self, document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob: async def create(
self,
document_id: str,
*,
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> AnalysisJob:
"""Create a new analysis job and launch background processing.""" """Create a new analysis job and launch background processing."""
doc = await document_repo.find_by_id(document_id) doc = await document_repo.find_by_id(document_id)
if not doc: if not doc:
@ -41,7 +63,13 @@ class AnalysisService:
await analysis_repo.insert(job) await analysis_repo.insert(job)
task = asyncio.create_task( task = asyncio.create_task(
self._run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options) self._run_analysis(
job.id,
doc.storage_path,
doc.filename,
pipeline_options,
chunking_options,
)
) )
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id)) task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
@ -56,10 +84,35 @@ class AnalysisService:
async def delete(self, job_id: str) -> bool: async def delete(self, job_id: str) -> bool:
return await analysis_repo.delete(job_id) return await analysis_repo.delete(job_id)
async def rechunk(self, job_id: str, chunking_options: dict) -> list[ChunkResult]:
"""Re-chunk an existing completed analysis with new options."""
job = await analysis_repo.find_by_id(job_id)
if not job:
raise ValueError(f"Analysis not found: {job_id}")
if job.status != AnalysisStatus.COMPLETED:
raise ValueError(f"Analysis is not completed: {job_id}")
if not job.document_json:
raise ValueError(f"No document data available for re-chunking: {job_id}")
if not self._chunker:
raise ValueError("Chunking is not available")
options = ChunkingOptions(**chunking_options)
chunks = await self._chunker.chunk(job.document_json, options)
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
await analysis_repo.update_chunks(job_id, chunks_json)
return chunks
async def _run_analysis( async def _run_analysis(
self, job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None, self,
job_id: str,
file_path: str,
filename: str,
pipeline_options: dict | None = None,
chunking_options: dict | None = None,
) -> None: ) -> None:
"""Background task: run conversion and update job status.""" """Background task: run conversion and optionally chunk."""
try: try:
job = await analysis_repo.find_by_id(job_id) job = await analysis_repo.find_by_id(job_id)
if not job: if not job:
@ -79,10 +132,19 @@ class AnalysisService:
pages_json = json.dumps([asdict(p) for p in result.pages]) pages_json = json.dumps([asdict(p) for p in result.pages])
chunks_json = None
if chunking_options and self._chunker and result.document_json:
chunk_opts = ChunkingOptions(**chunking_options)
chunks = await self._chunker.chunk(result.document_json, chunk_opts)
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id)
job.mark_completed( job.mark_completed(
markdown=result.content_markdown, markdown=result.content_markdown,
html=result.content_html, html=result.content_html,
pages_json=pages_json, pages_json=pages_json,
document_json=result.document_json,
chunks_json=chunks_json,
) )
await analysis_repo.update_status(job) await analysis_repo.update_status(job)

View file

@ -53,8 +53,12 @@ class TestDocumentEndpoints:
@patch("services.document_service.find_by_id", new_callable=AsyncMock) @patch("services.document_service.find_by_id", new_callable=AsyncMock)
def test_get_document(self, mock_find, client): def test_get_document(self, mock_find, client):
mock_find.return_value = Document( mock_find.return_value = Document(
id="d1", filename="test.pdf", content_type="application/pdf", id="d1",
file_size=2048, page_count=3, storage_path="/tmp/test", filename="test.pdf",
content_type="application/pdf",
file_size=2048,
page_count=3,
storage_path="/tmp/test",
) )
resp = client.get("/api/documents/d1") resp = client.get("/api/documents/d1")
@ -74,8 +78,10 @@ class TestDocumentEndpoints:
@patch("services.document_service.upload", new_callable=AsyncMock) @patch("services.document_service.upload", new_callable=AsyncMock)
def test_upload_document(self, mock_upload, client): def test_upload_document(self, mock_upload, client):
mock_upload.return_value = Document( mock_upload.return_value = Document(
id="new-1", filename="uploaded.pdf", id="new-1",
content_type="application/pdf", file_size=512, filename="uploaded.pdf",
content_type="application/pdf",
file_size=512,
storage_path="/tmp/uploaded", storage_path="/tmp/uploaded",
) )
@ -115,9 +121,11 @@ class TestDocumentEndpoints:
class TestAnalysisEndpoints: class TestAnalysisEndpoints:
def test_list_analyses(self, client, mock_analysis_service): def test_list_analyses(self, client, mock_analysis_service):
mock_analysis_service.find_all = AsyncMock(return_value=[ mock_analysis_service.find_all = AsyncMock(
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"), return_value=[
]) AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
]
)
resp = client.get("/api/analyses") resp = client.get("/api/analyses")
assert resp.status_code == 200 assert resp.status_code == 200
@ -146,37 +154,52 @@ class TestAnalysisEndpoints:
assert resp.status_code == 404 assert resp.status_code == 404
def test_create_analysis(self, client, mock_analysis_service): def test_create_analysis(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j1", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
)) id="j1",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={"documentId": "d1"}) resp = client.post("/api/analyses", json={"documentId": "d1"})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["id"] == "j1" assert data["id"] == "j1"
assert data["documentId"] == "d1" assert data["documentId"] == "d1"
mock_analysis_service.create.assert_called_once_with("d1", pipeline_options=None) mock_analysis_service.create.assert_called_once_with(
"d1",
pipeline_options=None,
chunking_options=None,
)
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service): def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j2", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
)) id="j2",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": { json={
"do_ocr": False, "documentId": "d1",
"do_table_structure": True, "pipelineOptions": {
"table_mode": "fast", "do_ocr": False,
"do_code_enrichment": True, "do_table_structure": True,
"do_formula_enrichment": False, "table_mode": "fast",
"do_picture_classification": False, "do_code_enrichment": True,
"do_picture_description": False, "do_formula_enrichment": False,
"generate_picture_images": True, "do_picture_classification": False,
"generate_page_images": False, "do_picture_description": False,
"images_scale": 2.0, "generate_picture_images": True,
} "generate_page_images": False,
}) "images_scale": 2.0,
},
},
)
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["id"] == "j2" assert data["id"] == "j2"
@ -191,14 +214,17 @@ class TestAnalysisEndpoints:
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service): def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
"""Pipeline options should use defaults for unspecified fields.""" """Pipeline options should use defaults for unspecified fields."""
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob( mock_analysis_service.create = AsyncMock(
id="j3", document_id="d1", document_filename="test.pdf", return_value=AnalysisJob(
)) id="j3",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses", json={"documentId": "d1", "pipelineOptions": {"do_ocr": False}}
"pipelineOptions": {"do_ocr": False} )
})
assert resp.status_code == 200 assert resp.status_code == 200
opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"] opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]

View file

@ -14,6 +14,7 @@ from domain.bbox import EMPTY_BBOX, to_topleft_list
# Standard conversions # Standard conversions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestToTopleftListStandard: class TestToTopleftListStandard:
"""Normal bbox conversions (happy path).""" """Normal bbox conversions (happy path)."""
@ -29,10 +30,10 @@ class TestToTopleftListStandard:
result = to_topleft_list(bbox, page_height=792.0) result = to_topleft_list(bbox, page_height=792.0)
# After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192 # After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192
assert result[0] == 50 # l unchanged assert result[0] == 50 # l unchanged
assert result[1] == pytest.approx(92.0) # t = page_height - old_t assert result[1] == pytest.approx(92.0) # t = page_height - old_t
assert result[2] == 200 # r unchanged assert result[2] == 200 # r unchanged
assert result[3] == pytest.approx(192.0) # b = page_height - old_b assert result[3] == pytest.approx(192.0) # b = page_height - old_b
def test_result_has_positive_dimensions(self): def test_result_has_positive_dimensions(self):
"""Converted bbox should always have b > t (positive height).""" """Converted bbox should always have b > t (positive height)."""
@ -60,6 +61,7 @@ class TestToTopleftListStandard:
# Page format variations # Page format variations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPageFormats: class TestPageFormats:
"""Verify correct conversion across different page sizes.""" """Verify correct conversion across different page sizes."""
@ -105,6 +107,7 @@ class TestPageFormats:
# Degenerate / edge-case bboxes # Degenerate / edge-case bboxes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDegenerateBboxes: class TestDegenerateBboxes:
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX.""" """Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
@ -151,6 +154,7 @@ class TestDegenerateBboxes:
# Precision and boundary values # Precision and boundary values
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPrecision: class TestPrecision:
"""Floating-point precision and edge values.""" """Floating-point precision and edge values."""

View file

@ -0,0 +1,292 @@
"""Tests for chunking feature — domain, schemas, service, and API endpoints."""
from __future__ import annotations
import json
from dataclasses import asdict
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
from api.schemas import ChunkingOptionsRequest, ChunkResponse, RechunkRequest
from domain.models import AnalysisJob, AnalysisStatus
from domain.value_objects import ChunkingOptions, ChunkResult
from main import app
# ---------------------------------------------------------------------------
# Domain: value objects
# ---------------------------------------------------------------------------
class TestChunkingOptions:
def test_defaults(self):
opts = ChunkingOptions()
assert opts.chunker_type == "hybrid"
assert opts.max_tokens == 512
assert opts.merge_peers is True
assert opts.repeat_table_header is True
def test_custom_values(self):
opts = ChunkingOptions(chunker_type="hierarchical", max_tokens=256, merge_peers=False)
assert opts.chunker_type == "hierarchical"
assert opts.max_tokens == 256
assert opts.merge_peers is False
def test_is_default(self):
assert ChunkingOptions().is_default()
assert not ChunkingOptions(max_tokens=256).is_default()
class TestChunkResult:
def test_defaults(self):
chunk = ChunkResult(text="hello")
assert chunk.text == "hello"
assert chunk.headings == []
assert chunk.source_page is None
assert chunk.token_count == 0
def test_full_values(self):
chunk = ChunkResult(
text="content",
headings=["Title", "Section"],
source_page=3,
token_count=42,
)
assert chunk.headings == ["Title", "Section"]
assert chunk.source_page == 3
assert chunk.token_count == 42
def test_serializable(self):
chunk = ChunkResult(text="x", headings=["h1"], source_page=1, token_count=10)
data = asdict(chunk)
assert data == {"text": "x", "headings": ["h1"], "source_page": 1, "token_count": 10}
# ---------------------------------------------------------------------------
# Domain: AnalysisJob with chunking fields
# ---------------------------------------------------------------------------
class TestAnalysisJobChunking:
def test_default_chunking_fields(self):
job = AnalysisJob()
assert job.document_json is None
assert job.chunks_json is None
def test_mark_completed_with_chunks(self):
job = AnalysisJob()
job.mark_running()
job.mark_completed(
markdown="# Title",
html="<h1>Title</h1>",
pages_json="[]",
document_json='{"name": "doc"}',
chunks_json='[{"text": "chunk1"}]',
)
assert job.status == AnalysisStatus.COMPLETED
assert job.document_json == '{"name": "doc"}'
assert job.chunks_json == '[{"text": "chunk1"}]'
def test_mark_completed_without_chunks(self):
job = AnalysisJob()
job.mark_running()
job.mark_completed(markdown="md", html="html", pages_json="[]")
assert job.document_json is None
assert job.chunks_json is None
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class TestChunkingOptionsRequest:
def test_defaults(self):
opts = ChunkingOptionsRequest()
assert opts.chunker_type == "hybrid"
assert opts.max_tokens == 512
assert opts.merge_peers is True
assert opts.repeat_table_header is True
def test_custom_values(self):
opts = ChunkingOptionsRequest(chunker_type="hierarchical", max_tokens=1024)
assert opts.chunker_type == "hierarchical"
assert opts.max_tokens == 1024
def test_invalid_chunker_type(self):
with pytest.raises(ValueError, match="chunker_type"):
ChunkingOptionsRequest(chunker_type="invalid")
def test_max_tokens_too_low(self):
with pytest.raises(ValueError, match="max_tokens"):
ChunkingOptionsRequest(max_tokens=10)
def test_max_tokens_too_high(self):
with pytest.raises(ValueError, match="max_tokens"):
ChunkingOptionsRequest(max_tokens=10000)
def test_boundary_max_tokens(self):
opts_low = ChunkingOptionsRequest(max_tokens=64)
assert opts_low.max_tokens == 64
opts_high = ChunkingOptionsRequest(max_tokens=8192)
assert opts_high.max_tokens == 8192
class TestChunkResponse:
def test_serializes_to_camel_case(self):
resp = ChunkResponse(text="hello", headings=["H1"], source_page=1, token_count=5)
data = resp.model_dump(by_alias=True)
assert "sourcePage" in data
assert "tokenCount" in data
assert data["text"] == "hello"
class TestRechunkRequest:
def test_parses(self):
req = RechunkRequest(chunkingOptions=ChunkingOptionsRequest(max_tokens=256))
assert req.chunkingOptions.max_tokens == 256
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def mock_analysis_service(client):
mock_svc = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock_svc
yield mock_svc
app.state.analysis_service = original
class TestCreateAnalysisWithChunking:
def test_create_with_chunking_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(
return_value=AnalysisJob(
id="j1",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post(
"/api/analyses",
json={
"documentId": "d1",
"chunkingOptions": {
"chunker_type": "hybrid",
"max_tokens": 256,
"merge_peers": False,
},
},
)
assert resp.status_code == 200
call_kwargs = mock_analysis_service.create.call_args
chunking = call_kwargs.kwargs["chunking_options"]
assert chunking["chunker_type"] == "hybrid"
assert chunking["max_tokens"] == 256
assert chunking["merge_peers"] is False
def test_create_without_chunking_options(self, client, mock_analysis_service):
mock_analysis_service.create = AsyncMock(
return_value=AnalysisJob(
id="j1",
document_id="d1",
document_filename="test.pdf",
)
)
resp = client.post("/api/analyses", json={"documentId": "d1"})
assert resp.status_code == 200
call_kwargs = mock_analysis_service.create.call_args
assert call_kwargs.kwargs["chunking_options"] is None
def test_response_includes_chunking_fields(self, client, mock_analysis_service):
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
job.mark_running()
job.mark_completed(
markdown="# Title",
html="<h1>Title</h1>",
pages_json="[]",
document_json='{"name": "doc"}',
chunks_json=json.dumps([asdict(ChunkResult(text="chunk1", token_count=5))]),
)
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
resp = client.get("/api/analyses/j1")
assert resp.status_code == 200
data = resp.json()
assert data["hasDocumentJson"] is True
assert data["chunksJson"] is not None
chunks = json.loads(data["chunksJson"])
assert len(chunks) == 1
assert chunks[0]["text"] == "chunk1"
class TestRechunkEndpoint:
def test_rechunk_success(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
return_value=[
ChunkResult(text="chunk1", headings=["H1"], source_page=1, token_count=10),
ChunkResult(text="chunk2", headings=["H1", "H2"], source_page=2, token_count=20),
]
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "hybrid", "max_tokens": 128},
},
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["text"] == "chunk1"
assert data[0]["sourcePage"] == 1
assert data[0]["tokenCount"] == 10
assert data[1]["headings"] == ["H1", "H2"]
def test_rechunk_not_completed(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
side_effect=ValueError("Analysis is not completed: j1"),
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "hybrid"},
},
)
assert resp.status_code == 400
def test_rechunk_no_document_json(self, client, mock_analysis_service):
mock_analysis_service.rechunk = AsyncMock(
side_effect=ValueError("No document data available for re-chunking: j1"),
)
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "hierarchical"},
},
)
assert resp.status_code == 400
def test_rechunk_invalid_chunker_type(self, client, mock_analysis_service):
resp = client.post(
"/api/analyses/j1/rechunk",
json={
"chunkingOptions": {"chunker_type": "invalid"},
},
)
assert resp.status_code == 422

View file

@ -23,6 +23,7 @@ from infra.local_converter import (
# build_converter — verifies Docling pipeline options are wired correctly # build_converter — verifies Docling pipeline options are wired correctly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildConverter: class TestBuildConverter:
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions.""" """Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
@ -101,18 +102,20 @@ class TestBuildConverter:
assert opts.images_scale == 2.0 assert opts.images_scale == 2.0
def test_all_options_combined(self): def test_all_options_combined(self):
conv = build_converter(ConversionOptions( conv = build_converter(
do_ocr=False, ConversionOptions(
do_table_structure=True, do_ocr=False,
table_mode="fast", do_table_structure=True,
do_code_enrichment=True, table_mode="fast",
do_formula_enrichment=True, do_code_enrichment=True,
do_picture_classification=True, do_formula_enrichment=True,
do_picture_description=True, do_picture_classification=True,
generate_picture_images=True, do_picture_description=True,
generate_page_images=True, generate_picture_images=True,
images_scale=1.5, generate_page_images=True,
)) images_scale=1.5,
)
)
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False assert opts.do_ocr is False
assert opts.do_table_structure is True assert opts.do_table_structure is True
@ -130,6 +133,7 @@ class TestBuildConverter:
# convert_document — default vs custom converter routing # convert_document — default vs custom converter routing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConvertDocumentRouting: class TestConvertDocumentRouting:
"""Verify convert_document uses default converter for default opts, custom otherwise.""" """Verify convert_document uses default converter for default opts, custom otherwise."""
@ -142,6 +146,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_get_default.return_value = mock_conv mock_get_default.return_value = mock_conv
@ -159,6 +164,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -176,6 +182,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -193,6 +200,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -210,6 +218,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -226,6 +235,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -242,6 +252,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -258,6 +269,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -275,6 +287,7 @@ class TestConvertDocumentRouting:
mock_result.document.iterate_items.return_value = [] mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = "" mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = "" mock_result.document.export_to_html.return_value = ""
mock_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv mock_build.return_value = mock_conv
@ -299,30 +312,37 @@ class TestConvertDocumentRouting:
# Service layer — pipeline options forwarding # Service layer — pipeline options forwarding
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServiceForwardsPipelineOptions: class TestServiceForwardsPipelineOptions:
"""Verify analysis_service.create and _run_analysis forward pipeline options.""" """Verify analysis_service.create and _run_analysis forward pipeline options."""
@pytest.fixture @pytest.fixture
def mock_doc(self): def mock_doc(self):
from domain.models import Document from domain.models import Document
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf") return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
@pytest.fixture @pytest.fixture
def mock_job(self): def mock_job(self):
from domain.models import AnalysisJob from domain.models import AnalysisJob
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_passes_pipeline_options_to_run( async def test_create_passes_pipeline_options_to_run(
self, mock_analysis_repo, mock_doc_repo, mock_doc, self,
mock_analysis_repo,
mock_doc_repo,
mock_doc,
): ):
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc) mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock() mock_analysis_repo.insert = AsyncMock()
mock_converter = AsyncMock() mock_converter = AsyncMock()
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
opts = {"do_ocr": False, "table_mode": "fast"} opts = {"do_ocr": False, "table_mode": "fast"}
@ -335,13 +355,17 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.analysis_repo") @patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_passes_none_when_no_options( async def test_create_passes_none_when_no_options(
self, mock_analysis_repo, mock_doc_repo, mock_doc, self,
mock_analysis_repo,
mock_doc_repo,
mock_doc,
): ):
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc) mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock() mock_analysis_repo.insert = AsyncMock()
mock_converter = AsyncMock() mock_converter = AsyncMock()
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
with patch("services.analysis_service.asyncio.create_task") as mock_task: with patch("services.analysis_service.asyncio.create_task") as mock_task:
@ -352,7 +376,10 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_forwards_options_to_convert( async def test_run_analysis_forwards_options_to_convert(
self, mock_doc_repo, mock_analysis_repo, mock_job, self,
mock_doc_repo,
mock_analysis_repo,
mock_job,
): ):
from domain.value_objects import ConversionResult, PageDetail from domain.value_objects import ConversionResult, PageDetail
@ -369,6 +396,7 @@ class TestServiceForwardsPipelineOptions:
) )
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
opts = { opts = {
@ -399,7 +427,10 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_uses_defaults_when_no_options( async def test_run_analysis_uses_defaults_when_no_options(
self, mock_doc_repo, mock_analysis_repo, mock_job, self,
mock_doc_repo,
mock_analysis_repo,
mock_job,
): ):
from domain.value_objects import ConversionResult, PageDetail from domain.value_objects import ConversionResult, PageDetail
@ -416,6 +447,7 @@ class TestServiceForwardsPipelineOptions:
) )
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None) await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
@ -429,7 +461,10 @@ class TestServiceForwardsPipelineOptions:
@patch("services.analysis_service.document_repo") @patch("services.analysis_service.document_repo")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_analysis_marks_failed_on_error( async def test_run_analysis_marks_failed_on_error(
self, mock_doc_repo, mock_analysis_repo, mock_job, self,
mock_doc_repo,
mock_analysis_repo,
mock_job,
): ):
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job) mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock() mock_analysis_repo.update_status = AsyncMock()
@ -438,6 +473,7 @@ class TestServiceForwardsPipelineOptions:
mock_converter.convert.side_effect = RuntimeError("Docling crashed") mock_converter.convert.side_effect = RuntimeError("Docling crashed")
from services.analysis_service import AnalysisService from services.analysis_service import AnalysisService
svc = AnalysisService(converter=mock_converter) svc = AnalysisService(converter=mock_converter)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False}) await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
@ -453,6 +489,7 @@ class TestServiceForwardsPipelineOptions:
# API endpoint — full request/response with pipeline options # API endpoint — full request/response with pipeline options
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAnalysisEndpointPipelineOptions: class TestAnalysisEndpointPipelineOptions:
"""Integration-level tests for the analysis creation endpoint with pipeline options.""" """Integration-level tests for the analysis creation endpoint with pipeline options."""
@ -461,6 +498,7 @@ class TestAnalysisEndpointPipelineOptions:
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from main import app from main import app
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@pytest.fixture @pytest.fixture
@ -468,6 +506,7 @@ class TestAnalysisEndpointPipelineOptions:
from unittest.mock import MagicMock from unittest.mock import MagicMock
from main import app from main import app
mock = MagicMock() mock = MagicMock()
original = getattr(app.state, "analysis_service", None) original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock app.state.analysis_service = mock
@ -476,20 +515,25 @@ class TestAnalysisEndpointPipelineOptions:
def test_no_pipeline_options_sends_none(self, client, mock_svc): def test_no_pipeline_options_sends_none(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={"documentId": "d1"}) client.post("/api/analyses", json={"documentId": "d1"})
mock_svc.create.assert_called_once_with("d1", pipeline_options=None) mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None)
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc): def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={ client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {}, json={
}) "documentId": "d1",
"pipelineOptions": {},
},
)
opts = mock_svc.create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is True assert opts["do_ocr"] is True
@ -501,12 +545,16 @@ class TestAnalysisEndpointPipelineOptions:
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc): def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={ client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5}, json={
}) "documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
},
)
opts = mock_svc.create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
@ -522,6 +570,7 @@ class TestAnalysisEndpointPipelineOptions:
def test_full_pipeline_options(self, client, mock_svc): def test_full_pipeline_options(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
payload = { payload = {
@ -547,18 +596,25 @@ class TestAnalysisEndpointPipelineOptions:
assert opts == payload["pipelineOptions"] assert opts == payload["pipelineOptions"]
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc): def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {"do_ocr": "not-a-bool"}, json={
}) "documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"},
},
)
assert resp.status_code == 422 assert resp.status_code == 422
def test_unknown_pipeline_option_ignored(self, client, mock_svc): def test_unknown_pipeline_option_ignored(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1")) mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
resp = client.post("/api/analyses", json={ resp = client.post(
"documentId": "d1", "/api/analyses",
"pipelineOptions": {"do_ocr": True, "unknown_field": True}, json={
}) "documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
},
)
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -1,6 +1,5 @@
"""Tests for persistence repositories using a temporary SQLite database.""" """Tests for persistence repositories using a temporary SQLite database."""
import pytest import pytest
from domain.models import AnalysisJob, AnalysisStatus, Document from domain.models import AnalysisJob, AnalysisStatus, Document

View file

@ -1,6 +1,5 @@
"""Tests for API schemas — camelCase serialization and validation.""" """Tests for API schemas — camelCase serialization and validation."""
import pytest import pytest
from api.schemas import ( from api.schemas import (
@ -97,7 +96,10 @@ class TestPipelineOptionsRequest:
def test_custom_values(self): def test_custom_values(self):
opts = PipelineOptionsRequest( opts = PipelineOptionsRequest(
do_ocr=False, table_mode="fast", do_code_enrichment=True, images_scale=2.0, do_ocr=False,
table_mode="fast",
do_code_enrichment=True,
images_scale=2.0,
) )
assert opts.do_ocr is False assert opts.do_ocr is False
assert opts.table_mode == "fast" assert opts.table_mode == "fast"

View file

@ -20,6 +20,7 @@ from infra.serve_converter import (
# Unit tests — form data building # Unit tests — form data building
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildFormData: class TestBuildFormData:
def test_default_options(self): def test_default_options(self):
data = _build_form_data(ConversionOptions()) data = _build_form_data(ConversionOptions())
@ -36,7 +37,9 @@ class TestBuildFormData:
def test_custom_options(self): def test_custom_options(self):
opts = ConversionOptions( opts = ConversionOptions(
do_ocr=False, table_mode="fast", images_scale=2.0, do_ocr=False,
table_mode="fast",
images_scale=2.0,
generate_picture_images=True, generate_picture_images=True,
) )
data = _build_form_data(opts) data = _build_form_data(opts)
@ -50,6 +53,7 @@ class TestBuildFormData:
# Unit tests — response parsing # Unit tests — response parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestParseResponse: class TestParseResponse:
def test_minimal_response(self): def test_minimal_response(self):
data = { data = {
@ -82,12 +86,34 @@ class TestParseResponse:
{ {
"label": "title", "label": "title",
"text": "Title", "text": "Title",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}], "prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 20,
"r": 200,
"b": 40,
"coord_origin": "TOPLEFT",
},
}
],
}, },
{ {
"label": "paragraph", "label": "paragraph",
"text": "Text", "text": "Text",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 50, "r": 200, "b": 70, "coord_origin": "TOPLEFT"}}], "prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 50,
"r": 200,
"b": 70,
"coord_origin": "TOPLEFT",
},
}
],
}, },
], ],
"tables": [], "tables": [],
@ -112,7 +138,9 @@ class TestParseResponse:
"1": {"size": {"width": 612.0, "height": 792.0}}, "1": {"size": {"width": 612.0, "height": 792.0}},
"2": {"size": {"width": 595.0, "height": 842.0}}, "2": {"size": {"width": 595.0, "height": 842.0}},
}, },
"texts": [], "tables": [], "pictures": [], "texts": [],
"tables": [],
"pictures": [],
}, },
} }
} }
@ -135,7 +163,9 @@ class TestParseResponse:
def test_json_content_as_string(self): def test_json_content_as_string(self):
json_doc = { json_doc = {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [], "tables": [], "pictures": [], "texts": [],
"tables": [],
"pictures": [],
} }
data = { data = {
"document": { "document": {
@ -171,10 +201,40 @@ class TestParseResponse:
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [], "texts": [],
"tables": [ "tables": [
{"label": "table", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 300, "b": 200, "coord_origin": "TOPLEFT"}}]}, {
"label": "table",
"text": "",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 10,
"r": 300,
"b": 200,
"coord_origin": "TOPLEFT",
},
}
],
},
], ],
"pictures": [ "pictures": [
{"label": "picture", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 50, "t": 300, "r": 250, "b": 500, "coord_origin": "TOPLEFT"}}]}, {
"label": "picture",
"text": "",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 50,
"t": 300,
"r": 250,
"b": 500,
"coord_origin": "TOPLEFT",
},
}
],
},
], ],
}, },
} }
@ -189,14 +249,19 @@ class TestParseResponse:
# Unit tests — bbox extraction # Unit tests — bbox extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestExtractBbox: class TestExtractBbox:
def test_topleft_passthrough(self): def test_topleft_passthrough(self):
bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0) bbox = _extract_bbox(
{"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0
)
assert bbox == [10, 20, 100, 50] assert bbox == [10, 20, 100, 50]
def test_bottomleft_conversion(self): def test_bottomleft_conversion(self):
# In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box) # In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box)
bbox = _extract_bbox({"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0) bbox = _extract_bbox(
{"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0
)
# new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50 # new_top = 792 - 772 = 20, new_bottom = 792 - 742 = 50
assert bbox == [10, 20, 100, 50] assert bbox == [10, 20, 100, 50]
@ -217,9 +282,11 @@ class TestExtractBbox:
# Unit tests — label mapping # Unit tests — label mapping
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLabelMapping: class TestLabelMapping:
def test_known_labels(self): def test_known_labels(self):
from infra.serve_converter import _LABEL_MAP from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP["table"] == "table" assert _LABEL_MAP["table"] == "table"
assert _LABEL_MAP["picture"] == "picture" assert _LABEL_MAP["picture"] == "picture"
assert _LABEL_MAP["figure"] == "picture" assert _LABEL_MAP["figure"] == "picture"
@ -232,6 +299,7 @@ class TestLabelMapping:
def test_unknown_label_defaults_to_text(self): def test_unknown_label_defaults_to_text(self):
from infra.serve_converter import _LABEL_MAP from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP.get("unknown_thing", "text") == "text" assert _LABEL_MAP.get("unknown_thing", "text") == "text"
@ -239,6 +307,7 @@ class TestLabelMapping:
# Unit tests — ServeConverter # Unit tests — ServeConverter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServeConverter: class TestServeConverter:
def test_headers_with_api_key(self): def test_headers_with_api_key(self):
conv = ServeConverter(base_url="http://localhost:5001", api_key="secret") conv = ServeConverter(base_url="http://localhost:5001", api_key="secret")
@ -257,6 +326,7 @@ class TestServeConverter:
# Integration tests — HTTP calls (mocked) # Integration tests — HTTP calls (mocked)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestServeConverterConvert: class TestServeConverterConvert:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_successful_conversion(self, tmp_path): async def test_successful_conversion(self, tmp_path):
@ -270,7 +340,22 @@ class TestServeConverterConvert:
"json_content": { "json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [ "texts": [
{"label": "title", "text": "Converted", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}]}, {
"label": "title",
"text": "Converted",
"prov": [
{
"page_no": 1,
"bbox": {
"l": 10,
"t": 20,
"r": 200,
"b": 40,
"coord_origin": "TOPLEFT",
},
}
],
},
], ],
"tables": [], "tables": [],
"pictures": [], "pictures": [],
@ -312,7 +397,9 @@ class TestServeConverterConvert:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Server Error", request=MagicMock(), response=MagicMock(status_code=500), "Server Error",
request=MagicMock(),
response=MagicMock(status_code=500),
) )
mock_client = AsyncMock() mock_client = AsyncMock()
@ -322,8 +409,10 @@ class TestServeConverterConvert:
conv = ServeConverter(base_url="http://localhost:5001") conv = ServeConverter(base_url="http://localhost:5001")
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client), \ with (
pytest.raises(httpx.HTTPStatusError): patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client),
pytest.raises(httpx.HTTPStatusError),
):
await conv.convert(str(test_file), ConversionOptions()) await conv.convert(str(test_file), ConversionOptions())
@pytest.mark.asyncio @pytest.mark.asyncio
@ -358,6 +447,7 @@ class TestServeConverterConvert:
# Integration — converter wiring in main.py # Integration — converter wiring in main.py
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestConverterWiring: class TestConverterWiring:
def test_local_engine_builds_local_converter(self): def test_local_engine_builds_local_converter(self):
from infra.local_converter import LocalConverter from infra.local_converter import LocalConverter
@ -365,14 +455,19 @@ class TestConverterWiring:
with patch("main.settings", Settings(conversion_engine="local")): with patch("main.settings", Settings(conversion_engine="local")):
from main import _build_converter from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, LocalConverter) assert isinstance(converter, LocalConverter)
def test_remote_engine_builds_serve_converter(self): def test_remote_engine_builds_serve_converter(self):
from infra.settings import Settings from infra.settings import Settings
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001")): with patch(
"main.settings",
Settings(conversion_engine="remote", docling_serve_url="http://serve:5001"),
):
from main import _build_converter from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, ServeConverter) assert isinstance(converter, ServeConverter)
assert converter._base_url == "http://serve:5001" assert converter._base_url == "http://serve:5001"
@ -380,8 +475,16 @@ class TestConverterWiring:
def test_remote_engine_passes_api_key(self): def test_remote_engine_passes_api_key(self):
from infra.settings import Settings from infra.settings import Settings
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001", docling_serve_api_key="my-key")): with patch(
"main.settings",
Settings(
conversion_engine="remote",
docling_serve_url="http://serve:5001",
docling_serve_api_key="my-key",
),
):
from main import _build_converter from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, ServeConverter) assert isinstance(converter, ServeConverter)
assert converter._api_key == "my-key" assert converter._api_key == "my-key"

View file

@ -1,20 +1,34 @@
import type { Analysis, PipelineOptions } from '../../shared/types' import type { Analysis, Chunk, ChunkingOptions, PipelineOptions } from '../../shared/types'
import { apiFetch } from '../../shared/api/http' import { apiFetch } from '../../shared/api/http'
export function createAnalysis( export function createAnalysis(
documentId: string, documentId: string,
pipelineOptions: PipelineOptions | null = null, pipelineOptions: PipelineOptions | null = null,
chunkingOptions: ChunkingOptions | null = null,
): Promise<Analysis> { ): Promise<Analysis> {
const body: Record<string, unknown> = { documentId } const body: Record<string, unknown> = { documentId }
if (pipelineOptions) { if (pipelineOptions) {
body.pipelineOptions = pipelineOptions body.pipelineOptions = pipelineOptions
} }
if (chunkingOptions) {
body.chunkingOptions = chunkingOptions
}
return apiFetch<Analysis>('/api/analyses', { return apiFetch<Analysis>('/api/analyses', {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
export function rechunkAnalysis(
jobId: string,
chunkingOptions: ChunkingOptions,
): Promise<Chunk[]> {
return apiFetch<Chunk[]>(`/api/analyses/${jobId}/rechunk`, {
method: 'POST',
body: JSON.stringify({ chunkingOptions }),
})
}
export function fetchAnalyses(): Promise<Analysis[]> { export function fetchAnalyses(): Promise<Analysis[]> {
return apiFetch<Analysis[]>('/api/analyses') return apiFetch<Analysis[]>('/api/analyses')
} }

View file

@ -131,7 +131,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
const store = useAnalysisStore() const store = useAnalysisStore()
await store.run('d1') await store.run('d1')
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
store.stopPolling() store.stopPolling()
}) })
@ -155,7 +155,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
} }
await store.run('d1', opts) await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts) expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts, null)
store.stopPolling() store.stopPolling()
}) })
@ -168,7 +168,7 @@ describe('useAnalysisStore — pipeline options forwarding', () => {
const opts = { do_ocr: false } const opts = { do_ocr: false }
await store.run('d1', opts) await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }) expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false }, null)
store.stopPolling() store.stopPolling()
}) })

View file

@ -70,7 +70,7 @@ describe('useAnalysisStore', () => {
expect(store.currentAnalysis).toEqual(job) expect(store.currentAnalysis).toEqual(job)
expect(store.analyses[0]).toEqual(job) expect(store.analyses[0]).toEqual(job)
expect(store.running).toBe(true) expect(store.running).toBe(true)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, null)
// Advance timer to trigger polling // Advance timer to trigger polling
await vi.advanceTimersByTimeAsync(2000) await vi.advanceTimersByTimeAsync(2000)
@ -90,7 +90,7 @@ describe('useAnalysisStore', () => {
const options = { do_ocr: false, table_mode: 'fast' } const options = { do_ocr: false, table_mode: 'fast' }
await store.run('d1', options) await store.run('d1', options)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options) expect(api.createAnalysis).toHaveBeenCalledWith('d1', options, null)
store.stopPolling() store.stopPolling()
}) })

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { Analysis, Page, PipelineOptions } from '../../shared/types' import type { Analysis, Chunk, ChunkingOptions, Page, PipelineOptions } from '../../shared/types'
import * as api from './api' import * as api from './api'
export const useAnalysisStore = defineStore('analysis', () => { export const useAnalysisStore = defineStore('analysis', () => {
@ -35,14 +35,44 @@ export const useAnalysisStore = defineStore('analysis', () => {
} }
} }
const currentChunks = computed<Chunk[]>(() => {
if (!currentAnalysis.value?.chunksJson) return []
try {
return JSON.parse(currentAnalysis.value.chunksJson) as Chunk[]
} catch {
return []
}
})
const rechunking = ref(false)
async function rechunk(jobId: string, chunkingOptions: ChunkingOptions): Promise<Chunk[]> {
rechunking.value = true
error.value = null
try {
const chunks = await api.rechunkAnalysis(jobId, chunkingOptions)
if (currentAnalysis.value?.id === jobId) {
currentAnalysis.value = await api.fetchAnalysis(jobId)
}
return chunks
} catch (e) {
error.value = (e as Error).message || 'Failed to rechunk'
console.error('Failed to rechunk', e)
throw e
} finally {
rechunking.value = false
}
}
async function run( async function run(
documentId: string, documentId: string,
pipelineOptions: PipelineOptions | null = null, pipelineOptions: PipelineOptions | null = null,
chunkingOptions: ChunkingOptions | null = null,
): Promise<Analysis> { ): Promise<Analysis> {
running.value = true running.value = true
error.value = null error.value = null
try { try {
const analysis = await api.createAnalysis(documentId, pipelineOptions) const analysis = await api.createAnalysis(documentId, pipelineOptions, chunkingOptions)
currentAnalysis.value = analysis currentAnalysis.value = analysis
analyses.value.unshift(analysis) analyses.value.unshift(analysis)
startPolling(analysis.id) startPolling(analysis.id)
@ -118,11 +148,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
analyses, analyses,
currentAnalysis, currentAnalysis,
currentPages, currentPages,
currentChunks,
running, running,
rechunking,
error, error,
clearError, clearError,
load, load,
run, run,
rechunk,
select, select,
remove, remove,
stopPolling, stopPolling,

View file

@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { rechunkAnalysis, createAnalysis } from '../analysis/api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http'
describe('chunking API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('createAnalysis sends chunkingOptions when provided', async () => {
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
apiFetch.mockResolvedValue(job)
const chunkingOpts = { chunker_type: 'hybrid' as const, max_tokens: 256 }
await createAnalysis('doc-1', null, chunkingOpts)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1', chunkingOptions: chunkingOpts }),
})
})
it('createAnalysis omits chunkingOptions when null', async () => {
apiFetch.mockResolvedValue({ id: '1' })
await createAnalysis('doc-1', null, null)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1' }),
})
})
it('rechunkAnalysis sends POST to rechunk endpoint', async () => {
const chunks = [{ text: 'chunk1', headings: [], sourcePage: 1, tokenCount: 10 }]
apiFetch.mockResolvedValue(chunks)
const opts = { chunker_type: 'hybrid' as const, max_tokens: 512 }
const result = await rechunkAnalysis('job-1', opts)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/job-1/rechunk', {
method: 'POST',
body: JSON.stringify({ chunkingOptions: opts }),
})
expect(result).toEqual(chunks)
})
})

View file

@ -0,0 +1 @@
export { default as ChunkPanel } from './ui/ChunkPanel.vue'

View file

@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAnalysisStore } from '../analysis/store'
vi.mock('../analysis/api', () => ({
createAnalysis: vi.fn(),
fetchAnalyses: vi.fn().mockResolvedValue([]),
fetchAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
rechunkAnalysis: vi.fn(),
}))
import * as api from '../analysis/api'
describe('analysis store — chunking', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('currentChunks parses chunksJson from current analysis', () => {
const store = useAnalysisStore()
const chunks = [
{ text: 'chunk1', headings: ['H1'], sourcePage: 1, tokenCount: 10 },
{ text: 'chunk2', headings: [], sourcePage: 2, tokenCount: 20 },
]
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: JSON.stringify(chunks),
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
expect(store.currentChunks).toEqual(chunks)
})
it('currentChunks returns empty array when no chunksJson', () => {
const store = useAnalysisStore()
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: false,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
expect(store.currentChunks).toEqual([])
})
it('rechunk calls API and refreshes analysis', async () => {
const store = useAnalysisStore()
const chunks = [{ text: 'c1', headings: [], sourcePage: 1, tokenCount: 5 }]
vi.mocked(api.rechunkAnalysis).mockResolvedValue(chunks)
vi.mocked(api.fetchAnalysis).mockResolvedValue({
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: JSON.stringify(chunks),
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
})
store.currentAnalysis = {
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'COMPLETED',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: true,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
}
const result = await store.rechunk('j1', { chunker_type: 'hybrid', max_tokens: 256 })
expect(api.rechunkAnalysis).toHaveBeenCalledWith('j1', {
chunker_type: 'hybrid',
max_tokens: 256,
})
expect(result).toEqual(chunks)
expect(store.rechunking).toBe(false)
})
it('run passes chunkingOptions to API', async () => {
const store = useAnalysisStore()
vi.mocked(api.createAnalysis).mockResolvedValue({
id: 'j1',
documentId: 'd1',
documentFilename: null,
status: 'PENDING',
contentMarkdown: null,
contentHtml: null,
pagesJson: null,
chunksJson: null,
hasDocumentJson: false,
errorMessage: null,
startedAt: null,
completedAt: null,
createdAt: '2024-01-01',
})
await store.run('d1', null, { chunker_type: 'hierarchical' })
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null, {
chunker_type: 'hierarchical',
})
})
})

View file

@ -0,0 +1,336 @@
<template>
<div class="chunk-panel">
<!-- Chunking config -->
<div class="chunk-config">
<div class="config-section">
<label class="config-label">{{ t('chunking.settings') }}</label>
<div class="config-row">
<label class="config-label-sm">{{ t('chunking.chunkerType') }}</label>
<select class="config-select" v-model="options.chunker_type">
<option value="hybrid">Hybrid</option>
<option value="hierarchical">Hierarchical</option>
</select>
</div>
<div class="config-row">
<label class="config-label-sm">{{ t('chunking.maxTokens') }}</label>
<input
type="number"
class="config-input"
v-model.number="options.max_tokens"
min="64"
max="8192"
step="64"
/>
</div>
<div class="config-toggle-row" v-if="options.chunker_type === 'hybrid'">
<label class="toggle-label">
<input type="checkbox" v-model="options.merge_peers" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('chunking.mergePeers') }}</span>
</label>
</div>
<div class="config-toggle-row" v-if="options.chunker_type === 'hybrid'">
<label class="toggle-label">
<input type="checkbox" v-model="options.repeat_table_header" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('chunking.repeatTableHeader') }}</span>
</label>
</div>
</div>
<button
class="chunk-btn primary"
:disabled="!canRechunk || analysisStore.rechunking"
@click="doRechunk"
>
<div v-if="analysisStore.rechunking" class="spinner-sm" />
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
</button>
</div>
<!-- Chunks list -->
<div class="chunk-results" v-if="analysisStore.currentChunks.length">
<div class="chunk-summary">
{{ analysisStore.currentChunks.length }} {{ t('chunking.chunks') }}
</div>
<div class="chunk-list">
<div
class="chunk-card"
v-for="(chunk, idx) in analysisStore.currentChunks"
:key="idx"
>
<div class="chunk-header">
<span class="chunk-index">#{{ idx + 1 }}</span>
<span class="chunk-tokens" v-if="chunk.tokenCount">
{{ chunk.tokenCount }} tokens
</span>
<span class="chunk-page" v-if="chunk.sourcePage">
p.{{ chunk.sourcePage }}
</span>
</div>
<div class="chunk-headings" v-if="chunk.headings.length">
<span class="chunk-heading" v-for="h in chunk.headings" :key="h">{{ h }}</span>
</div>
<div class="chunk-text">{{ chunk.text }}</div>
</div>
</div>
</div>
<div class="chunk-empty" v-else-if="!analysisStore.rechunking">
<p>{{ t('chunking.noChunks') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed } from 'vue'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import type { ChunkingOptions } from '../../../shared/types'
const analysisStore = useAnalysisStore()
const { t } = useI18n()
const options = reactive<Required<ChunkingOptions>>({
chunker_type: 'hybrid',
max_tokens: 512,
merge_peers: true,
repeat_table_header: true,
})
const canRechunk = computed(() => {
const analysis = analysisStore.currentAnalysis
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
})
async function doRechunk() {
if (!analysisStore.currentAnalysis) return
await analysisStore.rechunk(analysisStore.currentAnalysis.id, { ...options })
}
</script>
<style scoped>
.chunk-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chunk-config {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 12px;
}
.config-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.config-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.config-label-sm {
font-size: 12px;
color: var(--text-secondary);
}
.config-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.config-select,
.config-input {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
color: var(--text);
width: 100%;
}
.config-toggle-row {
display: flex;
align-items: center;
gap: 8px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.toggle-input {
display: none;
}
.toggle-switch {
width: 32px;
height: 18px;
background: var(--bg-tertiary);
border-radius: 9px;
position: relative;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-input:checked + .toggle-switch {
background: var(--accent);
}
.toggle-input:checked + .toggle-switch::after {
transform: translateX(14px);
}
.chunk-btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.chunk-btn.primary {
background: var(--accent);
color: white;
}
.chunk-btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chunk-results {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.chunk-summary {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.chunk-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.chunk-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
}
.chunk-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.chunk-index {
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.chunk-tokens,
.chunk-page {
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 4px;
}
.chunk-headings {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.chunk-heading {
font-size: 11px;
color: var(--accent);
background: var(--accent-bg, rgba(99, 102, 241, 0.1));
padding: 1px 6px;
border-radius: 4px;
}
.chunk-text {
font-size: 12px;
color: var(--text);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
.chunk-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-secondary);
font-size: 13px;
}
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View file

@ -37,6 +37,15 @@
> >
{{ t('studio.verify') }} {{ t('studio.verify') }}
</button> </button>
<button
v-if="chunkingEnabled"
class="toggle-btn"
:class="{ active: mode === 'preparer' }"
@click="mode = 'preparer'"
:disabled="!analysisStore.currentAnalysis"
>
{{ t('studio.prepare') }}
</button>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
@ -280,6 +289,11 @@
@highlight-element="highlightedElementIndex = $event" @highlight-element="highlightedElementIndex = $event"
/> />
</div> </div>
<!-- PREPARER MODE (feature-flipped) -->
<div v-if="mode === 'preparer' && chunkingEnabled" class="prepare-panel">
<ChunkPanel />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -293,6 +307,8 @@ import { useAnalysisStore } from '../features/analysis/store'
import { DocumentUpload, DocumentList } from '../features/document/index' import { DocumentUpload, DocumentList } from '../features/document/index'
import { ResultTabs } from '../features/analysis/index' import { ResultTabs } from '../features/analysis/index'
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue' import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
import { ChunkPanel } from '../features/chunking'
import { useFeatureFlag } from '../features/feature-flags'
import { getPreviewUrl } from '../features/document/api' import { getPreviewUrl } from '../features/document/api'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
import type { PipelineOptions } from '../shared/types' import type { PipelineOptions } from '../shared/types'
@ -302,6 +318,7 @@ const router = useRouter()
const documentStore = useDocumentStore() const documentStore = useDocumentStore()
const analysisStore = useAnalysisStore() const analysisStore = useAnalysisStore()
const { t } = useI18n() const { t } = useI18n()
const chunkingEnabled = useFeatureFlag('chunking')
const mode = ref('configurer') const mode = ref('configurer')
const currentPage = ref(1) const currentPage = ref(1)

View file

@ -95,6 +95,18 @@ const messages: Messages = {
'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.', 'history.emptyDocs': 'Aucun document. Importez un document depuis le Studio.',
'history.open': 'Ouvrir', 'history.open': 'Ouvrir',
// Chunking
'studio.prepare': 'Préparer',
'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Type de chunker',
'chunking.maxTokens': 'Tokens max',
'chunking.mergePeers': 'Fusionner les pairs',
'chunking.repeatTableHeader': 'Répéter en-têtes tableaux',
'chunking.run': 'Chunker',
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
// Settings // Settings
'settings.title': 'Paramètres', 'settings.title': 'Paramètres',
'settings.apiUrl': 'API URL', 'settings.apiUrl': 'API URL',
@ -185,6 +197,17 @@ const messages: Messages = {
'history.emptyDocs': 'No documents yet. Upload a document from the Studio.', 'history.emptyDocs': 'No documents yet. Upload a document from the Studio.',
'history.open': 'Open', 'history.open': 'Open',
'studio.prepare': 'Prepare',
'chunking.settings': 'Chunking',
'chunking.chunkerType': 'Chunker type',
'chunking.maxTokens': 'Max tokens',
'chunking.mergePeers': 'Merge peers',
'chunking.repeatTableHeader': 'Repeat table headers',
'chunking.run': 'Chunk',
'chunking.chunking': 'Chunking...',
'chunking.chunks': 'chunks',
'chunking.noChunks': 'Run chunking to prepare segments.',
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.apiUrl': 'API URL', 'settings.apiUrl': 'API URL',
'settings.version': 'Version', 'settings.version': 'Version',

View file

@ -30,12 +30,28 @@ export interface Analysis {
contentMarkdown: string | null contentMarkdown: string | null
contentHtml: string | null contentHtml: string | null
pagesJson: string | null pagesJson: string | null
chunksJson: string | null
hasDocumentJson: boolean
errorMessage: string | null errorMessage: string | null
startedAt: string | null startedAt: string | null
completedAt: string | null completedAt: string | null
createdAt: string createdAt: string
} }
export interface ChunkingOptions {
chunker_type?: 'hybrid' | 'hierarchical'
max_tokens?: number
merge_peers?: boolean
repeat_table_header?: boolean
}
export interface Chunk {
text: string
headings: string[]
sourcePage: number | null
tokenCount: number
}
export interface PageElement { export interface PageElement {
type: string type: string
bbox: [number, number, number, number] bbox: [number, number, number, number]