Merge pull request #11 from scub-france/connect-configuration

Connect options with model configuration
This commit is contained in:
Pier-Jean Malandrino 2026-03-20 09:23:50 +01:00 committed by GitHub
commit 55f3f22c4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1246 additions and 134 deletions

View file

@ -35,8 +35,12 @@ async def create_analysis(body: CreateAnalysisRequest):
if not body.documentId or not body.documentId.strip(): if not body.documentId or not body.documentId.strip():
raise HTTPException(status_code=400, detail="documentId is required") raise HTTPException(status_code=400, detail="documentId is required")
pipeline_opts = None
if body.pipelineOptions:
pipeline_opts = body.pipelineOptions.model_dump()
try: try:
job = await analysis_service.create(body.documentId) job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))

View file

@ -48,5 +48,20 @@ class AnalysisResponse(_CamelModel):
created_at: str | datetime created_at: str | datetime
class PipelineOptionsRequest(BaseModel):
"""Docling pipeline configuration options."""
do_ocr: bool = True
do_table_structure: bool = True
table_mode: str = "accurate" # "accurate" or "fast"
do_code_enrichment: bool = False
do_formula_enrichment: bool = False
do_picture_classification: bool = False
do_picture_description: bool = False
generate_picture_images: bool = False
generate_page_images: bool = False
images_scale: float = 1.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

View file

@ -106,11 +106,15 @@ def build_converter(
do_ocr: bool = True, do_ocr: bool = True,
do_table_structure: bool = True, do_table_structure: bool = True,
table_mode: str = "accurate", table_mode: str = "accurate",
do_code_enrichment: bool = False,
do_formula_enrichment: bool = False,
do_picture_classification: bool = False,
do_picture_description: bool = False,
generate_picture_images: bool = False,
generate_page_images: bool = False,
images_scale: float = 1.0,
) -> DocumentConverter: ) -> DocumentConverter:
"""Build a DocumentConverter with the given pipeline options. """Build a DocumentConverter with the given pipeline options."""
Only exposes options that work out of the box (no extra model downloads).
"""
table_options = TableStructureOptions( table_options = TableStructureOptions(
do_cell_matching=True, do_cell_matching=True,
mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST, mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST,
@ -120,12 +124,13 @@ def build_converter(
do_ocr=do_ocr, do_ocr=do_ocr,
do_table_structure=do_table_structure, do_table_structure=do_table_structure,
table_structure_options=table_options, table_structure_options=table_options,
do_code_enrichment=False, do_code_enrichment=do_code_enrichment,
do_formula_enrichment=False, do_formula_enrichment=do_formula_enrichment,
do_picture_classification=False, do_picture_classification=do_picture_classification,
do_picture_description=False, do_picture_description=do_picture_description,
generate_page_images=False, generate_page_images=generate_page_images,
generate_picture_images=False, generate_picture_images=generate_picture_images,
images_scale=images_scale,
) )
return DocumentConverter( return DocumentConverter(
@ -228,19 +233,42 @@ def convert_document(
do_ocr: bool = True, do_ocr: bool = True,
do_table_structure: bool = True, do_table_structure: bool = True,
table_mode: str = "accurate", table_mode: str = "accurate",
do_code_enrichment: bool = False,
do_formula_enrichment: bool = False,
do_picture_classification: bool = False,
do_picture_description: bool = False,
generate_picture_images: bool = False,
generate_page_images: bool = False,
images_scale: float = 1.0,
) -> ConversionResult: ) -> ConversionResult:
"""Convert a document and return structured results. """Convert a document and return structured results.
This is the main entry point for document parsing. Runs synchronously This is the main entry point for document parsing. Runs synchronously
(caller should use asyncio.to_thread for non-blocking execution). (caller should use asyncio.to_thread for non-blocking execution).
""" """
if do_ocr and do_table_structure and table_mode == "accurate": # Use cached default converter only when all options match defaults
is_default = (
do_ocr and do_table_structure and table_mode == "accurate"
and not do_code_enrichment and not do_formula_enrichment
and not do_picture_classification and not do_picture_description
and not generate_picture_images and not generate_page_images
and images_scale == 1.0
)
if is_default:
conv = get_default_converter() conv = get_default_converter()
else: else:
conv = build_converter( conv = build_converter(
do_ocr=do_ocr, do_ocr=do_ocr,
do_table_structure=do_table_structure, do_table_structure=do_table_structure,
table_mode=table_mode, table_mode=table_mode,
do_code_enrichment=do_code_enrichment,
do_formula_enrichment=do_formula_enrichment,
do_picture_classification=do_picture_classification,
do_picture_description=do_picture_description,
generate_picture_images=generate_picture_images,
generate_page_images=generate_page_images,
images_scale=images_scale,
) )
with _converter_lock: with _converter_lock:

View file

@ -14,7 +14,7 @@ from persistence import analysis_repo, document_repo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def create(document_id: str) -> AnalysisJob: async def create(document_id: str, *, pipeline_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:
@ -25,7 +25,7 @@ async def create(document_id: str) -> AnalysisJob:
await analysis_repo.insert(job) await analysis_repo.insert(job)
# Fire-and-forget background task # Fire-and-forget background task
asyncio.create_task(_run_analysis(job.id, doc.storage_path, doc.filename)) asyncio.create_task(_run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options))
return job return job
@ -42,7 +42,9 @@ async def delete(job_id: str) -> bool:
return await analysis_repo.delete(job_id) return await analysis_repo.delete(job_id)
async def _run_analysis(job_id: str, file_path: str, filename: str) -> None: async def _run_analysis(
job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
) -> None:
"""Background task: run Docling conversion and update job status.""" """Background task: run Docling conversion and update job status."""
job = await analysis_repo.find_by_id(job_id) job = await analysis_repo.find_by_id(job_id)
if not job: if not job:
@ -54,8 +56,13 @@ async def _run_analysis(job_id: str, file_path: str, filename: str) -> None:
logger.info("Analysis started: %s (file: %s)", job_id, filename) logger.info("Analysis started: %s (file: %s)", job_id, filename)
try: try:
# Build kwargs from pipeline options
convert_kwargs = pipeline_options or {}
# Run blocking Docling conversion in a thread # Run blocking Docling conversion in a thread
result: ConversionResult = await asyncio.to_thread(convert_document, file_path) result: ConversionResult = await asyncio.to_thread(
convert_document, file_path, **convert_kwargs,
)
pages_json = json.dumps([asdict(p) for p in result.pages]) pages_json = json.dumps([asdict(p) for p in result.pages])

View file

@ -147,6 +147,60 @@ class TestAnalysisEndpoints:
data = resp.json() data = resp.json()
assert data["id"] == "j1" assert data["id"] == "j1"
assert data["documentId"] == "d1" assert data["documentId"] == "d1"
mock_create.assert_called_once_with("d1", pipeline_options=None)
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis_with_pipeline_options(self, mock_create, client):
mock_create.return_value = AnalysisJob(
id="j2", document_id="d1", document_filename="test.pdf",
)
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {
"do_ocr": False,
"do_table_structure": True,
"table_mode": "fast",
"do_code_enrichment": True,
"do_formula_enrichment": False,
"do_picture_classification": False,
"do_picture_description": False,
"generate_picture_images": True,
"generate_page_images": False,
"images_scale": 2.0,
}
})
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "j2"
call_kwargs = mock_create.call_args
opts = call_kwargs.kwargs["pipeline_options"]
assert opts["do_ocr"] is False
assert opts["table_mode"] == "fast"
assert opts["do_code_enrichment"] is True
assert opts["generate_picture_images"] is True
assert opts["images_scale"] == 2.0
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis_with_partial_pipeline_options(self, mock_create, client):
"""Pipeline options should use defaults for unspecified fields."""
mock_create.return_value = AnalysisJob(
id="j3", document_id="d1", document_filename="test.pdf",
)
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": False}
})
assert resp.status_code == 200
opts = mock_create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False
# Defaults
assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False
@patch("services.analysis_service.create", new_callable=AsyncMock) @patch("services.analysis_service.create", new_callable=AsyncMock)
def test_create_analysis_document_not_found(self, mock_create, client): def test_create_analysis_document_not_found(self, mock_create, client):

