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():
|
if not body.documentId or not body.documentId.strip():
|
||||||
raise HTTPException(status_code=400, detail="documentId is required")
|
raise HTTPException(status_code=400, detail="documentId is required")
|
||||||
|
|
||||||
|
pipeline_opts = None
|
||||||
|
if body.pipelineOptions:
|
||||||
|
pipeline_opts = body.pipelineOptions.model_dump()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await analysis_service.create(body.documentId)
|
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,20 @@ class AnalysisResponse(_CamelModel):
|
||||||
created_at: str | datetime
|
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):
|
class CreateAnalysisRequest(BaseModel):
|
||||||
documentId: str # camelCase to match existing frontend contract
|
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_ocr: bool = True,
|
||||||
do_table_structure: bool = True,
|
do_table_structure: bool = True,
|
||||||
table_mode: str = "accurate",
|
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:
|
) -> DocumentConverter:
|
||||||
"""Build a DocumentConverter with the given pipeline options.
|
"""Build a DocumentConverter with the given pipeline options."""
|
||||||
|
|
||||||
Only exposes options that work out of the box (no extra model downloads).
|
|
||||||
"""
|
|
||||||
table_options = TableStructureOptions(
|
table_options = TableStructureOptions(
|
||||||
do_cell_matching=True,
|
do_cell_matching=True,
|
||||||
mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST,
|
mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST,
|
||||||
|
|
@ -120,12 +124,13 @@ def build_converter(
|
||||||
do_ocr=do_ocr,
|
do_ocr=do_ocr,
|
||||||
do_table_structure=do_table_structure,
|
do_table_structure=do_table_structure,
|
||||||
table_structure_options=table_options,
|
table_structure_options=table_options,
|
||||||
do_code_enrichment=False,
|
do_code_enrichment=do_code_enrichment,
|
||||||
do_formula_enrichment=False,
|
do_formula_enrichment=do_formula_enrichment,
|
||||||
do_picture_classification=False,
|
do_picture_classification=do_picture_classification,
|
||||||
do_picture_description=False,
|
do_picture_description=do_picture_description,
|
||||||
generate_page_images=False,
|
generate_page_images=generate_page_images,
|
||||||
generate_picture_images=False,
|
generate_picture_images=generate_picture_images,
|
||||||
|
images_scale=images_scale,
|
||||||
)
|
)
|
||||||
|
|
||||||
return DocumentConverter(
|
return DocumentConverter(
|
||||||
|
|
@ -228,19 +233,42 @@ def convert_document(
|
||||||
do_ocr: bool = True,
|
do_ocr: bool = True,
|
||||||
do_table_structure: bool = True,
|
do_table_structure: bool = True,
|
||||||
table_mode: str = "accurate",
|
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:
|
) -> ConversionResult:
|
||||||
"""Convert a document and return structured results.
|
"""Convert a document and return structured results.
|
||||||
|
|
||||||
This is the main entry point for document parsing. Runs synchronously
|
This is the main entry point for document parsing. Runs synchronously
|
||||||
(caller should use asyncio.to_thread for non-blocking execution).
|
(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()
|
conv = get_default_converter()
|
||||||
else:
|
else:
|
||||||
conv = build_converter(
|
conv = build_converter(
|
||||||
do_ocr=do_ocr,
|
do_ocr=do_ocr,
|
||||||
do_table_structure=do_table_structure,
|
do_table_structure=do_table_structure,
|
||||||
table_mode=table_mode,
|
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:
|
with _converter_lock:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from persistence import analysis_repo, document_repo
|
||||||
logger = logging.getLogger(__name__)
|
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."""
|
"""Create a new analysis job and launch background processing."""
|
||||||
doc = await document_repo.find_by_id(document_id)
|
doc = await document_repo.find_by_id(document_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
|
|
@ -25,7 +25,7 @@ async def create(document_id: str) -> AnalysisJob:
|
||||||
await analysis_repo.insert(job)
|
await analysis_repo.insert(job)
|
||||||
|
|
||||||
# Fire-and-forget background task
|
# 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
|
return job
|
||||||
|
|
||||||
|
|
@ -42,7 +42,9 @@ async def delete(job_id: str) -> bool:
|
||||||
return await analysis_repo.delete(job_id)
|
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."""
|
"""Background task: run Docling conversion and update job status."""
|
||||||
job = await analysis_repo.find_by_id(job_id)
|
job = await analysis_repo.find_by_id(job_id)
|
||||||
if not job:
|
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)
|
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Build kwargs from pipeline options
|
||||||
|
convert_kwargs = pipeline_options or {}
|
||||||
|
|
||||||
# Run blocking Docling conversion in a thread
|
# 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])
|
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,60 @@ class TestAnalysisEndpoints:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == "j1"
|
assert data["id"] == "j1"
|
||||||
assert data["documentId"] == "d1"
|
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)
|
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||||
def test_create_analysis_document_not_found(self, mock_create, client):
|
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,
|
AnalysisResponse,
|
||||||
CreateAnalysisRequest,
|
CreateAnalysisRequest,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
|
PipelineOptionsRequest,
|
||||||
_to_camel,
|
_to_camel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -79,7 +80,47 @@ class TestAnalysisResponse:
|
||||||
assert resp.document_id == "d1"
|
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:
|
class TestCreateAnalysisRequest:
|
||||||
def test_parses_document_id(self):
|
def test_parses_document_id(self):
|
||||||
req = CreateAnalysisRequest(documentId="doc-42")
|
req = CreateAnalysisRequest(documentId="doc-42")
|
||||||
assert req.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'
|
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', {
|
return apiFetch('/api/analyses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ documentId })
|
body: JSON.stringify(body)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ describe('analysis API', () => {
|
||||||
vi.clearAllMocks()
|
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' }
|
const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
|
||||||
apiFetch.mockResolvedValue(job)
|
apiFetch.mockResolvedValue(job)
|
||||||
|
|
||||||
|
|
@ -25,6 +25,20 @@ describe('analysis API', () => {
|
||||||
expect(result).toEqual(job)
|
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 () => {
|
it('fetchAnalyses calls GET /api/analyses', async () => {
|
||||||
const jobs = [{ id: '1', status: 'COMPLETED' }]
|
const jobs = [{ id: '1', status: 'COMPLETED' }]
|
||||||
apiFetch.mockResolvedValue(jobs)
|
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
|
running.value = true
|
||||||
try {
|
try {
|
||||||
const analysis = await api.createAnalysis(documentId)
|
const analysis = await api.createAnalysis(documentId, pipelineOptions)
|
||||||
currentAnalysis.value = analysis
|
currentAnalysis.value = analysis
|
||||||
analyses.value.unshift(analysis)
|
analyses.value.unshift(analysis)
|
||||||
startPolling(analysis.id)
|
startPolling(analysis.id)
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ describe('useAnalysisStore', () => {
|
||||||
expect(store.currentAnalysis).toEqual(job)
|
expect(store.currentAnalysis).toEqual(job)
|
||||||
expect(store.analyses[0]).toEqual(job)
|
expect(store.analyses[0]).toEqual(job)
|
||||||
expect(store.running).toBe(true)
|
expect(store.running).toBe(true)
|
||||||
|
expect(api.createAnalysis).toHaveBeenCalledWith('d1', null)
|
||||||
|
|
||||||
// Advance timer to trigger polling
|
// Advance timer to trigger polling
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
|
@ -80,6 +81,20 @@ describe('useAnalysisStore', () => {
|
||||||
store.stopPolling()
|
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 () => {
|
it('run() resets running on error', async () => {
|
||||||
api.createAnalysis.mockRejectedValue(new Error('fail'))
|
api.createAnalysis.mockRejectedValue(new Error('fail'))
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,6 @@
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<label class="config-label">
|
<label class="config-label">
|
||||||
{{ t('config.model') }}
|
{{ t('config.model') }}
|
||||||
<span class="config-hint">?</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="config-select-display">
|
<div class="config-select-display">
|
||||||
<span class="config-model-name">Docling</span>
|
<span class="config-model-name">Docling</span>
|
||||||
|
|
@ -147,50 +146,109 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline options -->
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<label class="config-label">
|
<label class="config-label">{{ t('config.pipeline') }}</label>
|
||||||
{{ t('config.pages') }}
|
|
||||||
<span class="config-hint">?</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" class="config-input" :placeholder="t('config.pagesPlaceholder')" v-model="pageRange" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="config-section">
|
<div class="config-toggle-row">
|
||||||
<label class="config-label">
|
<label class="toggle-label">
|
||||||
{{ t('config.extractTables') }}
|
<input type="checkbox" v-model="pipelineOptions.do_ocr" class="toggle-input" />
|
||||||
<span class="config-hint">?</span>
|
<span class="toggle-switch" />
|
||||||
</label>
|
<span class="toggle-text">{{ t('config.ocr') }}</span>
|
||||||
<select class="config-select" v-model="tableMode">
|
</label>
|
||||||
<option value="markdown">{{ t('config.markdownIntegrated') }}</option>
|
<span class="config-hint"><span class="config-tooltip">{{ t('config.ocrHint') }}</span>?</span>
|
||||||
<option value="html">HTML</option>
|
</div>
|
||||||
<option value="csv">CSV</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="config-section">
|
<div class="config-toggle-row">
|
||||||
<label class="config-label">{{ t('config.extract') }}</label>
|
<label class="toggle-label">
|
||||||
<div class="extract-options">
|
<input type="checkbox" v-model="pipelineOptions.do_table_structure" class="toggle-input" />
|
||||||
<button
|
<span class="toggle-switch" />
|
||||||
v-for="opt in extractOptions"
|
<span class="toggle-text">{{ t('config.tableStructure') }}</span>
|
||||||
:key="opt.id"
|
</label>
|
||||||
class="extract-btn"
|
<span class="config-hint"><span class="config-tooltip">{{ t('config.tableStructureHint') }}</span>?</span>
|
||||||
:class="{ active: activeExtracts.has(opt.id) }"
|
</div>
|
||||||
@click="toggleExtract(opt.id)"
|
|
||||||
>
|
<div class="config-sub-option" v-if="pipelineOptions.do_table_structure">
|
||||||
<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>
|
<label class="config-label-sm">{{ t('config.tableMode') }}</label>
|
||||||
<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>
|
<select class="config-select" v-model="pipelineOptions.table_mode">
|
||||||
<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>
|
<option value="accurate">{{ t('config.tableModeAccurate') }}</option>
|
||||||
{{ opt.label }}
|
<option value="fast">{{ t('config.tableModeFast') }}</option>
|
||||||
</button>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enrichment options -->
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<label class="config-label">
|
<label class="config-label">{{ t('config.enrichment') }}</label>
|
||||||
{{ t('config.annotateImages') }}
|
|
||||||
<span class="config-hint">?</span>
|
<div class="config-toggle-row">
|
||||||
</label>
|
<label class="toggle-label">
|
||||||
<button class="config-add-btn">{{ t('config.add') }}</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Documents list at bottom -->
|
<!-- Documents list at bottom -->
|
||||||
|
|
@ -225,12 +283,23 @@ const { t } = useI18n()
|
||||||
|
|
||||||
const mode = ref('configurer')
|
const mode = ref('configurer')
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageRange = ref('')
|
|
||||||
const tableMode = ref('markdown')
|
|
||||||
const visualMode = ref(false)
|
const visualMode = ref(false)
|
||||||
const pdfImageRef = ref(null)
|
const pdfImageRef = ref(null)
|
||||||
const bboxOverlayRef = 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(() => {
|
const hasAnalysisResults = computed(() => {
|
||||||
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
|
return analysisStore.currentAnalysis?.status === 'COMPLETED' && analysisStore.currentPages?.length > 0
|
||||||
})
|
})
|
||||||
|
|
@ -244,18 +313,6 @@ function onPdfImageLoad() {
|
||||||
nextTick(() => bboxOverlayRef.value?.draw())
|
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(() => {
|
const selectedDoc = computed(() => {
|
||||||
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
||||||
})
|
})
|
||||||
|
|
@ -274,7 +331,7 @@ function onPageInput(e) {
|
||||||
|
|
||||||
async function runAnalysis() {
|
async function runAnalysis() {
|
||||||
if (!documentStore.selectedId) return
|
if (!documentStore.selectedId) return
|
||||||
await analysisStore.run(documentStore.selectedId)
|
await analysisStore.run(documentStore.selectedId, { ...pipelineOptions })
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMore() {
|
function addMore() {
|
||||||
|
|
@ -734,6 +791,7 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-hint {
|
.config-hint {
|
||||||
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -744,6 +802,43 @@ onMounted(() => {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: help;
|
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 {
|
.config-select-display {
|
||||||
|
|
@ -812,59 +907,78 @@ onMounted(() => {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.extract-options {
|
/* Toggle rows */
|
||||||
display: flex;
|
.config-toggle-row {
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.extract-btn {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: space-between;
|
||||||
padding: 8px 14px;
|
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);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: 10px;
|
||||||
color: var(--text-secondary);
|
transition: all var(--transition);
|
||||||
font-size: 13px;
|
flex-shrink: 0;
|
||||||
font-weight: 500;
|
}
|
||||||
cursor: pointer;
|
|
||||||
|
.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);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.extract-btn:hover {
|
.toggle-input:checked + .toggle-switch {
|
||||||
background: var(--bg-hover);
|
background: var(--accent);
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.extract-btn.active {
|
|
||||||
background: var(--accent-muted);
|
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.extract-icon {
|
.toggle-input:checked + .toggle-switch::after {
|
||||||
width: 16px;
|
left: 18px;
|
||||||
height: 16px;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-add-btn {
|
.toggle-text {
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: 8px 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
color: var(--text);
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition);
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-add-btn:hover {
|
.config-sub-option {
|
||||||
background: var(--bg-hover);
|
padding-left: 46px;
|
||||||
color: var(--text);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label-sm {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-docs {
|
.config-docs {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const messages = {
|
||||||
|
|
||||||
// Studio — import
|
// Studio — import
|
||||||
'studio.title': 'Intelligence des documents',
|
'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.recentDocs': 'Documents récents',
|
||||||
|
|
||||||
// Studio — workspace
|
// Studio — workspace
|
||||||
|
|
@ -25,16 +25,29 @@ const messages = {
|
||||||
|
|
||||||
// Config panel
|
// Config panel
|
||||||
'config.model': 'Modèle',
|
'config.model': 'Modèle',
|
||||||
'config.pages': 'Pages',
|
'config.pipeline': 'Pipeline',
|
||||||
'config.pagesPlaceholder': 'par ex. "1-4,8"',
|
'config.ocr': 'OCR',
|
||||||
'config.extractTables': 'Extraire les tableaux',
|
'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.markdownIntegrated': 'Markdown intégré',
|
'config.tableStructure': 'Extraction des tableaux',
|
||||||
'config.extract': 'Extraire',
|
'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.images': 'Images',
|
'config.tableMode': 'Mode tableaux',
|
||||||
'config.header': 'En-tête',
|
'config.tableModeAccurate': 'Précis',
|
||||||
'config.footer': 'Pied de page',
|
'config.tableModeFast': 'Rapide',
|
||||||
'config.annotateImages': 'Annoter les images',
|
'config.enrichment': 'Enrichissement',
|
||||||
'config.add': 'Ajouter +',
|
'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',
|
'config.documents': 'Documents',
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
|
|
@ -45,7 +58,7 @@ const messages = {
|
||||||
'results.noImages': 'Aucune image détectée dans ce document',
|
'results.noImages': 'Aucune image détectée dans ce document',
|
||||||
'results.noMarkdown': 'Pas de contenu markdown',
|
'results.noMarkdown': 'Pas de contenu markdown',
|
||||||
'results.runAnalysis': 'Lancez une analyse pour voir les résultats',
|
'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',
|
'results.page': 'Page',
|
||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
|
|
@ -86,16 +99,29 @@ const messages = {
|
||||||
'studio.visual': 'Visual',
|
'studio.visual': 'Visual',
|
||||||
|
|
||||||
'config.model': 'Model',
|
'config.model': 'Model',
|
||||||
'config.pages': 'Pages',
|
'config.pipeline': 'Pipeline',
|
||||||
'config.pagesPlaceholder': 'e.g. "1-4,8"',
|
'config.ocr': 'OCR',
|
||||||
'config.extractTables': 'Extract tables',
|
'config.ocrHint': 'Applies Optical Character Recognition on scanned pages or embedded images. Essential for non-native PDFs.',
|
||||||
'config.markdownIntegrated': 'Inline Markdown',
|
'config.tableStructure': 'Table extraction',
|
||||||
'config.extract': 'Extract',
|
'config.tableStructureHint': 'Detects tables in the document and reconstructs their row/column structure using the TableFormer model, with cell matching.',
|
||||||
'config.images': 'Images',
|
'config.tableMode': 'Table mode',
|
||||||
'config.header': 'Header',
|
'config.tableModeAccurate': 'Accurate',
|
||||||
'config.footer': 'Footer',
|
'config.tableModeFast': 'Fast',
|
||||||
'config.annotateImages': 'Annotate images',
|
'config.enrichment': 'Enrichment',
|
||||||
'config.add': 'Add +',
|
'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',
|
'config.documents': 'Documents',
|
||||||
|
|
||||||
'results.textResult': 'Text result',
|
'results.textResult': 'Text result',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue