docling-studio/document-parser/tests/test_pipeline_options.py
Pier-Jean Malandrino efc27932dd refactor(audit): remediate 0.5.0 audit findings — clean architecture, security, DRY, SOLID, perf
Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).

Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
  * Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
  * Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
  * infra/llm/ollama_provider.py — OllamaProvider with health_check
  * infra/docling_agent_reasoning.py — runner adapter, encapsulates the
    private _rag_loop call (tracked at docling-project/docling-agent#26),
    commits OLLAMA_HOST once at boot (eliminates the per-request env race),
    translates upstream IndexError into ReasoningParseError
  * api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
    consumes app.state.reasoning_runner via the port
  * main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
    when REASONING_ENABLED=true and deps are importable
  * Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
    type RAGResult → ReasoningResult, frontend feature flag wiring,
    i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
    consumers in production)
  * 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
    httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
    serialization, R13 Protocol conformance via isinstance
  * E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
  * README — Live Reasoning section (env vars, archi, link to issue #26)

Bloc B — Security (audit 08, dev-only context)
  * docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
    flagged as dev-only with link to OpenSearch security docs
  * main.py — boot warning if NEO4J_URI is set with the default 'changeme'
    password, so prod operators can't silently inherit it

Bloc C — DRY frontend (audit 05)
  * shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
  * features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
  * api/schemas.py — DOCUMENT_STATUS_UPLOADED constant

Bloc D — Quality (audits 02/06/07/09/10/12)
  * domain/ports.py — DocumentConverter.supports_page_batching property
    (LSP fix, replaces isinstance(ServeConverter) check)
  * domain/ports.py — VectorStore.ping() (encapsulation, replaces
    _vector_store._client.info() reach-around)
  * api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
    aligned with the user-facing terminology (URLs unchanged)
  * api/documents.py — Path.read_bytes() + generate_preview() wrapped in
    asyncio.to_thread, unblocks the FastAPI event loop on /preview
  * infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
  * src/__tests__/integration/ — cross-feature integration test relocated
    out of features/history/ so feature folders stay self-contained
  * Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
    exact value comparisons)

Validation
  * 446 backend pytest, 202 frontend vitest — all green
  * ruff + ruff format + ESLint + Prettier + vue-tsc clean
  * Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO

Closes #200
2026-04-29 14:00:00 +02:00

589 lines
23 KiB
Python

