Fix audit findings: remove domain→infra violation, align Serve API, fix DI

- Delete domain/parsing.py (broke hexagonal layering by importing infra)
- Migrate all tests to import directly from domain.value_objects and
  infra.local_converter
- Rewrite ServeConverter to match real Docling Serve v1 API contract:
  options sent as individual form fields (not JSON blob), response
  parsed from document.json_content (DoclingDocument), proper bbox
  coord_origin handling (TOPLEFT/BOTTOMLEFT)
- Transmit all conversion options including generate_picture_images
- Replace fragile lazy import circular dep with FastAPI Depends() +
  app.state for AnalysisService injection
- Add frontend file size validation (50MB) before upload

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Pier-Jean Malandrino 2026-03-31 10:58:58 +02:00
parent a3486a8501
commit fe4e792885
8 changed files with 385 additions and 339 deletions

View file

@ -3,19 +3,22 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Annotated
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from api.schemas import AnalysisResponse, CreateAnalysisRequest from api.schemas import AnalysisResponse, CreateAnalysisRequest
from services.analysis_service import AnalysisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/analyses", tags=["analyses"]) router = APIRouter(prefix="/api/analyses", tags=["analyses"])
def _get_service(): def _get_service(request: Request) -> AnalysisService:
"""Lazy import to avoid circular dependency at module level.""" return request.app.state.analysis_service
from main import analysis_service
return analysis_service
ServiceDep = Annotated[AnalysisService, Depends(_get_service)]
def _to_response(job) -> AnalysisResponse: def _to_response(job) -> AnalysisResponse:
@ -35,7 +38,7 @@ def _to_response(job) -> AnalysisResponse:
@router.post("", response_model=AnalysisResponse) @router.post("", response_model=AnalysisResponse)
async def create_analysis(body: CreateAnalysisRequest): async def create_analysis(body: CreateAnalysisRequest, service: ServiceDep):
"""Create a new analysis job for a document.""" """Create a new analysis job for a document."""
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")
@ -45,7 +48,7 @@ async def create_analysis(body: CreateAnalysisRequest):
pipeline_opts = body.pipelineOptions.model_dump() pipeline_opts = body.pipelineOptions.model_dump()
try: try:
job = await _get_service().create(body.documentId, pipeline_options=pipeline_opts) job = await service.create(body.documentId, pipeline_options=pipeline_opts)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
@ -53,24 +56,24 @@ async def create_analysis(body: CreateAnalysisRequest):
@router.get("", response_model=list[AnalysisResponse]) @router.get("", response_model=list[AnalysisResponse])
async def list_analyses(): async def list_analyses(service: ServiceDep):
"""List all analysis jobs.""" """List all analysis jobs."""
jobs = await _get_service().find_all() jobs = await service.find_all()
return [_to_response(j) for j in jobs] return [_to_response(j) for j in jobs]
@router.get("/{job_id}", response_model=AnalysisResponse) @router.get("/{job_id}", response_model=AnalysisResponse)
async def get_analysis(job_id: str): async def get_analysis(job_id: str, service: ServiceDep):
"""Get a single analysis job.""" """Get a single analysis job."""
job = await _get_service().find_by_id(job_id) job = await service.find_by_id(job_id)
if not job: if not job:
raise HTTPException(status_code=404, detail="Analysis not found") raise HTTPException(status_code=404, detail="Analysis not found")
return _to_response(job) return _to_response(job)
@router.delete("/{job_id}", status_code=204) @router.delete("/{job_id}", status_code=204)
async def delete_analysis(job_id: str): async def delete_analysis(job_id: str, service: ServiceDep):
"""Delete an analysis job.""" """Delete an analysis job."""
deleted = await _get_service().delete(job_id) deleted = await service.delete(job_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail="Analysis not found") raise HTTPException(status_code=404, detail="Analysis not found")

View file

@ -1,34 +0,0 @@
"""Backward-compatible re-exports for domain.parsing.
After the hexagonal architecture refactoring:
- Value objects moved to domain.value_objects
- Docling implementation moved to infra.local_converter
This module re-exports the public names so existing code and tests
that import from domain.parsing continue to work.
"""
from __future__ import annotations
from domain.value_objects import ( # noqa: F401
ConversionOptions,
ConversionResult,
PageDetail,
PageElement,
)
from infra.local_converter import (
_build_docling_converter,
_convert_sync,
_extract_pages_detail as extract_pages_detail, # noqa: F401
_get_default_converter as get_default_converter, # noqa: F401
)
def build_converter(options: ConversionOptions | None = None):
"""Build a Docling DocumentConverter (backward-compatible signature)."""
return _build_docling_converter(options or ConversionOptions())
def convert_document(file_path: str, options: ConversionOptions | None = None) -> ConversionResult:
"""Convert a document synchronously (backward-compatible signature)."""
return _convert_sync(file_path, options or ConversionOptions())

View file

