Merge pull request #11 from scub-france/connect-configuration
Connect options with model configuration
This commit is contained in:
commit
55f3f22c4b
15 changed files with 1246 additions and 134 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
564
document-parser/tests/test_pipeline_options.py
Normal file
564
document-parser/tests/test_pipeline_options.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
226
frontend/src/features/analysis/pipelineOptions.test.js
Normal file
226
frontend/src/features/analysis/pipelineOptions.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(() => {})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue