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

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

View file

@ -35,8 +35,12 @@ async def create_analysis(body: CreateAnalysisRequest):
if not body.documentId or not body.documentId.strip():
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))

View file

@ -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

View file

@ -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:

View file

@ -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])

View file

@ -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):

View file

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

View file

@ -6,6 +6,7 @@ from api.schemas import (
AnalysisResponse,
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"

View file

@ -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)
})
}

View file

@ -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)

View file

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

View file

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

View file

@ -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(() => {})

View file

@ -139,7 +139,6 @@
<div class="config-section">
<label class="config-label">
{{ t('config.model') }}
<span class="config-hint">?</span>
</label>
<div class="config-select-display">
<span class="config-model-name">Docling</span>
@ -147,50 +146,109 @@
</div>
</div>
<!-- Pipeline options -->
<div class="config-section">
<label class="config-label">
{{ t('config.pages') }}
<span class="config-hint">?</span>
</label>
<input type="text" class="config-input" :placeholder="t('config.pagesPlaceholder')" v-model="pageRange" />
</div>
<label class="config-label">{{ t('config.pipeline') }}</label>
<div class="config-section">
<label class="config-label">
{{ t('config.extractTables') }}
<span class="config-hint">?</span>
</label>
<select class="config-select" v-model="tableMode">
<option value="markdown">{{ t('config.markdownIntegrated') }}</option>
<option value="html">HTML</option>
<option value="csv">CSV</option>
</select>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_ocr" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.ocr') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span>
</div>
<div class="config-section">
<label class="config-label">{{ t('config.extract') }}</label>
<div class="extract-options">
<button
v-for="opt in extractOptions"
:key="opt.id"
class="extract-btn"
:class="{ active: activeExtracts.has(opt.id) }"
@click="toggleExtract(opt.id)"
>
<svg v-if="opt.icon === 'image'" viewBox="0 0 20 20" fill="currentColor" class="extract-icon"><path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/></svg>
<svg v-else-if="opt.icon === 'header'" viewBox="0 0 20 20" fill="currentColor" class="extract-icon"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="extract-icon"><path d="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 2h10v7h-2l-1-2-3 4-2-3-2 3V5z"/></svg>
{{ opt.label }}
</button>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span>
</div>
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
<label class="config-label-sm">{{ t('config.tableMode') }}</label>
<select class="config-select" v-model="pipelineOptions.table_mode">
<option value="accurate">{{ t('config.tableModeAccurate') }}</option>
<option value="fast">{{ t('config.tableModeFast') }}</option>
</select>
</div>
</div>
<!-- Enrichment options -->
<div class="config-section">
<label class="config-label">
{{ t('config.annotateImages') }}
<span class="config-hint">?</span>
</label>
<button class="config-add-btn">{{ t('config.add') }}</button>
<label class="config-label">{{ t('config.enrichment') }}</label>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_code_enrichment" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.codeEnrichment') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.codeEnrichmentHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_formula_enrichment" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.formulaEnrichment') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.formulaEnrichmentHint') }}</span>?</span>
</div>
</div>
<!-- Picture options -->
<div class="config-section">
<label class="config-label">{{ t('config.pictures') }}</label>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_classification" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureClassification') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureClassificationHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.do_picture_description" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.pictureDescription') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.pictureDescriptionHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_picture_images" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePictureImages') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePictureImagesHint') }}</span>?</span>
</div>
<div class="config-toggle-row">
<label class="toggle-label">
<input type="checkbox" v-model="pipelineOptions.generate_page_images" class="toggle-input" />
<span class="toggle-switch" />
<span class="toggle-text">{{ t('config.generatePageImages') }}</span>
</label>
<span class="config-hint"><span class="config-tooltip">{{ t('config.generatePageImagesHint') }}</span>?</span>
</div>
<div class="config-sub-option" v-if="pipelineOptions.generate_picture_images || pipelineOptions.generate_page_images">
<label class="config-label-sm">{{ t('config.imagesScale') }}</label>
<select class="config-select" v-model.number="pipelineOptions.images_scale">
<option :value="0.5">0.5x</option>
<option :value="1.0">1.0x</option>
<option :value="1.5">1.5x</option>
<option :value="2.0">2.0x</option>
</select>
</div>
</div>
<!-- Documents list at bottom -->
@ -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 {

View file

@ -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',