diff --git a/document-parser/api/analyses.py b/document-parser/api/analyses.py index 72c4712..b43309c 100644 --- a/document-parser/api/analyses.py +++ b/document-parser/api/analyses.py @@ -35,8 +35,12 @@ async def create_analysis(body: CreateAnalysisRequest): if not body.documentId or not body.documentId.strip(): raise HTTPException(status_code=400, detail="documentId is required") + pipeline_opts = None + if body.pipelineOptions: + pipeline_opts = body.pipelineOptions.model_dump() + try: - job = await analysis_service.create(body.documentId) + job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index e9a981b..2030bfa 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -48,5 +48,20 @@ class AnalysisResponse(_CamelModel): 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): documentId: str # camelCase to match existing frontend contract + pipelineOptions: PipelineOptionsRequest | None = None diff --git a/document-parser/data/docling_studio.db b/document-parser/data/docling_studio.db index b3fd80f..bdb74be 100644 Binary files a/document-parser/data/docling_studio.db and b/document-parser/data/docling_studio.db differ diff --git a/document-parser/domain/parsing.py b/document-parser/domain/parsing.py index e7c5faf..8177cc2 100644 --- a/document-parser/domain/parsing.py +++ b/document-parser/domain/parsing.py @@ -106,11 +106,15 @@ def build_converter( do_ocr: bool = True, do_table_structure: bool = True, 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: - """Build a DocumentConverter with the given pipeline options. - - Only exposes options that work out of the box (no extra model downloads). - """ + """Build a DocumentConverter with the given pipeline options.""" table_options = TableStructureOptions( do_cell_matching=True, mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST, @@ -120,12 +124,13 @@ def build_converter( do_ocr=do_ocr, do_table_structure=do_table_structure, table_structure_options=table_options, - do_code_enrichment=False, - do_formula_enrichment=False, - do_picture_classification=False, - do_picture_description=False, - generate_page_images=False, - generate_picture_images=False, + 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_page_images=generate_page_images, + generate_picture_images=generate_picture_images, + images_scale=images_scale, ) return DocumentConverter( @@ -228,19 +233,42 @@ def convert_document( do_ocr: bool = True, do_table_structure: bool = True, 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: """Convert a document and return structured results. This is the main entry point for document parsing. Runs synchronously (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() else: conv = build_converter( do_ocr=do_ocr, do_table_structure=do_table_structure, 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: diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py index 12708d3..4f2612f 100644 --- a/document-parser/services/analysis_service.py +++ b/document-parser/services/analysis_service.py @@ -14,7 +14,7 @@ from persistence import analysis_repo, document_repo 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.""" doc = await document_repo.find_by_id(document_id) if not doc: @@ -25,7 +25,7 @@ async def create(document_id: str) -> AnalysisJob: await analysis_repo.insert(job) # 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 @@ -42,7 +42,9 @@ async def delete(job_id: str) -> bool: 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.""" job = await analysis_repo.find_by_id(job_id) 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) try: + # Build kwargs from pipeline options + convert_kwargs = pipeline_options or {} + # 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]) diff --git a/document-parser/tests/test_api_endpoints.py b/document-parser/tests/test_api_endpoints.py index 1d5467d..20f2754 100644 --- a/document-parser/tests/test_api_endpoints.py +++ b/document-parser/tests/test_api_endpoints.py @@ -147,6 +147,60 @@ class TestAnalysisEndpoints: data = resp.json() assert data["id"] == "j1" 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) def test_create_analysis_document_not_found(self, mock_create, client): diff --git a/document-parser/tests/test_pipeline_options.py b/document-parser/tests/test_pipeline_options.py new file mode 100644 index 0000000..6e619e8 --- /dev/null +++ b/document-parser/tests/test_pipeline_options.py @@ -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="

Test

", + 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 diff --git a/document-parser/tests/test_schemas.py b/document-parser/tests/test_schemas.py index dc6c8a8..1a79c6a 100644 --- a/document-parser/tests/test_schemas.py +++ b/document-parser/tests/test_schemas.py @@ -6,6 +6,7 @@ from api.schemas import ( AnalysisResponse, CreateAnalysisRequest, DocumentResponse, + PipelineOptionsRequest, _to_camel, ) @@ -79,7 +80,47 @@ class TestAnalysisResponse: 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: def test_parses_document_id(self): req = CreateAnalysisRequest(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" diff --git a/frontend/src/features/analysis/api.js b/frontend/src/features/analysis/api.js index ee0209f..1b1ae93 100644 --- a/frontend/src/features/analysis/api.js +++ b/frontend/src/features/analysis/api.js @@ -1,9 +1,13 @@ 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', { method: 'POST', - body: JSON.stringify({ documentId }) + body: JSON.stringify(body) }) } diff --git a/frontend/src/features/analysis/api.test.js b/frontend/src/features/analysis/api.test.js index 684b8d6..e18183f 100644 --- a/frontend/src/features/analysis/api.test.js +++ b/frontend/src/features/analysis/api.test.js @@ -12,7 +12,7 @@ describe('analysis API', () => { 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' } apiFetch.mockResolvedValue(job) @@ -25,6 +25,20 @@ describe('analysis API', () => { 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 () => { const jobs = [{ id: '1', status: 'COMPLETED' }] apiFetch.mockResolvedValue(jobs) diff --git a/frontend/src/features/analysis/pipelineOptions.test.js b/frontend/src/features/analysis/pipelineOptions.test.js new file mode 100644 index 0000000..2aacaa3 --- /dev/null +++ b/frontend/src/features/analysis/pipelineOptions.test.js @@ -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() + }) +}) diff --git a/frontend/src/features/analysis/store.js b/frontend/src/features/analysis/store.js index a99eec6..e01025e 100644 --- a/frontend/src/features/analysis/store.js +++ b/frontend/src/features/analysis/store.js @@ -25,10 +25,10 @@ export const useAnalysisStore = defineStore('analysis', () => { } } - async function run(documentId) { + async function run(documentId, pipelineOptions = null) { running.value = true try { - const analysis = await api.createAnalysis(documentId) + const analysis = await api.createAnalysis(documentId, pipelineOptions) currentAnalysis.value = analysis analyses.value.unshift(analysis) startPolling(analysis.id) diff --git a/frontend/src/features/analysis/store.test.js b/frontend/src/features/analysis/store.test.js index 30324fd..9ee8313 100644 --- a/frontend/src/features/analysis/store.test.js +++ b/frontend/src/features/analysis/store.test.js @@ -70,6 +70,7 @@ describe('useAnalysisStore', () => { expect(store.currentAnalysis).toEqual(job) expect(store.analyses[0]).toEqual(job) expect(store.running).toBe(true) + expect(api.createAnalysis).toHaveBeenCalledWith('d1', null) // Advance timer to trigger polling await vi.advanceTimersByTimeAsync(2000) @@ -80,6 +81,20 @@ describe('useAnalysisStore', () => { 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 () => { api.createAnalysis.mockRejectedValue(new Error('fail')) vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue index 61c77b5..cbcf370 100644 --- a/frontend/src/pages/StudioPage.vue +++ b/frontend/src/pages/StudioPage.vue @@ -139,7 +139,6 @@
Docling @@ -147,50 +146,109 @@
+
- - -
+ -
- - -
+
+ + {{ t('config.ocrHint') }}? +
-
- -
- +
+ + {{ t('config.tableStructureHint') }}? +
+ +
+ +
+
- - + + +
+ + {{ t('config.codeEnrichmentHint') }}? +
+ +
+ + {{ t('config.formulaEnrichmentHint') }}? +
+
+ + +
+ + +
+ + {{ t('config.pictureClassificationHint') }}? +
+ +
+ + {{ t('config.pictureDescriptionHint') }}? +
+ +
+ + {{ t('config.generatePictureImagesHint') }}? +
+ +
+ + {{ t('config.generatePageImagesHint') }}? +
+ +
+ + +
@@ -225,12 +283,23 @@ const { t } = useI18n() const mode = ref('configurer') const currentPage = ref(1) -const pageRange = ref('') -const tableMode = ref('markdown') const visualMode = ref(false) const pdfImageRef = 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(() => { return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0 }) @@ -244,18 +313,6 @@ function onPdfImageLoad() { 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(() => { return documentStore.documents.find(d => d.id === documentStore.selectedId) }) @@ -274,7 +331,7 @@ function onPageInput(e) { async function runAnalysis() { if (!documentStore.selectedId) return - await analysisStore.run(documentStore.selectedId) + await analysisStore.run(documentStore.selectedId, { ...pipelineOptions }) } function addMore() { @@ -734,6 +791,7 @@ onMounted(() => { } .config-hint { + position: relative; display: inline-flex; align-items: center; justify-content: center; @@ -744,6 +802,43 @@ onMounted(() => { font-size: 10px; color: var(--text-muted); 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 { @@ -812,59 +907,78 @@ onMounted(() => { color: var(--text); } -.extract-options { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.extract-btn { +/* Toggle rows */ +.config-toggle-row { display: flex; align-items: center; - gap: 6px; - padding: 8px 14px; + justify-content: space-between; + 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); border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 13px; - font-weight: 500; - cursor: pointer; + border-radius: 10px; + transition: all var(--transition); + flex-shrink: 0; +} + +.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); } -.extract-btn:hover { - background: var(--bg-hover); - color: var(--text); -} - -.extract-btn.active { - background: var(--accent-muted); +.toggle-input:checked + .toggle-switch { + background: var(--accent); border-color: var(--accent); - color: var(--accent); } -.extract-icon { - width: 16px; - height: 16px; +.toggle-input:checked + .toggle-switch::after { + left: 18px; + background: white; } -.config-add-btn { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 8px 14px; - color: var(--text-secondary); +.toggle-text { font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition); - align-self: flex-start; + color: var(--text); } -.config-add-btn:hover { - background: var(--bg-hover); - color: var(--text); +.config-sub-option { + padding-left: 46px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.config-label-sm { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); } .config-docs { diff --git a/frontend/src/shared/i18n.js b/frontend/src/shared/i18n.js index b0e7c56..3d96ce2 100644 --- a/frontend/src/shared/i18n.js +++ b/frontend/src/shared/i18n.js @@ -9,7 +9,7 @@ const messages = { // Studio — import '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 — workspace @@ -25,16 +25,29 @@ const messages = { // Config panel 'config.model': 'Modèle', - 'config.pages': 'Pages', - 'config.pagesPlaceholder': 'par ex. "1-4,8"', - 'config.extractTables': 'Extraire les tableaux', - 'config.markdownIntegrated': 'Markdown intégré', - 'config.extract': 'Extraire', - 'config.images': 'Images', - 'config.header': 'En-tête', - 'config.footer': 'Pied de page', - 'config.annotateImages': 'Annoter les images', - 'config.add': 'Ajouter +', + 'config.pipeline': 'Pipeline', + 'config.ocr': 'OCR', + '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.tableStructure': 'Extraction des tableaux', + '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.tableMode': 'Mode tableaux', + 'config.tableModeAccurate': 'Précis', + 'config.tableModeFast': 'Rapide', + 'config.enrichment': 'Enrichissement', + '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', // Results @@ -45,7 +58,7 @@ const messages = { 'results.noImages': 'Aucune image détectée dans ce document', 'results.noMarkdown': 'Pas de contenu markdown', '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', // Upload @@ -86,16 +99,29 @@ const messages = { 'studio.visual': 'Visual', 'config.model': 'Model', - 'config.pages': 'Pages', - 'config.pagesPlaceholder': 'e.g. "1-4,8"', - 'config.extractTables': 'Extract tables', - 'config.markdownIntegrated': 'Inline Markdown', - 'config.extract': 'Extract', - 'config.images': 'Images', - 'config.header': 'Header', - 'config.footer': 'Footer', - 'config.annotateImages': 'Annotate images', - 'config.add': 'Add +', + 'config.pipeline': 'Pipeline', + 'config.ocr': 'OCR', + 'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.', + 'config.tableStructure': 'Table extraction', + 'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.', + 'config.tableMode': 'Table mode', + 'config.tableModeAccurate': 'Accurate', + 'config.tableModeFast': 'Fast', + 'config.enrichment': 'Enrichment', + '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', 'results.textResult': 'Text result',