@ -1,12 +1,18 @@
"""Remote Docling Serve converter — delegates conversion via HTTP. """Remote Docling Serve converter — delegates conversion via HTTP.
This adapter implements the DocumentConverter port by calling a remote This adapter implements the DocumentConverter port by calling a remote
Docling Serve instance's REST API. It supports both synchronous and Docling Serve instance's REST API (v1).
asynchronous conversion endpoints.
API contract based on docling-serve source code:
- Options are sent as individual multipart form fields (not a JSON blob)
- Response contains document.md_content, document.html_content, document.json_content
- json_content is a serialized DoclingDocument with texts[], tables[], pictures[]
- Bounding boxes use {l, t, r, b, coord_origin} format
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
@ -22,12 +28,28 @@ from domain.value_objects import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Docling Serve API base path
_API_PREFIX = "/v1" _API_PREFIX = "/v1"
# Default timeout for HTTP requests (seconds)
_DEFAULT_TIMEOUT = 600.0 _DEFAULT_TIMEOUT = 600.0
# Docling Serve label → our element type
_LABEL_MAP = {
"table": "table",
"picture": "picture",
"figure": "picture",
"title": "title",
"section_header": "section_header",
"list_item": "list",
"formula": "formula",
"code": "code",
"caption": "text",
"footnote": "text",
"page_header": "text",
"page_footer": "text",
"paragraph": "text",
"text": "text",
"reference": "text",
}
class ServeConverter: class ServeConverter:
"""Adapter that delegates document conversion to a remote Docling Serve instance.""" """Adapter that delegates document conversion to a remote Docling Serve instance."""
@ -48,21 +70,6 @@ class ServeConverter:
headers["X-Api-Key"] = self._api_key headers["X-Api-Key"] = self._api_key
return headers return headers
def _build_conversion_options(self, options: ConversionOptions) -> dict:
"""Map our ConversionOptions to Docling Serve's expected format."""
opts: dict = {
"to_formats": ["md", "html"],
"do_ocr": options.do_ocr,
"do_table_structure": options.do_table_structure,
"table_mode": options.table_mode,
"do_code_enrichment": options.do_code_enrichment,
"do_formula_enrichment": options.do_formula_enrichment,
"do_picture_classification": options.do_picture_classification,
"do_picture_description": options.do_picture_description,
"images_scale": options.images_scale,
}
return opts
async def convert( async def convert(
self, file_path: str, options: ConversionOptions, self, file_path: str, options: ConversionOptions,
) -> ConversionResult: ) -> ConversionResult:
@ -70,25 +77,20 @@ class ServeConverter:
path = Path(file_path) path = Path(file_path)
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
conversion_opts = self._build_conversion_options(options) form_data = _build_form_data(options)
url = f"{self._base_url}{_API_PREFIX}/convert/file" url = f"{self._base_url}{_API_PREFIX}/convert/file"
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
with open(path, "rb") as f: with open(path, "rb") as f:
files = {"files": (path.name, f, content_type)}
data = {"options": _serialize_options(conversion_opts)}
logger.info("Sending conversion request to %s", url)
response = await client.post( response = await client.post(
url, url,
files=files, files={"files": (path.name, f, content_type)},
data=data, data=form_data,
headers=self._headers(), headers=self._headers(),
) )
response.raise_for_status() response.raise_for_status()
result_data = response.json() result_data = response.json()
return _parse_response(result_data) return _parse_response(result_data)
@ -102,40 +104,46 @@ class ServeConverter:
) )
return resp.status_code == 200 return resp.status_code == 200
except httpx.HTTPError: except httpx.HTTPError:
logger.warning("Docling Serve health check failed at %s", self._base_url, exc_info=True)
return False return False
def _serialize_options(opts: dict) -> str: def _build_form_data(options: ConversionOptions) -> dict[str, str]:
"""Serialize conversion options to JSON string for multipart form.""" """Build individual form fields matching Docling Serve's FormDepends pattern.
import json
return json.dumps(opts) Docling Serve uses FormDepends to flatten ConvertDocumentsRequestOptions
into individual form fields (not a JSON blob).
"""
return {
"to_formats": '["md","html","json"]',
"do_ocr": str(options.do_ocr).lower(),
"do_table_structure": str(options.do_table_structure).lower(),
"table_mode": options.table_mode,
"do_code_enrichment": str(options.do_code_enrichment).lower(),
"do_formula_enrichment": str(options.do_formula_enrichment).lower(),
"do_picture_classification": str(options.do_picture_classification).lower(),
"do_picture_description": str(options.do_picture_description).lower(),
"include_images": str(options.generate_picture_images).lower(),
"images_scale": str(options.images_scale),
}
def _parse_response(data: dict) -> ConversionResult: def _parse_response(data: dict) -> ConversionResult:
"""Parse Docling Serve JSON response into our domain ConversionResult. """Parse Docling Serve v1 ConvertDocumentResponse into our domain ConversionResult."""
document = data.get("document", {})
Docling Serve returns a DoclingDocument structure. The response format content_md = document.get("md_content") or ""
contains document content and page-level information with bounding boxes. content_html = document.get("html_content") or ""
"""
document = data.get("document", data)
# Extract markdown and HTML content # json_content contains the full DoclingDocument with pages, elements, bboxes
content_md = "" json_content = document.get("json_content")
content_html = "" if isinstance(json_content, str):
json_content = json.loads(json_content)
# Docling Serve may return content in different formats pages: list[PageDetail] = []
if "md_content" in document: if json_content:
content_md = document["md_content"] pages = _extract_pages_from_docling_document(json_content)
elif "export_to_markdown" in document:
content_md = document["export_to_markdown"]
if "html_content" in document:
content_html = document["html_content"]
elif "export_to_html" in document:
content_html = document["export_to_html"]
# Parse pages
pages = _extract_pages(document)
page_count = len(pages) if pages else 1 page_count = len(pages) if pages else 1
return ConversionResult( return ConversionResult(
@ -146,13 +154,19 @@ def _parse_response(data: dict) -> ConversionResult:
) )
def _extract_pages(document: dict) -> list[PageDetail]: def _extract_pages_from_docling_document(doc: dict) -> list[PageDetail]:
"""Extract page details with elements from Docling Serve response.""" """Extract pages with elements from a serialized DoclingDocument.
DoclingDocument structure:
- pages: {page_no: {size: {width, height}}}
- texts: [{label, text, prov: [{page_no, bbox: {l,t,r,b,coord_origin}}]}]
- tables: [{label, prov: [...], data: {...}}]
- pictures: [{label, prov: [...]}]
"""
pages_dict: dict[int, PageDetail] = {} pages_dict: dict[int, PageDetail] = {}
# Extract page dimensions from pages metadata # Build page dimensions
raw_pages = document.get("pages", {}) for page_key, page_data in doc.get("pages", {}).items():
for page_key, page_data in raw_pages.items():
page_no = int(page_key) page_no = int(page_key)
size = page_data.get("size", {}) size = page_data.get("size", {})
pages_dict[page_no] = PageDetail( pages_dict[page_no] = PageDetail(
@ -161,69 +175,55 @@ def _extract_pages(document: dict) -> list[PageDetail]:
height=size.get("height", 792.0), height=size.get("height", 792.0),
) )
# Extract elements from the document body # Process all element arrays
body = document.get("body", document.get("main_text", [])) for item in doc.get("texts", []):
if isinstance(body, list): _add_element(item, pages_dict)
for item in body:
_process_serve_item(item, pages_dict, document) for item in doc.get("tables", []):
_add_element(item, pages_dict)
for item in doc.get("pictures", []):
_add_element(item, pages_dict)
return sorted(pages_dict.values(), key=lambda p: p.page_number) return sorted(pages_dict.values(), key=lambda p: p.page_number)
def _process_serve_item( def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
item: dict, pages: dict[int, PageDetail], document: dict, """Add an element from a DoclingDocument array to the correct page."""
) -> None: label = item.get("label", "text")
"""Process a single item from Docling Serve response body.""" element_type = _LABEL_MAP.get(label, "text")
prov_list = item.get("prov", []) content = item.get("text", "") or ""
if not prov_list:
return
item_type = _map_item_type(item) for prov in item.get("prov", []):
content = item.get("text", "") page_no = prov.get("page_no", 1)
level = item.get("level", 0)
for prov in prov_list:
page_no = prov.get("page_no", prov.get("page", 1))
if page_no not in pages: if page_no not in pages:
pages[page_no] = PageDetail( pages[page_no] = PageDetail(
page_number=page_no, width=612.0, height=792.0, page_number=page_no, width=612.0, height=792.0,
) )
bbox_data = prov.get("bbox", {}) bbox_data = prov.get("bbox", {})
if isinstance(bbox_data, dict): bbox = _extract_bbox(bbox_data, pages[page_no].height)
bbox = [
bbox_data.get("l", 0.0),
bbox_data.get("t", 0.0),
bbox_data.get("r", 0.0),
bbox_data.get("b", 0.0),
]
elif isinstance(bbox_data, list) and len(bbox_data) == 4:
bbox = [float(v) for v in bbox_data]
else:
bbox = [0.0, 0.0, 0.0, 0.0]
pages[page_no].elements.append( pages[page_no].elements.append(
PageElement(type=item_type, bbox=bbox, content=content, level=level) PageElement(type=element_type, bbox=bbox, content=content, level=0)
) )
def _map_item_type(item: dict) -> str: def _extract_bbox(bbox_data: dict, page_height: float) -> list[float]:
"""Map Docling Serve item type to our element type string.""" """Extract and normalize bbox to TOPLEFT [l, t, r, b] format."""
item_type = item.get("type", item.get("obj_type", "text")) if not isinstance(bbox_data, dict):
type_mapping = { return [0.0, 0.0, 0.0, 0.0]
"table": "table",
"picture": "picture", l = bbox_data.get("l", 0.0)
"figure": "picture", t = bbox_data.get("t", 0.0)
"title": "title", r = bbox_data.get("r", 0.0)
"section_header": "section_header", b = bbox_data.get("b", 0.0)
"section-header": "section_header", coord_origin = bbox_data.get("coord_origin", "TOPLEFT")
"list_item": "list",
"list": "list", if coord_origin == "BOTTOMLEFT":
"formula": "formula", # Convert: top = page_height - old_top, bottom = page_height - old_bottom
"equation": "formula", new_t = page_height - b
"code": "code", new_b = page_height - t
"floating": "floating", t, b = new_t, new_b
"text": "text",
"paragraph": "text", return [l, t, r, b]
}
return type_mapping.get(item_type.lower(), "text") if item_type else "text"

