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.codeEnrichmentHint') }}?
+
+
+
+
+ {{ t('config.formulaEnrichmentHint') }}?
+
+
+
+
+
@@ -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',