View file

@ -0,0 +1,564 @@
"""Tests for pipeline options — build_converter, convert_document routing, service forwarding."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import (
PdfPipelineOptions,
TableFormerMode,
)
from domain.parsing import build_converter, convert_document, get_default_converter
# ---------------------------------------------------------------------------
# build_converter — verifies Docling pipeline options are wired correctly
# ---------------------------------------------------------------------------
class TestBuildConverter:
"""Verify that build_converter produces a DocumentConverter with the right PdfPipelineOptions."""
def _get_pipeline_options(self, converter) -> PdfPipelineOptions:
"""Extract PdfPipelineOptions from a DocumentConverter."""
fmt_opt = converter.format_to_options[InputFormat.PDF]
return fmt_opt.pipeline_options
def test_defaults(self):
conv = build_converter()
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is True
assert opts.do_table_structure is True
assert opts.table_structure_options.mode == TableFormerMode.ACCURATE
assert opts.do_code_enrichment is False
assert opts.do_formula_enrichment is False
assert opts.do_picture_classification is False
assert opts.do_picture_description is False
assert opts.generate_page_images is False
assert opts.generate_picture_images is False
assert opts.images_scale == 1.0
def test_ocr_disabled(self):
conv = build_converter(do_ocr=False)
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False
def test_table_mode_fast(self):
conv = build_converter(table_mode="fast")
opts = self._get_pipeline_options(conv)
assert opts.table_structure_options.mode == TableFormerMode.FAST
def test_table_mode_accurate(self):
conv = build_converter(table_mode="accurate")
opts = self._get_pipeline_options(conv)
assert opts.table_structure_options.mode == TableFormerMode.ACCURATE
def test_table_structure_disabled(self):
conv = build_converter(do_table_structure=False)
opts = self._get_pipeline_options(conv)
assert opts.do_table_structure is False
def test_code_enrichment_enabled(self):
conv = build_converter(do_code_enrichment=True)
opts = self._get_pipeline_options(conv)
assert opts.do_code_enrichment is True
def test_formula_enrichment_enabled(self):
conv = build_converter(do_formula_enrichment=True)
opts = self._get_pipeline_options(conv)
assert opts.do_formula_enrichment is True
def test_picture_classification_enabled(self):
conv = build_converter(do_picture_classification=True)
opts = self._get_pipeline_options(conv)
assert opts.do_picture_classification is True
def test_picture_description_enabled(self):
conv = build_converter(do_picture_description=True)
opts = self._get_pipeline_options(conv)
assert opts.do_picture_description is True
def test_generate_picture_images(self):
conv = build_converter(generate_picture_images=True)
opts = self._get_pipeline_options(conv)
assert opts.generate_picture_images is True
def test_generate_page_images(self):
conv = build_converter(generate_page_images=True)
opts = self._get_pipeline_options(conv)
assert opts.generate_page_images is True
def test_images_scale(self):
conv = build_converter(images_scale=2.0)
opts = self._get_pipeline_options(conv)
assert opts.images_scale == 2.0
def test_all_options_combined(self):
conv = build_converter(
do_ocr=False,
do_table_structure=True,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False
assert opts.do_table_structure is True
assert opts.table_structure_options.mode == TableFormerMode.FAST
assert opts.do_code_enrichment is True
assert opts.do_formula_enrichment is True
assert opts.do_picture_classification is True
assert opts.do_picture_description is True
assert opts.generate_picture_images is True
assert opts.generate_page_images is True
assert opts.images_scale == 1.5
# ---------------------------------------------------------------------------
# convert_document — default vs custom converter routing
# ---------------------------------------------------------------------------
class TestConvertDocumentRouting:
"""Verify convert_document uses default converter for default opts, custom otherwise."""
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_get_default.return_value = mock_conv
convert_document("/tmp/test.pdf")
mock_get_default.assert_called_once()
mock_build.assert_not_called()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_ocr=False)
mock_build.assert_called_once()
mock_get_default.assert_not_called()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", table_mode="fast")
mock_build.assert_called_once()
assert mock_build.call_args.kwargs["table_mode"] == "fast"
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_code_enrichment=True)
mock_build.assert_called_once()
assert mock_build.call_args.kwargs["do_code_enrichment"] is True
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_formula_enrichment=True)
mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", do_picture_classification=True)
mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", generate_picture_images=True)
mock_build.assert_called_once()
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", images_scale=2.0)
mock_build.assert_called_once()
assert mock_build.call_args.kwargs["images_scale"] == 2.0
@patch("domain.parsing.get_default_converter")
@patch("domain.parsing.build_converter")
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
mock_conv = MagicMock()
mock_result = MagicMock()
mock_result.document.pages = {}
mock_result.document.iterate_items.return_value = []
mock_result.document.export_to_markdown.return_value = ""
mock_result.document.export_to_html.return_value = ""
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document(
"/tmp/test.pdf",
do_ocr=False,
do_table_structure=False,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
mock_build.assert_called_once_with(
do_ocr=False,
do_table_structure=False,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=True,
do_picture_classification=True,
do_picture_description=True,
generate_picture_images=True,
generate_page_images=True,
images_scale=1.5,
)
# ---------------------------------------------------------------------------
# Service layer — pipeline options forwarding
# ---------------------------------------------------------------------------
class TestServiceForwardsPipelineOptions:
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
@pytest.fixture
def mock_doc(self):
from domain.models import Document
return Document(id="d1", filename="test.pdf", storage_path="/tmp/test.pdf")
@pytest.fixture
def mock_job(self):
from domain.models import AnalysisJob
return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service._run_analysis")
@pytest.mark.asyncio
async def test_create_passes_pipeline_options_to_run(
self, mock_run, mock_analysis_repo, mock_doc_repo, mock_doc,
):
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock()
# Patch _run_analysis as a coroutine that we can inspect
mock_run.return_value = None
from services import analysis_service
opts = {"do_ocr": False, "table_mode": "fast"}
# We need to patch asyncio.create_task to capture the coroutine args
with patch("services.analysis_service.asyncio.create_task") as mock_task:
await analysis_service.create("d1", pipeline_options=opts)
# create_task should have been called with _run_analysis(...)
mock_task.assert_called_once()
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.analysis_repo")
@pytest.mark.asyncio
async def test_create_passes_none_when_no_options(
self, mock_analysis_repo, mock_doc_repo, mock_doc,
):
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
mock_analysis_repo.insert = AsyncMock()
from services import analysis_service
with patch("services.analysis_service.asyncio.create_task") as mock_task:
await analysis_service.create("d1")
mock_task.assert_called_once()
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio
async def test_run_analysis_forwards_options_to_convert(
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
):
from domain.parsing import ConversionResult, PageDetail
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock()
mock_doc_repo.update_page_count = AsyncMock()
mock_convert.return_value = ConversionResult(
page_count=1,
content_markdown="# Test",
content_html="<h1>Test</h1>",
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
)
from services.analysis_service import _run_analysis
opts = {
"do_ocr": False,
"table_mode": "fast",
"do_code_enrichment": True,
"do_formula_enrichment": False,
"do_picture_classification": False,
"do_picture_description": False,
"generate_picture_images": True,
"generate_page_images": False,
"images_scale": 2.0,
}
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
mock_convert.assert_called_once_with(
"/tmp/test.pdf",
do_ocr=False,
table_mode="fast",
do_code_enrichment=True,
do_formula_enrichment=False,
do_picture_classification=False,
do_picture_description=False,
generate_picture_images=True,
generate_page_images=False,
images_scale=2.0,
)
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio
async def test_run_analysis_uses_defaults_when_no_options(
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
):
from domain.parsing import ConversionResult, PageDetail
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock()
mock_doc_repo.update_page_count = AsyncMock()
mock_convert.return_value = ConversionResult(
page_count=1,
content_markdown="",
content_html="",
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
)
from services.analysis_service import _run_analysis
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
# Called with file_path only (no kwargs spread from empty dict)
mock_convert.assert_called_once_with("/tmp/test.pdf")
@patch("services.analysis_service.analysis_repo")
@patch("services.analysis_service.document_repo")
@patch("services.analysis_service.convert_document")
@pytest.mark.asyncio
async def test_run_analysis_marks_failed_on_error(
self, mock_convert, mock_doc_repo, mock_analysis_repo, mock_job,
):
mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
mock_analysis_repo.update_status = AsyncMock()
mock_convert.side_effect = RuntimeError("Docling crashed")
from services.analysis_service import _run_analysis
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
# Should have called update_status twice: RUNNING then FAILED
assert mock_analysis_repo.update_status.call_count == 2
last_job = mock_analysis_repo.update_status.call_args_list[-1][0][0]
assert last_job.status.value == "FAILED"
assert "Docling crashed" in last_job.error_message
# ---------------------------------------------------------------------------
# API endpoint — full request/response with pipeline options
# ---------------------------------------------------------------------------
class TestAnalysisEndpointPipelineOptions:
"""Integration-level tests for the analysis creation endpoint with pipeline options."""
@pytest.fixture
def client(self):
from fastapi.testclient import TestClient
from main import app
return TestClient(app, raise_server_exceptions=False)
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_no_pipeline_options_sends_none(self, mock_create, client):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={"documentId": "d1"})
mock_create.assert_called_once_with("d1", pipeline_options=None)
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_empty_pipeline_options_object_uses_defaults(self, mock_create, client):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {},
})
opts = mock_create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is True
assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False
assert opts["do_formula_enrichment"] is False
assert opts["images_scale"] == 1.0
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_partial_pipeline_options_merges_with_defaults(self, mock_create, client):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
})
opts = mock_create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False
assert opts["images_scale"] == 1.5
# All other fields should have defaults
assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False
assert opts["do_formula_enrichment"] is False
assert opts["do_picture_classification"] is False
assert opts["do_picture_description"] is False
assert opts["generate_picture_images"] is False
assert opts["generate_page_images"] is False
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_full_pipeline_options(self, mock_create, client):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
payload = {
"documentId": "d1",
"pipelineOptions": {
"do_ocr": False,
"do_table_structure": False,
"table_mode": "fast",
"do_code_enrichment": True,
"do_formula_enrichment": True,
"do_picture_classification": True,
"do_picture_description": True,
"generate_picture_images": True,
"generate_page_images": True,
"images_scale": 2.0,
},
}
resp = client.post("/api/analyses", json=payload)
assert resp.status_code == 200
opts = mock_create.call_args.kwargs["pipeline_options"]
assert opts == payload["pipelineOptions"]
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_invalid_pipeline_option_type_rejected(self, mock_create, client):
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"},
})
assert resp.status_code == 422
@patch("services.analysis_service.create", new_callable=AsyncMock)
def test_unknown_pipeline_option_ignored(self, mock_create, client):
from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1")
resp = client.post("/api/analyses", json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
})
# Pydantic ignores extra fields by default
assert resp.status_code == 200

View file

@ -6,6 +6,7 @@ from api.schemas import (
AnalysisResponse, AnalysisResponse,
CreateAnalysisRequest, CreateAnalysisRequest,
DocumentResponse, DocumentResponse,
PipelineOptionsRequest,
_to_camel, _to_camel,
) )
@ -79,7 +80,47 @@ class TestAnalysisResponse:
assert resp.document_id == "d1" assert resp.document_id == "d1"
class TestPipelineOptionsRequest:
def test_defaults(self):
opts = PipelineOptionsRequest()
assert opts.do_ocr is True
assert opts.do_table_structure is True
assert opts.table_mode == "accurate"
assert opts.do_code_enrichment is False
assert opts.do_formula_enrichment is False
assert opts.do_picture_classification is False
assert opts.do_picture_description is False
assert opts.generate_picture_images is False
assert opts.generate_page_images is False
assert opts.images_scale == 1.0
def test_custom_values(self):
opts = PipelineOptionsRequest(
do_ocr=False, table_mode="fast", do_code_enrichment=True, images_scale=2.0,
)
assert opts.do_ocr is False
assert opts.table_mode == "fast"
assert opts.do_code_enrichment is True
assert opts.images_scale == 2.0
def test_model_dump(self):
opts = PipelineOptionsRequest(do_ocr=False)
data = opts.model_dump()
assert data["do_ocr"] is False
assert data["do_table_structure"] is True # default preserved
class TestCreateAnalysisRequest: class TestCreateAnalysisRequest:
def test_parses_document_id(self): def test_parses_document_id(self):
req = CreateAnalysisRequest(documentId="doc-42") req = CreateAnalysisRequest(documentId="doc-42")
assert req.documentId == "doc-42" assert req.documentId == "doc-42"
assert req.pipelineOptions is None
def test_parses_with_pipeline_options(self):
req = CreateAnalysisRequest(
documentId="doc-42",
pipelineOptions=PipelineOptionsRequest(do_ocr=False, table_mode="fast"),
)
assert req.documentId == "doc-42"
assert req.pipelineOptions.do_ocr is False
assert req.pipelineOptions.table_mode == "fast"

View file

@ -1,9 +1,13 @@
import { apiFetch } from '../../shared/api/http.js' import { apiFetch } from '../../shared/api/http.js'
export function createAnalysis(documentId) { export function createAnalysis(documentId, pipelineOptions = null) {
const body = { documentId }
if (pipelineOptions) {
body.pipelineOptions = pipelineOptions
}
return apiFetch('/api/analyses', { return apiFetch('/api/analyses', {
method: 'POST', method: 'POST',
body: JSON.stringify({ documentId }) body: JSON.stringify(body)
}) })
} }

View file

@ -12,7 +12,7 @@ describe('analysis API', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('createAnalysis sends POST with documentId', async () => { it('createAnalysis sends POST with documentId only', async () => {
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' } const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
apiFetch.mockResolvedValue(job) apiFetch.mockResolvedValue(job)
@ -25,6 +25,20 @@ describe('analysis API', () => {
expect(result).toEqual(job) expect(result).toEqual(job)
}) })
it('createAnalysis sends POST with pipeline options', async () => {
const job = { id: '2', documentId: 'doc-1', status: 'PENDING' }
apiFetch.mockResolvedValue(job)
const options = { do_ocr: false, table_mode: 'fast', do_code_enrichment: true }
const result = await createAnalysis('doc-1', options)
expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1', pipelineOptions: options }),
})
expect(result).toEqual(job)
})
it('fetchAnalyses calls GET /api/analyses', async () => { it('fetchAnalyses calls GET /api/analyses', async () => {
const jobs = [{ id: '1', status: 'COMPLETED' }] const jobs = [{ id: '1', status: 'COMPLETED' }]
apiFetch.mockResolvedValue(jobs) apiFetch.mockResolvedValue(jobs)

View file

@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { createAnalysis } from './api.js'
import { useAnalysisStore } from './store.js'
vi.mock('../../shared/api/http.js', () => ({
apiFetch: vi.fn(),
}))
vi.mock('./api.js', () => ({
fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(),
createAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http.js'
import * as api from './api.js'
// ---------------------------------------------------------------------------
// API layer — body construction with pipeline options
// ---------------------------------------------------------------------------
describe('createAnalysis — pipeline options body construction', () => {
// For these tests we need the REAL createAnalysis, not the mock.
// So we re-import the actual module.
let realCreateAnalysis
beforeEach(() => {
vi.clearAllMocks()
// Reset the real module to get the unmocked version
vi.doUnmock('./api.js')
})
// We test the mock integration (store → api) separately below.
// Here we verify the raw apiFetch call shape.
it('sends body without pipelineOptions when null', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const { createAnalysis: real } = await import('./api.js')
await real('doc-1', null)
expect(realApiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1' }),
})
})
it('sends body without pipelineOptions when undefined', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const { createAnalysis: real } = await import('./api.js')
await real('doc-1')
expect(realApiFetch).toHaveBeenCalledWith('/api/analyses', {
method: 'POST',
body: JSON.stringify({ documentId: 'doc-1' }),
})
})
it('includes pipelineOptions in body when provided', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const opts = {
do_ocr: false,
do_table_structure: true,
table_mode: 'fast',
do_code_enrichment: true,
do_formula_enrichment: false,
do_picture_classification: false,
do_picture_description: false,
generate_picture_images: false,
generate_page_images: false,
images_scale: 1.0,
}
const { createAnalysis: real } = await import('./api.js')
await real('doc-1', opts)
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
expect(sentBody.documentId).toBe('doc-1')
expect(sentBody.pipelineOptions).toEqual(opts)
})
it('includes only specified fields in partial options', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
const opts = { do_ocr: false }
const { createAnalysis: real } = await import('./api.js')
await real('doc-1', opts)
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
expect(sentBody.pipelineOptions).toEqual({ do_ocr: false })
})
it('does not include pipelineOptions key when options is empty object treated as falsy', async () => {
const { apiFetch: realApiFetch } = await import('../../shared/api/http.js')
realApiFetch.mockResolvedValue({ id: '1', status: 'PENDING' })
// Empty object is truthy in JS, so it SHOULD be included
const { createAnalysis: real } = await import('./api.js')
await real('doc-1', {})
const sentBody = JSON.parse(realApiFetch.mock.calls[0][1].body)
expect(sentBody.pipelineOptions).toEqual({})
})
})
// ---------------------------------------------------------------------------
// Store → API integration — pipeline options forwarding
// ---------------------------------------------------------------------------
describe('useAnalysisStore — pipeline options forwarding', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('run() passes null when no options provided', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockResolvedValue({ ...job, status: 'COMPLETED' })
const store = useAnalysisStore()
await store.run('d1')
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
store.stopPolling()
})
it('run() forwards full pipeline options object', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockResolvedValue({ ...job, status: 'COMPLETED' })
const store = useAnalysisStore()
const opts = {
do_ocr: false,
do_table_structure: true,
table_mode: 'fast',
do_code_enrichment: true,
do_formula_enrichment: false,
do_picture_classification: false,
do_picture_description: true,
generate_picture_images: true,
generate_page_images: false,
images_scale: 2.0,
}
await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', opts)
store.stopPolling()
})
it('run() forwards partial pipeline options', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockResolvedValue({ ...job, status: 'COMPLETED' })
const store = useAnalysisStore()
const opts = { do_ocr: false }
await store.run('d1', opts)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', { do_ocr: false })
store.stopPolling()
})
it('run() sets running state even with pipeline options', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
const store = useAnalysisStore()
const opts = { do_ocr: false, table_mode: 'fast' }
await store.run('d1', opts)
expect(store.running).toBe(true)
expect(store.currentAnalysis).toEqual(job)
store.stopPolling()
})
it('run() with options still triggers polling', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockResolvedValue({ ...job, status: 'RUNNING' })
const store = useAnalysisStore()
await store.run('d1', { do_ocr: false })
// After 2s polling should fire
await vi.advanceTimersByTimeAsync(2000)
expect(api.fetchAnalysis).toHaveBeenCalledWith('j1')
// Still running
expect(store.running).toBe(true)
// Now complete
api.fetchAnalysis.mockResolvedValue({ ...job, status: 'COMPLETED' })
await vi.advanceTimersByTimeAsync(2000)
expect(store.running).toBe(false)
store.stopPolling()
})
it('run() with options handles API error gracefully', async () => {
api.createAnalysis.mockRejectedValue(new Error('Server error'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useAnalysisStore()
await expect(store.run('d1', { do_ocr: false })).rejects.toThrow('Server error')
expect(store.running).toBe(false)
expect(store.currentAnalysis).toBeNull()
})
})

View file

@ -25,10 +25,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
} }
} }
async function run(documentId) { async function run(documentId, pipelineOptions = null) {
running.value = true running.value = true
try { try {
const analysis = await api.createAnalysis(documentId) const analysis = await api.createAnalysis(documentId, pipelineOptions)
currentAnalysis.value = analysis currentAnalysis.value = analysis
analyses.value.unshift(analysis) analyses.value.unshift(analysis)
startPolling(analysis.id) startPolling(analysis.id)

View file

@ -70,6 +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)
// Advance timer to trigger polling // Advance timer to trigger polling
await vi.advanceTimersByTimeAsync(2000) await vi.advanceTimersByTimeAsync(2000)
@ -80,6 +81,20 @@ describe('useAnalysisStore', () => {
store.stopPolling() store.stopPolling()
}) })
it('run() forwards pipeline options to API', async () => {
const job = { id: 'j2', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockResolvedValue({ ...job, status: 'COMPLETED' })
const store = useAnalysisStore()
const options = { do_ocr: false, table_mode: 'fast' }
await store.run('d1', options)
expect(api.createAnalysis).toHaveBeenCalledWith('d1', options)
store.stopPolling()
})
it('run() resets running on error', async () => { it('run() resets running on error', async () => {
api.createAnalysis.mockRejectedValue(new Error('fail')) api.createAnalysis.mockRejectedValue(new Error('fail'))
vi.spyOn(console, 'error').mockImplementation(() => {}) vi.spyOn(console, 'error').mockImplementation(() => {})

View file

@ -139,7 +139,6 @@
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">
{{ t('config.model') }} {{ t('config.model') }}
<span class="config-hint">?</span>
</label> </label>
<div class="config-select-display"> <div class="config-select-display">
<span class="config-model-name">Docling</span> <span class="config-model-name">Docling</span>
@ -147,50 +146,109 @@
</div> </div>
</div> </div>
<!-- Pipeline options -->
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">{{ t('config.pipeline') }}</label>
{{ t('config.pages') }}
<span class="config-hint">?</span>
</label>
<input type="text" class="config-input" :placeholder="t('config.pagesPlaceholder')" v-model="pageRange" />
</div>
<div class="config-section"> <div class="config-toggle-row">
<label class="config-label"> <label class="toggle-label">
{{ t('config.extractTables') }} <input type="checkbox" v-model="pipelineOptions.do_ocr" class="toggle-input" />
<span class="config-hint">?</span> <span class="toggle-switch" />
</label> <span class="toggle-text">{{ t('config.ocr') }}</span>
<select class="config-select" v-model="tableMode"> </label>
<option value="markdown">{{ t('config.markdownIntegrated') }}</option> <span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span>
<option value="html">HTML</option> </div>
<option value="csv">CSV</option>
</select>
</div>
<div class="config-section"> <div class="config-toggle-row">
<label class="config-label">{{ t('config.extract') }}</label> <label class="toggle-label">
<div class="extract-options"> <input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" />
<button <span class="toggle-switch" />
v-for="opt in extractOptions" <span class="toggle-text">{{ t('config.tableStructure') }}</span>
:key="opt.id" </label>
class="extract-btn" <span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span>
:class="{ active: activeExtracts.has(opt.id) }" </div>
@click="toggleExtract(opt.id)"
> <div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
<svg v-if="opt.icon === 'image'" viewBox="0 0 20 20" fill="currentColor" class="extract-icon"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg> <label class="config-label-sm">{{ t('config.tableMode') }}</label>
<svg v-else-if="opt.icon === 'header'" viewBox="0 0 20 20" fill="currentColor" class="extract-icon"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg> <select class="config-select" v-model="pipelineOptions.table_mode">
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="extract-icon"><path d="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 2h10v7h-2l-1-2-3 4-2-3-2 3V5z"/></svg> <option value="accurate">{{ t('config.tableModeAccurate') }}</option>
{{ opt.label }} <option value="fast">{{ t('config.tableModeFast') }}</option>
</button> </select>
</div> </div>
</div> </div>
<!-- Enrichment options -->
<div class="config-section"> <div class="config-section">
<label class="config-label"> <label class="config-label">{{ t('config.enrichment') }}</label>
{{ t('config.annotateImages') }}
<span class="config-hint">?</span> <div class="config-toggle-row">
</label> <label class="toggle-label">
<button class="config-add-btn">{{ t('config.add') }}</button> <input type="checkbox" v-model="pipelineOptions.do_code_enrichment" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_formula_enrichment" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span>?</span>
</div>
</div>
<!-- Picture options -->
<div class="config-section">
<label class="config-label">{{ t('config.pictures') }}</label>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_classification" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_description" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_picture_images" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_page_images" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span>?</span>
</div>
<div class="config-sub-option" v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images">
<label class="config-label-sm">{{ t('config.imagesScale') }}</label>
<select class="config-select" v-model.number="pipelineOptions.images_scale">
<option :value="0.5">0.5x</option>
<option :value="1.0">1.0x</option>
<option :value="1.5">1.5x</option>
<option :value="2.0">2.0x</option>
</select>
</div>
</div> </div>
<!-- Documents list at bottom --> <!-- Documents list at bottom -->
@ -225,12 +283,23 @@ const { t } = useI18n()
const mode = ref('configurer') const mode = ref('configurer')
const currentPage = ref(1) const currentPage = ref(1)
const pageRange = ref('')
const tableMode = ref('markdown')
const visualMode = ref(false) const visualMode = ref(false)
const pdfImageRef = ref(null) const pdfImageRef = ref(null)
const bboxOverlayRef = ref(null) const bboxOverlayRef = ref(null)
const pipelineOptions = reactive({
do_ocr: true,
do_table_structure: true,
table_mode: 'accurate',
do_code_enrichment: false,
do_formula_enrichment: false,
do_picture_classification: false,
do_picture_description: false,
generate_picture_images: false,
generate_page_images: false,
images_scale: 1.0,
})
const hasAnalysisResults = computed(() => { const hasAnalysisResults = computed(() => {
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0 return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
}) })
@ -244,18 +313,6 @@ function onPdfImageLoad() {
nextTick(() => bboxOverlayRef.value?.draw()) nextTick(() => bboxOverlayRef.value?.draw())
} }
const extractOptions = computed(() => [
{ id: 'images', label: t('config.images'), icon: 'image' },
{ id: 'header', label: t('config.header'), icon: 'header' },
{ id: 'footer', label: t('config.footer'), icon: 'footer' }
])
const activeExtracts = reactive(new Set(['images']))
function toggleExtract(id) {
if (activeExtracts.has(id)) activeExtracts.delete(id)
else activeExtracts.add(id)
}
const selectedDoc = computed(() => { const selectedDoc = computed(() => {
return documentStore.documents.find(d => d.id === documentStore.selectedId) return documentStore.documents.find(d => d.id === documentStore.selectedId)
}) })
@ -274,7 +331,7 @@ function onPageInput(e) {
async function runAnalysis() { async function runAnalysis() {
if (!documentStore.selectedId) return if (!documentStore.selectedId) return
await analysisStore.run(documentStore.selectedId) await analysisStore.run(documentStore.selectedId, { ...pipelineOptions })
} }
function addMore() { function addMore() {
@ -734,6 +791,7 @@ onMounted(() => {
} }
.config-hint { .config-hint {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -744,6 +802,43 @@ onMounted(() => {
font-size: 10px; font-size: 10px;
color: var(--text-muted); color: var(--text-muted);
cursor: help; cursor: help;
flex-shrink: 0;
}
.config-hint:hover {
border-color: var(--accent);
color: var(--accent);
}
.config-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 8px);
right: -8px;
width: 240px;
padding: 8px 10px;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 11px;
line-height: 1.5;
color: var(--text-secondary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
pointer-events: none;
}
.config-tooltip::after {
content: '';
position: absolute;
top: 100%;
right: 12px;
border: 5px solid transparent;
border-top-color: var(--border-light);
}
.config-hint:hover .config-tooltip {
display: block;
} }
.config-select-display { .config-select-display {
@ -812,59 +907,78 @@ onMounted(() => {
color: var(--text); color: var(--text);
} }
.extract-options { /* Toggle rows */
display: flex; .config-toggle-row {
gap: 8px;
flex-wrap: wrap;
}
.extract-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; justify-content: space-between;
padding: 8px 14px; padding: 6px 0;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.toggle-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch {
position: relative;
width: 36px;
height: 20px;
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 10px;
color: var(--text-secondary); transition: all var(--transition);
font-size: 13px; flex-shrink: 0;
font-weight: 500; }
cursor: pointer;
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--text-muted);
border-radius: 50%;
transition: all var(--transition); transition: all var(--transition);
} }
.extract-btn:hover { .toggle-input:checked + .toggle-switch {
background: var(--bg-hover); background: var(--accent);
color: var(--text);
}
.extract-btn.active {
background: var(--accent-muted);
border-color: var(--accent); border-color: var(--accent);
color: var(--accent);
} }
.extract-icon { .toggle-input:checked + .toggle-switch::after {
width: 16px; left: 18px;
height: 16px; background: white;
} }
.config-add-btn { .toggle-text {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 14px;
color: var(--text-secondary);
font-size: 13px; font-size: 13px;
font-weight: 500; color: var(--text);
cursor: pointer;
transition: all var(--transition);
align-self: flex-start;
} }
.config-add-btn:hover { .config-sub-option {
background: var(--bg-hover); padding-left: 46px;
color: var(--text); display: flex;
flex-direction: column;
gap: 4px;
}
.config-label-sm {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
} }
.config-docs { .config-docs {

View file

@ -9,7 +9,7 @@ const messages = {
// Studio — import // Studio — import
'studio.title': 'Intelligence des documents', 'studio.title': 'Intelligence des documents',
'studio.subtitle': 'Importez un document PDF pour commencer l\'analyse avec Docling', 'studio.subtitle': "Importez un document PDF pour commencer l'analyse avec Docling",
'studio.recentDocs': 'Documents récents', 'studio.recentDocs': 'Documents récents',
// Studio — workspace // Studio — workspace
@ -25,16 +25,29 @@ const messages = {
// Config panel // Config panel
'config.model': 'Modèle', 'config.model': 'Modèle',
'config.pages': 'Pages', 'config.pipeline': 'Pipeline',
'config.pagesPlaceholder': 'par ex. "1-4,8"', 'config.ocr': 'OCR',
'config.extractTables': 'Extraire les tableaux', 'config.ocrHint': "Applique la reconnaissance optique de caractères sur les pages scannées ou les images intégrées. Indispensable pour les PDF non-natifs.",
'config.markdownIntegrated': 'Markdown intégré', 'config.tableStructure': 'Extraction des tableaux',
'config.extract': 'Extraire', 'config.tableStructureHint': "Détecte les tableaux dans le document et reconstruit leur structure lignes/colonnes via le modèle TableFormer, avec correspondance des cellules.",
'config.images': 'Images', 'config.tableMode': 'Mode tableaux',
'config.header': 'En-tête', 'config.tableModeAccurate': 'Précis',
'config.footer': 'Pied de page', 'config.tableModeFast': 'Rapide',
'config.annotateImages': 'Annoter les images', 'config.enrichment': 'Enrichissement',
'config.add': 'Ajouter +', 'config.codeEnrichment': 'Code',
'config.codeEnrichmentHint': "Active un modèle OCR spécialisé pour les blocs de code, préservant l'indentation et la syntaxe.",
'config.formulaEnrichment': 'Formules',
'config.formulaEnrichmentHint': "Reconnaît les formules mathématiques et les convertit en LaTeX via un modèle dédié.",
'config.pictures': 'Images',
'config.pictureClassification': 'Classification',
'config.pictureClassificationHint': "Classe chaque image détectée par type (graphique, photo, diagramme, logo…) via un modèle de classification.",
'config.pictureDescription': 'Description',
'config.pictureDescriptionHint': "Génère une description textuelle de chaque image via un Vision Language Model (VLM). Utile pour l'accessibilité et l'indexation.",
'config.generatePictureImages': 'Extraire les images',
'config.generatePictureImagesHint': "Extrait les images détectées du document et les sauvegarde en tant que fichiers séparés. Nécessaire pour l'export d'images.",
'config.generatePageImages': 'Images de pages',
'config.generatePageImagesHint': "Rasterise chaque page du PDF en image. Utile pour la visualisation ou le post-traitement visuel.",
'config.imagesScale': 'Échelle images',
'config.documents': 'Documents', 'config.documents': 'Documents',
// Results // Results
@ -45,7 +58,7 @@ const messages = {
'results.noImages': 'Aucune image détectée dans ce document', 'results.noImages': 'Aucune image détectée dans ce document',
'results.noMarkdown': 'Pas de contenu markdown', 'results.noMarkdown': 'Pas de contenu markdown',
'results.runAnalysis': 'Lancez une analyse pour voir les résultats', 'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
'results.analysisFailed': 'L\'analyse a échoué', 'results.analysisFailed': "L'analyse a échoué",
'results.page': 'Page', 'results.page': 'Page',
// Upload // Upload
@ -86,16 +99,29 @@ const messages = {
'studio.visual': 'Visual', 'studio.visual': 'Visual',
'config.model': 'Model', 'config.model': 'Model',
'config.pages': 'Pages', 'config.pipeline': 'Pipeline',
'config.pagesPlaceholder': 'e.g. "1-4,8"', 'config.ocr': 'OCR',
'config.extractTables': 'Extract tables', 'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
'config.markdownIntegrated': 'Inline Markdown', 'config.tableStructure': 'Table extraction',
'config.extract': 'Extract', 'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
'config.images': 'Images', 'config.tableMode': 'Table mode',
'config.header': 'Header', 'config.tableModeAccurate': 'Accurate',
'config.footer': 'Footer', 'config.tableModeFast': 'Fast',
'config.annotateImages': 'Annotate images', 'config.enrichment': 'Enrichment',
'config.add': 'Add +', 'config.codeEnrichment': 'Code',
'config.codeEnrichmentHint': 'Activates a specialized OCR model for code blocks, preserving indentation and syntax.',
'config.formulaEnrichment': 'Formulas',
'config.formulaEnrichmentHint': 'Recognizes mathematical formulas and converts them to LaTeX using a dedicated model.',
'config.pictures': 'Pictures',
'config.pictureClassification': 'Classification',
'config.pictureClassificationHint': 'Classifies each detected image by type (chart, photo, diagram, logo…) using a classification model.',
'config.pictureDescription': 'Description',
'config.pictureDescriptionHint': 'Generates a text description for each image using a Vision Language Model (VLM). Useful for accessibility and indexing.',
'config.generatePictureImages': 'Extract pictures',
'config.generatePictureImagesHint': 'Extracts detected images from the document and saves them as separate files. Required for image export.',
'config.generatePageImages': 'Page images',
'config.generatePageImagesHint': 'Rasterizes each PDF page as an image. Useful for visual preview or post-processing.',
'config.imagesScale': 'Images scale',
'config.documents': 'Documents', 'config.documents': 'Documents',
'results.textResult': 'Text result', 'results.textResult': 'Text result',