"""Tests for pipeline options — build_converter, convert_document routing, service forwarding.
Requires the ``docling`` library (heavy, includes torch). Tests are skipped
automatically when docling is not installed (e.g. in lightweight CI environments
that only install docling-core).
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
docling = pytest.importorskip("docling", reason="docling library not installed")
from docling.datamodel.base_models import InputFormat # noqa: E402
from docling.datamodel.pipeline_options import ( # noqa: E402
PdfPipelineOptions,
TableFormerMode,
)
from domain.value_objects import ConversionOptions # noqa: E402
from infra.local_converter import ( # noqa: E402
_build_docling_converter as build_converter,
)
from infra.local_converter import ( # noqa: E402
_convert_sync as convert_document,
)
# ---------------------------------------------------------------------------
# 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(ConversionOptions())
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
# default document timeout is 120s (cf. infra/settings.py)
assert opts.document_timeout == 120.0
def test_ocr_disabled(self):
conv = build_converter(ConversionOptions(do_ocr=False))
opts = self._get_pipeline_options(conv)
assert opts.do_ocr is False
def test_table_mode_fast(self):
conv = build_converter(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(ConversionOptions(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(
ConversionOptions(
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("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_get_default.return_value = mock_conv
convert_document("/tmp/test.pdf", ConversionOptions())
mock_get_default.assert_called_once()
mock_build.assert_not_called()
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", ConversionOptions(do_ocr=False))
mock_build.assert_called_once()
mock_get_default.assert_not_called()
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
opts = ConversionOptions(table_mode="fast")
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once_with(opts)
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
opts = ConversionOptions(do_code_enrichment=True)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once_with(opts)
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", ConversionOptions(do_formula_enrichment=True))
mock_build.assert_called_once()
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", ConversionOptions(do_picture_classification=True))
mock_build.assert_called_once()
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
convert_document("/tmp/test.pdf", ConversionOptions(generate_picture_images=True))
mock_build.assert_called_once()
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
opts = ConversionOptions(images_scale=2.0)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once_with(opts)
@patch("infra.local_converter._ensure_default_converter")
@patch("infra.local_converter._build_docling_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_result.document.export_to_dict.return_value = {}
mock_conv.convert.return_value = mock_result
mock_build.return_value = mock_conv
opts = ConversionOptions(
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,
)
convert_document("/tmp/test.pdf", opts)
mock_build.assert_called_once_with(opts)
# ---------------------------------------------------------------------------
# Service layer — pipeline options forwarding
# ---------------------------------------------------------------------------
class TestServiceForwardsPipelineOptions:
"""Verify analysis_service.create and _run_analysis forward pipeline options."""
def _make_service(self, converter):
from services.analysis_service import AnalysisService
mock_analysis_repo = MagicMock()
mock_analysis_repo.find_by_id = AsyncMock()
mock_analysis_repo.insert = AsyncMock()
mock_analysis_repo.update_status = AsyncMock()
mock_document_repo = MagicMock()
mock_document_repo.find_by_id = AsyncMock()
mock_document_repo.update_page_count = AsyncMock()
return AnalysisService(
converter=converter,
analysis_repo=mock_analysis_repo,
document_repo=mock_document_repo,
)
@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")
@pytest.mark.asyncio
async def test_create_passes_pipeline_options_to_run(self, mock_doc):
mock_converter = AsyncMock()
svc = self._make_service(mock_converter)
svc._document_repo.find_by_id = AsyncMock(return_value=mock_doc)
opts = {"do_ocr": False, "table_mode": "fast"}
with patch("services.analysis_service.asyncio.create_task") as mock_task:
await svc.create("d1", pipeline_options=opts)
mock_task.assert_called_once()
@pytest.mark.asyncio
async def test_create_passes_none_when_no_options(self, mock_doc):
mock_converter = AsyncMock()
svc = self._make_service(mock_converter)
svc._document_repo.find_by_id = AsyncMock(return_value=mock_doc)
with patch("services.analysis_service.asyncio.create_task") as mock_task:
await svc.create("d1")
mock_task.assert_called_once()
@pytest.mark.asyncio
async def test_run_analysis_forwards_options_to_convert(self, mock_job):
from domain.value_objects import ConversionResult, PageDetail
mock_converter = AsyncMock()
mock_converter.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)],
)
svc = self._make_service(mock_converter)
svc._analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
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 svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
mock_converter.convert.assert_called_once()
call_args = mock_converter.convert.call_args
assert call_args[0][0] == "/tmp/test.pdf"
conv_opts = call_args[0][1]
assert conv_opts.do_ocr is False
assert conv_opts.table_mode == "fast"
assert conv_opts.do_code_enrichment is True
assert conv_opts.generate_picture_images is True
assert conv_opts.images_scale == 2.0
@pytest.mark.asyncio
async def test_run_analysis_uses_defaults_when_no_options(self, mock_job):
from domain.value_objects import ConversionResult, PageDetail
mock_converter = AsyncMock()
mock_converter.convert.return_value = ConversionResult(
page_count=1,
content_markdown="",
content_html="",
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
)
svc = self._make_service(mock_converter)
svc._analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
mock_converter.convert.assert_called_once()
call_args = mock_converter.convert.call_args
assert call_args[0][0] == "/tmp/test.pdf"
assert call_args[0][1] == ConversionOptions()
@pytest.mark.asyncio
async def test_run_analysis_marks_failed_on_error(self, mock_job):
mock_converter = AsyncMock()
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
svc = self._make_service(mock_converter)
svc._analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
# Should have called update_status twice: RUNNING then FAILED
assert svc._analysis_repo.update_status.call_count == 2
last_job = svc._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)
@pytest.fixture
def mock_svc(self, client):
from unittest.mock import MagicMock
from main import app
mock = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock
yield mock
app.state.analysis_service = original
def test_no_pipeline_options_sends_none(self, client, mock_svc):
from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={"documentId": "d1"})
mock_svc.create.assert_called_once_with("d1", pipeline_options=None, chunking_options=None)
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {},
},
)
opts = mock_svc.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
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
from domain.models import AnalysisJob
mock_svc.create = AsyncMock(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_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False
assert opts["images_scale"] == 1.5
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
def test_full_pipeline_options(self, client, mock_svc):
from domain.models import AnalysisJob
mock_svc.create = AsyncMock(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_svc.create.call_args.kwargs["pipeline_options"]
assert opts == payload["pipelineOptions"]
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
resp = client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"},
},
)
assert resp.status_code == 422
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
from domain.models import AnalysisJob
mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
resp = client.post(
"/api/analyses",
json={
"documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
},
)
assert resp.status_code == 200