View file

@ -14,13 +14,14 @@ from __future__ import annotations
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api.analyses import router as analyses_router from api.analyses import router as analyses_router
from api.documents import router as documents_router from api.documents import router as documents_router
from infra.settings import Settings from infra.settings import Settings
from persistence.database import init_db from persistence.database import init_db
from services.analysis_service import AnalysisService
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -50,8 +51,7 @@ def _build_converter():
return LocalConverter() return LocalConverter()
def _build_analysis_service(): def _build_analysis_service() -> AnalysisService:
from services.analysis_service import AnalysisService
converter = _build_converter() converter = _build_converter()
return AnalysisService( return AnalysisService(
converter=converter, converter=converter,
@ -59,10 +59,6 @@ def _build_analysis_service():
) )
# Singleton service instance — imported by API routers
analysis_service = _build_analysis_service()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FastAPI app # FastAPI app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -70,6 +66,7 @@ analysis_service = _build_analysis_service()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
app.state.analysis_service = _build_analysis_service()
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine) logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
yield yield
@ -92,6 +89,11 @@ app.include_router(documents_router)
app.include_router(analyses_router) app.include_router(analyses_router)
def get_analysis_service(request: Request) -> AnalysisService:
"""FastAPI dependency — retrieve the AnalysisService from app.state."""
return request.app.state.analysis_service
@app.get("/health") @app.get("/health")
def health(): def health():
"""Health check endpoint.""" """Health check endpoint."""

View file

@ -1,6 +1,6 @@
"""Tests for FastAPI API endpoints using TestClient.""" """Tests for FastAPI API endpoints using TestClient."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -14,6 +14,16 @@ def client():
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def mock_analysis_service(client):
"""Inject a mock AnalysisService into app.state for the duration of the test."""
mock_svc = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock_svc
yield mock_svc
app.state.analysis_service = original
class TestHealthEndpoint: class TestHealthEndpoint:
def test_health(self, client): def test_health(self, client):
resp = client.get("/health") resp = client.get("/health")
@ -104,11 +114,10 @@ class TestDocumentEndpoints:
class TestAnalysisEndpoints: class TestAnalysisEndpoints:
@patch("main.analysis_service.find_all", new_callable=AsyncMock) def test_list_analyses(self, client, mock_analysis_service):
def test_list_analyses(self, mock_find_all, client): mock_analysis_service.find_all = AsyncMock(return_value=[
mock_find_all.return_value = [
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"), AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
] ])
resp = client.get("/api/analyses") resp = client.get("/api/analyses")
assert resp.status_code == 200 assert resp.status_code == 200
@ -119,11 +128,10 @@ class TestAnalysisEndpoints:
assert data[0]["documentFilename"] == "test.pdf" assert data[0]["documentFilename"] == "test.pdf"
assert data[0]["status"] == "PENDING" assert data[0]["status"] == "PENDING"
@patch("main.analysis_service.find_by_id", new_callable=AsyncMock) def test_get_analysis(self, client, mock_analysis_service):
def test_get_analysis(self, mock_find, client):
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf") job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
job.mark_running() job.mark_running()
mock_find.return_value = job mock_analysis_service.find_by_id = AsyncMock(return_value=job)
resp = client.get("/api/analyses/j1") resp = client.get("/api/analyses/j1")
assert resp.status_code == 200 assert resp.status_code == 200
@ -131,31 +139,28 @@ class TestAnalysisEndpoints:
assert data["status"] == "RUNNING" assert data["status"] == "RUNNING"
assert data["startedAt"] is not None assert data["startedAt"] is not None
@patch("main.analysis_service.find_by_id", new_callable=AsyncMock) def test_get_analysis_not_found(self, client, mock_analysis_service):
def test_get_analysis_not_found(self, mock_find, client): mock_analysis_service.find_by_id = AsyncMock(return_value=None)
mock_find.return_value = None
resp = client.get("/api/analyses/missing") resp = client.get("/api/analyses/missing")
assert resp.status_code == 404 assert resp.status_code == 404
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_create_analysis(self, client, mock_analysis_service):
def test_create_analysis(self, mock_create, client): mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
mock_create.return_value = AnalysisJob(
id="j1", document_id="d1", document_filename="test.pdf", id="j1", document_id="d1", document_filename="test.pdf",
) ))
resp = client.post("/api/analyses", json={"documentId": "d1"}) resp = client.post("/api/analyses", json={"documentId": "d1"})
assert resp.status_code == 200 assert resp.status_code == 200
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) mock_analysis_service.create.assert_called_once_with("d1", pipeline_options=None)
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
def test_create_analysis_with_pipeline_options(self, mock_create, client): mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
mock_create.return_value = AnalysisJob(
id="j2", document_id="d1", document_filename="test.pdf", id="j2", document_id="d1", document_filename="test.pdf",
) ))
resp = client.post("/api/analyses", json={ resp = client.post("/api/analyses", json={
"documentId": "d1", "documentId": "d1",
@ -176,7 +181,7 @@ class TestAnalysisEndpoints:
data = resp.json() data = resp.json()
assert data["id"] == "j2" assert data["id"] == "j2"
call_kwargs = mock_create.call_args call_kwargs = mock_analysis_service.create.call_args
opts = call_kwargs.kwargs["pipeline_options"] opts = call_kwargs.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
assert opts["table_mode"] == "fast" assert opts["table_mode"] == "fast"
@ -184,12 +189,11 @@ class TestAnalysisEndpoints:
assert opts["generate_picture_images"] is True assert opts["generate_picture_images"] is True
assert opts["images_scale"] == 2.0 assert opts["images_scale"] == 2.0
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
def test_create_analysis_with_partial_pipeline_options(self, mock_create, client):
"""Pipeline options should use defaults for unspecified fields.""" """Pipeline options should use defaults for unspecified fields."""
mock_create.return_value = AnalysisJob( mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
id="j3", document_id="d1", document_filename="test.pdf", id="j3", document_id="d1", document_filename="test.pdf",
) ))
resp = client.post("/api/analyses", json={ resp = client.post("/api/analyses", json={
"documentId": "d1", "documentId": "d1",
@ -197,38 +201,35 @@ class TestAnalysisEndpoints:
}) })
assert resp.status_code == 200 assert resp.status_code == 200
opts = mock_create.call_args.kwargs["pipeline_options"] opts = mock_analysis_service.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
# Defaults # Defaults
assert opts["do_table_structure"] is True assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate" assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False assert opts["do_code_enrichment"] is False
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_create_analysis_document_not_found(self, client, mock_analysis_service):
def test_create_analysis_document_not_found(self, mock_create, client): mock_analysis_service.create = AsyncMock(side_effect=ValueError("Document not found: d99"))
mock_create.side_effect = ValueError("Document not found: d99")
resp = client.post("/api/analyses", json={"documentId": "d99"}) resp = client.post("/api/analyses", json={"documentId": "d99"})
assert resp.status_code == 404 assert resp.status_code == 404
def test_create_analysis_empty_document_id(self, client): def test_create_analysis_empty_document_id(self, client, mock_analysis_service):
resp = client.post("/api/analyses", json={"documentId": ""}) resp = client.post("/api/analyses", json={"documentId": ""})
assert resp.status_code == 400 assert resp.status_code == 400
def test_create_analysis_whitespace_document_id(self, client): def test_create_analysis_whitespace_document_id(self, client, mock_analysis_service):
resp = client.post("/api/analyses", json={"documentId": " "}) resp = client.post("/api/analyses", json={"documentId": " "})
assert resp.status_code == 400 assert resp.status_code == 400
@patch("main.analysis_service.delete", new_callable=AsyncMock) def test_delete_analysis(self, client, mock_analysis_service):
def test_delete_analysis(self, mock_delete, client): mock_analysis_service.delete = AsyncMock(return_value=True)
mock_delete.return_value = True
resp = client.delete("/api/analyses/j1") resp = client.delete("/api/analyses/j1")
assert resp.status_code == 204 assert resp.status_code == 204
@patch("main.analysis_service.delete", new_callable=AsyncMock) def test_delete_analysis_not_found(self, client, mock_analysis_service):
def test_delete_analysis_not_found(self, mock_delete, client): mock_analysis_service.delete = AsyncMock(return_value=False)
mock_delete.return_value = False
resp = client.delete("/api/analyses/missing") resp = client.delete("/api/analyses/missing")
assert resp.status_code == 404 assert resp.status_code == 404

View file

@ -11,10 +11,10 @@ from docling.datamodel.pipeline_options import (
TableFormerMode, TableFormerMode,
) )
from domain.parsing import ( from domain.value_objects import ConversionOptions
ConversionOptions, from infra.local_converter import (
build_converter, _build_docling_converter as build_converter,
convert_document, _convert_sync as convert_document,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -30,7 +30,7 @@ class TestBuildConverter:
return fmt_opt.pipeline_options return fmt_opt.pipeline_options
def test_defaults(self): def test_defaults(self):
conv = build_converter() conv = build_converter(ConversionOptions())
opts = self._get_pipeline_options(conv) opts = self._get_pipeline_options(conv)
assert opts.do_ocr is True assert opts.do_ocr is True
assert opts.do_table_structure is True assert opts.do_table_structure is True
@ -143,7 +143,7 @@ class TestConvertDocumentRouting:
mock_conv.convert.return_value = mock_result mock_conv.convert.return_value = mock_result
mock_get_default.return_value = mock_conv mock_get_default.return_value = mock_conv
convert_document("/tmp/test.pdf") convert_document("/tmp/test.pdf", ConversionOptions())
mock_get_default.assert_called_once() mock_get_default.assert_called_once()
mock_build.assert_not_called() mock_build.assert_not_called()
@ -457,30 +457,37 @@ class TestAnalysisEndpointPipelineOptions:
@pytest.fixture @pytest.fixture
def client(self): def client(self):
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from main import app from main import app
return TestClient(app, raise_server_exceptions=False) return TestClient(app, raise_server_exceptions=False)
@patch("main.analysis_service.create", new_callable=AsyncMock) @pytest.fixture
def test_no_pipeline_options_sends_none(self, mock_create, client): def mock_svc(self, client):
from main import app
from unittest.mock import MagicMock
mock = MagicMock()
original = getattr(app.state, "analysis_service", None)
app.state.analysis_service = mock
yield mock
app.state.analysis_service = original
def test_no_pipeline_options_sends_none(self, client, mock_svc):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1") mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={"documentId": "d1"}) client.post("/api/analyses", json={"documentId": "d1"})
mock_create.assert_called_once_with("d1", pipeline_options=None) mock_svc.create.assert_called_once_with("d1", pipeline_options=None)
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
def test_empty_pipeline_options_object_uses_defaults(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1") mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={ client.post("/api/analyses", json={
"documentId": "d1", "documentId": "d1",
"pipelineOptions": {}, "pipelineOptions": {},
}) })
opts = mock_create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is True assert opts["do_ocr"] is True
assert opts["do_table_structure"] is True assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate" assert opts["table_mode"] == "accurate"
@ -488,20 +495,18 @@ class TestAnalysisEndpointPipelineOptions:
assert opts["do_formula_enrichment"] is False assert opts["do_formula_enrichment"] is False
assert opts["images_scale"] == 1.0 assert opts["images_scale"] == 1.0
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
def test_partial_pipeline_options_merges_with_defaults(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1") mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
client.post("/api/analyses", json={ client.post("/api/analyses", json={
"documentId": "d1", "documentId": "d1",
"pipelineOptions": {"do_ocr": False, "images_scale": 1.5}, "pipelineOptions": {"do_ocr": False, "images_scale": 1.5},
}) })
opts = mock_create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts["do_ocr"] is False assert opts["do_ocr"] is False
assert opts["images_scale"] == 1.5 assert opts["images_scale"] == 1.5
# All other fields should have defaults
assert opts["do_table_structure"] is True assert opts["do_table_structure"] is True
assert opts["table_mode"] == "accurate" assert opts["table_mode"] == "accurate"
assert opts["do_code_enrichment"] is False assert opts["do_code_enrichment"] is False
@ -511,10 +516,9 @@ class TestAnalysisEndpointPipelineOptions:
assert opts["generate_picture_images"] is False assert opts["generate_picture_images"] is False
assert opts["generate_page_images"] is False assert opts["generate_page_images"] is False
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_full_pipeline_options(self, client, mock_svc):
def test_full_pipeline_options(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1") mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
payload = { payload = {
"documentId": "d1", "documentId": "d1",
@ -535,25 +539,22 @@ class TestAnalysisEndpointPipelineOptions:
resp = client.post("/api/analyses", json=payload) resp = client.post("/api/analyses", json=payload)
assert resp.status_code == 200 assert resp.status_code == 200
opts = mock_create.call_args.kwargs["pipeline_options"] opts = mock_svc.create.call_args.kwargs["pipeline_options"]
assert opts == payload["pipelineOptions"] assert opts == payload["pipelineOptions"]
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
def test_invalid_pipeline_option_type_rejected(self, mock_create, client):
resp = client.post("/api/analyses", json={ resp = client.post("/api/analyses", json={
"documentId": "d1", "documentId": "d1",
"pipelineOptions": {"do_ocr": "not-a-bool"}, "pipelineOptions": {"do_ocr": "not-a-bool"},
}) })
assert resp.status_code == 422 assert resp.status_code == 422
@patch("main.analysis_service.create", new_callable=AsyncMock) def test_unknown_pipeline_option_ignored(self, client, mock_svc):
def test_unknown_pipeline_option_ignored(self, mock_create, client):
from domain.models import AnalysisJob from domain.models import AnalysisJob
mock_create.return_value = AnalysisJob(id="j1", document_id="d1") mock_svc.create = AsyncMock(return_value=AnalysisJob(id="j1", document_id="d1"))
resp = client.post("/api/analyses", json={ resp = client.post("/api/analyses", json={
"documentId": "d1", "documentId": "d1",
"pipelineOptions": {"do_ocr": True, "unknown_field": True}, "pipelineOptions": {"do_ocr": True, "unknown_field": True},
}) })
# Pydantic ignores extra fields by default
assert resp.status_code == 200 assert resp.status_code == 200

View file

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from unittest.mock import AsyncMock, MagicMock, mock_open, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx import httpx
import pytest import pytest
@ -11,79 +11,92 @@ import pytest
from domain.value_objects import ConversionOptions, ConversionResult, PageDetail, PageElement from domain.value_objects import ConversionOptions, ConversionResult, PageDetail, PageElement
from infra.serve_converter import ( from infra.serve_converter import (
ServeConverter, ServeConverter,
_extract_pages, _build_form_data,
_map_item_type, _extract_bbox,
_extract_pages_from_docling_document,
_parse_response, _parse_response,
) )
# ---------------------------------------------------------------------------
# Unit tests — form data building
# ---------------------------------------------------------------------------
class TestBuildFormData:
def test_default_options(self):
data = _build_form_data(ConversionOptions())
assert data["do_ocr"] == "true"
assert data["do_table_structure"] == "true"
assert data["table_mode"] == "accurate"
assert data["do_code_enrichment"] == "false"
assert data["do_formula_enrichment"] == "false"
assert data["do_picture_classification"] == "false"
assert data["do_picture_description"] == "false"
assert data["include_images"] == "false"
assert data["images_scale"] == "1.0"
assert '"json"' in data["to_formats"]
def test_custom_options(self):
opts = ConversionOptions(
do_ocr=False, table_mode="fast", images_scale=2.0,
generate_picture_images=True,
)
data = _build_form_data(opts)
assert data["do_ocr"] == "false"
assert data["table_mode"] == "fast"
assert data["images_scale"] == "2.0"
assert data["include_images"] == "true"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unit tests — response parsing # Unit tests — response parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestParseResponse: class TestParseResponse:
"""Verify _parse_response correctly maps Docling Serve JSON to ConversionResult."""
def test_minimal_response(self): def test_minimal_response(self):
data = { data = {
"document": { "document": {
"md_content": "# Hello", "md_content": "# Hello",
"html_content": "<h1>Hello</h1>", "html_content": "<h1>Hello</h1>",
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [],
"tables": [],
"pictures": [],
},
} }
} }
result = _parse_response(data) result = _parse_response(data)
assert isinstance(result, ConversionResult) assert isinstance(result, ConversionResult)
assert result.content_markdown == "# Hello" assert result.content_markdown == "# Hello"
assert result.content_html == "<h1>Hello</h1>" assert result.content_html == "<h1>Hello</h1>"
assert result.page_count == 1 assert result.page_count == 1
assert len(result.pages) == 1
assert result.pages[0].width == 612.0 assert result.pages[0].width == 612.0
assert result.pages[0].height == 792.0
def test_multi_page_response(self): def test_response_with_elements(self):
data = { data = {
"document": { "document": {
"md_content": "text", "md_content": "# Title\nText",
"html_content": "<p>text</p>", "html_content": "<h1>Title</h1><p>Text</p>",
"pages": { "json_content": {
"1": {"size": {"width": 612.0, "height": 792.0}}, "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"2": {"size": {"width": 595.0, "height": 842.0}}, "texts": [
{
"label": "title",
"text": "Title",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}],
},
{
"label": "paragraph",
"text": "Text",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 50, "r": 200, "b": 70, "coord_origin": "TOPLEFT"}}],
},
],
"tables": [],
"pictures": [],
}, },
} }
} }
result = _parse_response(data)
assert result.page_count == 2
assert result.pages[0].page_number == 1
assert result.pages[1].page_number == 2
assert result.pages[1].width == 595.0 # A4
def test_response_with_body_elements(self):
data = {
"document": {
"md_content": "# Title\nSome text",
"html_content": "<h1>Title</h1><p>Some text</p>",
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"body": [
{
"type": "title",
"text": "Title",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40}}],
},
{
"type": "text",
"text": "Some text",
"level": 0,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 50, "r": 200, "b": 70}}],
},
],
}
}
result = _parse_response(data) result = _parse_response(data)
assert len(result.pages[0].elements) == 2 assert len(result.pages[0].elements) == 2
assert result.pages[0].elements[0].type == "title" assert result.pages[0].elements[0].type == "title"
@ -91,57 +104,121 @@ class TestParseResponse:
assert result.pages[0].elements[0].bbox == [10, 20, 200, 40] assert result.pages[0].elements[0].bbox == [10, 20, 200, 40]
assert result.pages[0].elements[1].type == "text" assert result.pages[0].elements[1].type == "text"
def test_empty_response(self): def test_multi_page(self):
data = {"document": {"pages": {}}}
result = _parse_response(data)
assert result.content_markdown == ""
assert result.content_html == ""
assert result.page_count == 1 # fallback minimum
def test_bbox_as_list(self):
data = { data = {
"document": { "document": {
"md_content": "", "md_content": "",
"html_content": "", "html_content": "",
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "json_content": {
"body": [ "pages": {
{ "1": {"size": {"width": 612.0, "height": 792.0}},
"type": "text", "2": {"size": {"width": 595.0, "height": 842.0}},
"text": "hello",
"prov": [{"page_no": 1, "bbox": [10.0, 20.0, 200.0, 40.0]}],
}, },
], "texts": [], "tables": [], "pictures": [],
},
} }
} }
result = _parse_response(data) result = _parse_response(data)
assert result.pages[0].elements[0].bbox == [10.0, 20.0, 200.0, 40.0] assert result.page_count == 2
assert result.pages[1].width == 595.0
def test_no_json_content(self):
data = {
"document": {
"md_content": "text",
"html_content": "<p>text</p>",
}
}
result = _parse_response(data)
assert result.content_markdown == "text"
assert result.pages == []
assert result.page_count == 1
def test_json_content_as_string(self):
json_doc = {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [], "tables": [], "pictures": [],
}
data = {
"document": {
"md_content": "",
"html_content": "",
"json_content": json.dumps(json_doc),
}
}
result = _parse_response(data)
assert result.page_count == 1
def test_tables_and_pictures(self):
data = {
"document": {
"md_content": "",
"html_content": "",
"json_content": {
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
"texts": [],
"tables": [
{"label": "table", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 300, "b": 200, "coord_origin": "TOPLEFT"}}]},
],
"pictures": [
{"label": "picture", "text": "", "prov": [{"page_no": 1, "bbox": {"l": 50, "t": 300, "r": 250, "b": 500, "coord_origin": "TOPLEFT"}}]},
],
},
}
}
result = _parse_response(data)
types = [e.type for e in result.pages[0].elements]
assert "table" in types
assert "picture" in types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unit tests — item type mapping # Unit tests — bbox extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMapItemType: class TestExtractBbox:
@pytest.mark.parametrize("input_type,expected", [ def test_topleft_passthrough(self):
("table", "table"), bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0)
("picture", "picture"), assert bbox == [10, 20, 100, 50]
("figure", "picture"),
("title", "title"),
("section_header", "section_header"),
("section-header", "section_header"),
("list_item", "list"),
("formula", "formula"),
("equation", "formula"),
("code", "code"),
("text", "text"),
("paragraph", "text"),
("unknown_type", "text"),
])
def test_type_mapping(self, input_type, expected):
assert _map_item_type({"type": input_type}) == expected
def test_missing_type_defaults_to_text(self): def test_bottomleft_conversion(self):
assert _map_item_type({}) == "text" bbox = _extract_bbox({"l": 10, "t": 742, "r": 100, "b": 772, "coord_origin": "BOTTOMLEFT"}, 792.0)
# new_t = 792 - 772 = 20, new_b = 792 - 742 = 50
assert bbox == [10, 20, 100, 50]
def test_missing_coord_origin_defaults_topleft(self):
bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50}, 792.0)
assert bbox == [10, 20, 100, 50]
def test_empty_dict(self):
bbox = _extract_bbox({}, 792.0)
assert bbox == [0.0, 0.0, 0.0, 0.0]
def test_non_dict_returns_zeros(self):
bbox = _extract_bbox("invalid", 792.0)
assert bbox == [0.0, 0.0, 0.0, 0.0]
# ---------------------------------------------------------------------------
# Unit tests — label mapping
# ---------------------------------------------------------------------------
class TestLabelMapping:
def test_known_labels(self):
from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP["table"] == "table"
assert _LABEL_MAP["picture"] == "picture"
assert _LABEL_MAP["figure"] == "picture"
assert _LABEL_MAP["title"] == "title"
assert _LABEL_MAP["section_header"] == "section_header"
assert _LABEL_MAP["list_item"] == "list"
assert _LABEL_MAP["formula"] == "formula"
assert _LABEL_MAP["code"] == "code"
assert _LABEL_MAP["paragraph"] == "text"
def test_unknown_label_defaults_to_text(self):
from infra.serve_converter import _LABEL_MAP
assert _LABEL_MAP.get("unknown_thing", "text") == "text"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -157,17 +234,6 @@ class TestServeConverter:
conv = ServeConverter(base_url="http://localhost:5001") conv = ServeConverter(base_url="http://localhost:5001")
assert conv._headers() == {} assert conv._headers() == {}
def test_build_conversion_options(self):
conv = ServeConverter(base_url="http://localhost:5001")
opts = ConversionOptions(do_ocr=False, table_mode="fast", images_scale=2.0)
result = conv._build_conversion_options(opts)
assert result["do_ocr"] is False
assert result["table_mode"] == "fast"
assert result["images_scale"] == 2.0
assert result["to_formats"] == ["md", "html"]
def test_base_url_trailing_slash_stripped(self): def test_base_url_trailing_slash_stripped(self):
conv = ServeConverter(base_url="http://localhost:5001/") conv = ServeConverter(base_url="http://localhost:5001/")
assert conv._base_url == "http://localhost:5001" assert conv._base_url == "http://localhost:5001"
@ -180,7 +246,6 @@ class TestServeConverter:
class TestServeConverterConvert: class TestServeConverterConvert:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_successful_conversion(self, tmp_path): async def test_successful_conversion(self, tmp_path):
# Create a temp file to "upload"
test_file = tmp_path / "test.pdf" test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 fake content") test_file.write_bytes(b"%PDF-1.4 fake content")
@ -188,14 +253,14 @@ class TestServeConverterConvert:
"document": { "document": {
"md_content": "# Converted", "md_content": "# Converted",
"html_content": "<h1>Converted</h1>", "html_content": "<h1>Converted</h1>",
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}}, "json_content": {
"body": [ "pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
{ "texts": [
"type": "title", {"label": "title", "text": "Converted", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}]},
"text": "Converted", ],
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40}}], "tables": [],
}, "pictures": [],
], },
} }
} }
@ -218,11 +283,13 @@ class TestServeConverterConvert:
assert result.content_markdown == "# Converted" assert result.content_markdown == "# Converted"
assert result.page_count == 1 assert result.page_count == 1
assert len(result.pages[0].elements) == 1 assert len(result.pages[0].elements) == 1
assert result.pages[0].elements[0].type == "title"
# Verify the HTTP call # Verify form fields sent individually (not as JSON blob)
mock_client.post.assert_called_once()
call_kwargs = mock_client.post.call_args call_kwargs = mock_client.post.call_args
assert "/v1/convert/file" in call_kwargs[0][0] sent_data = call_kwargs.kwargs.get("data", {})
assert "do_ocr" in sent_data
assert sent_data["do_ocr"] == "true"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_http_error_raises(self, tmp_path): async def test_http_error_raises(self, tmp_path):
@ -281,26 +348,26 @@ class TestConverterWiring:
def test_local_engine_builds_local_converter(self): def test_local_engine_builds_local_converter(self):
from infra.local_converter import LocalConverter from infra.local_converter import LocalConverter
from infra.settings import Settings from infra.settings import Settings
from main import _build_converter
with patch("main.settings", Settings(conversion_engine="local")): with patch("main.settings", Settings(conversion_engine="local")):
from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, LocalConverter) assert isinstance(converter, LocalConverter)
def test_remote_engine_builds_serve_converter(self): def test_remote_engine_builds_serve_converter(self):
from infra.settings import Settings from infra.settings import Settings
from main import _build_converter
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001")): with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001")):
from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, ServeConverter) assert isinstance(converter, ServeConverter)
assert converter._base_url == "http://serve:5001" assert converter._base_url == "http://serve:5001"
def test_remote_engine_passes_api_key(self): def test_remote_engine_passes_api_key(self):
from infra.settings import Settings from infra.settings import Settings
from main import _build_converter
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001", docling_serve_api_key="my-key")): with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001", docling_serve_api_key="my-key")):
from main import _build_converter
converter = _build_converter() converter = _build_converter()
assert isinstance(converter, ServeConverter) assert isinstance(converter, ServeConverter)
assert converter._api_key == "my-key" assert converter._api_key == "my-key"

View file

@ -3,6 +3,8 @@ import { ref } from 'vue'
import type { Document } from '../../shared/types' import type { Document } from '../../shared/types'
import * as api from './api' import * as api from './api'
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
export const useDocumentStore = defineStore('document', () => { export const useDocumentStore = defineStore('document', () => {
const documents = ref<Document[]>([]) const documents = ref<Document[]>([])
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
@ -24,6 +26,10 @@ export const useDocumentStore = defineStore('document', () => {
} }
async function upload(file: File): Promise<Document> { async function upload(file: File): Promise<Document> {
if (file.size > MAX_FILE_SIZE) {
error.value = 'File too large (max 50 MB)'
throw new Error(error.value)
}
uploading.value = true uploading.value = true
error.value = null error.value = null
try { try {