Merge pull request #28 from scub-france/fix/zombie-jobs-and-json-parse
Add Docling - serve
This commit is contained in:
commit
f190b39bec
21 changed files with 1237 additions and 356 deletions
|
|
@ -3,16 +3,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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 services import analysis_service
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/analyses", tags=["analyses"])
|
||||
|
||||
|
||||
def _get_service(request: Request) -> AnalysisService:
|
||||
return request.app.state.analysis_service
|
||||
|
||||
|
||||
ServiceDep = Annotated[AnalysisService, Depends(_get_service)]
|
||||
|
||||
|
||||
def _to_response(job) -> AnalysisResponse:
|
||||
return AnalysisResponse(
|
||||
id=job.id,
|
||||
|
|
@ -30,7 +38,7 @@ def _to_response(job) -> 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."""
|
||||
if not body.documentId or not body.documentId.strip():
|
||||
raise HTTPException(status_code=400, detail="documentId is required")
|
||||
|
|
@ -40,7 +48,7 @@ async def create_analysis(body: CreateAnalysisRequest):
|
|||
pipeline_opts = body.pipelineOptions.model_dump()
|
||||
|
||||
try:
|
||||
job = await analysis_service.create(body.documentId, pipeline_options=pipeline_opts)
|
||||
job = await service.create(body.documentId, pipeline_options=pipeline_opts)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
|
||||
|
|
@ -48,24 +56,24 @@ async def create_analysis(body: CreateAnalysisRequest):
|
|||
|
||||
|
||||
@router.get("", response_model=list[AnalysisResponse])
|
||||
async def list_analyses():
|
||||
async def list_analyses(service: ServiceDep):
|
||||
"""List all analysis jobs."""
|
||||
jobs = await analysis_service.find_all()
|
||||
jobs = await service.find_all()
|
||||
return [_to_response(j) for j in jobs]
|
||||
|
||||
|
||||
@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."""
|
||||
job = await analysis_service.find_by_id(job_id)
|
||||
job = await service.find_by_id(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@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."""
|
||||
deleted = await analysis_service.delete(job_id)
|
||||
deleted = await service.delete(job_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||
|
|
|
|||
24
document-parser/domain/ports.py
Normal file
24
document-parser/domain/ports.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Domain ports — abstract interfaces that infrastructure must implement.
|
||||
|
||||
These protocols define what the domain NEEDS, not how it's done.
|
||||
Infrastructure adapters (local Docling, Docling Serve, etc.) implement these.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from domain.value_objects import ConversionOptions, ConversionResult
|
||||
|
||||
|
||||
class DocumentConverter(Protocol):
|
||||
"""Port for document conversion.
|
||||
|
||||
Any implementation (local Docling lib, remote Docling Serve, mock, etc.)
|
||||
must satisfy this contract.
|
||||
"""
|
||||
|
||||
async def convert(
|
||||
self, file_path: str, options: ConversionOptions,
|
||||
) -> ConversionResult: ...
|
||||
51
document-parser/domain/value_objects.py
Normal file
51
document-parser/domain/value_objects.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Domain value objects — pure data structures for document conversion.
|
||||
|
||||
These types define the contract between the domain and infrastructure layers.
|
||||
They have ZERO external dependencies (no docling, no HTTP, no DB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageElement:
|
||||
type: str
|
||||
bbox: list[float]
|
||||
content: str
|
||||
level: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageDetail:
|
||||
page_number: int
|
||||
width: float
|
||||
height: float
|
||||
elements: list[PageElement] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionOptions:
|
||||
do_ocr: bool = True
|
||||
do_table_structure: bool = True
|
||||
table_mode: str = "accurate"
|
||||
do_code_enrichment: bool = False
|
||||
do_formula_enrichment: bool = False
|
||||
do_picture_classification: bool = False
|
||||
do_picture_description: bool = False
|
||||
generate_picture_images: bool = False
|
||||
generate_page_images: bool = False
|
||||
images_scale: float = 1.0
|
||||
|
||||
def is_default(self) -> bool:
|
||||
return self == ConversionOptions()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionResult:
|
||||
page_count: int
|
||||
content_markdown: str
|
||||
content_html: str
|
||||
pages: list[PageDetail]
|
||||
skipped_items: int = 0
|
||||
0
document-parser/infra/__init__.py
Normal file
0
document-parser/infra/__init__.py
Normal file
|
|
@ -1,15 +1,16 @@
|
|||
"""Docling document extraction logic — pure domain, no HTTP concerns.
|
||||
"""Local Docling converter — runs Docling as a Python library in-process.
|
||||
|
||||
Wraps the Docling library to convert documents and extract structured
|
||||
per-page elements with bounding boxes and hierarchy levels.
|
||||
This adapter implements the DocumentConverter port using the Docling library
|
||||
directly. It wraps the blocking DocumentConverter in asyncio.to_thread for
|
||||
non-blocking execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from docling.datamodel.base_models import InputFormat
|
||||
from docling.datamodel.pipeline_options import (
|
||||
|
|
@ -17,7 +18,8 @@ from docling.datamodel.pipeline_options import (
|
|||
TableFormerMode,
|
||||
TableStructureOptions,
|
||||
)
|
||||
from docling.document_converter import DocumentConverter, PdfFormatOption
|
||||
from docling.document_converter import DocumentConverter as DoclingConverter
|
||||
from docling.document_converter import PdfFormatOption
|
||||
from docling_core.types.doc import (
|
||||
CodeItem,
|
||||
DocItem,
|
||||
|
|
@ -33,10 +35,16 @@ from docling_core.types.doc import (
|
|||
)
|
||||
|
||||
from domain.bbox import to_topleft_list
|
||||
from domain.value_objects import (
|
||||
ConversionOptions,
|
||||
ConversionResult,
|
||||
PageDetail,
|
||||
PageElement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thread lock — DocumentConverter is not thread-safe
|
||||
# Thread lock — DoclingConverter is not thread-safe
|
||||
_converter_lock = threading.Lock()
|
||||
|
||||
# US Letter page dimensions (points) — fallback when page size is unknown
|
||||
|
|
@ -44,61 +52,13 @@ _DEFAULT_PAGE_WIDTH = 612.0
|
|||
_DEFAULT_PAGE_HEIGHT = 792.0
|
||||
|
||||
# Default converter (lazy-init on first request)
|
||||
_default_converter: DocumentConverter | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class PageElement:
|
||||
type: str
|
||||
bbox: list[float]
|
||||
content: str
|
||||
level: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageDetail:
|
||||
page_number: int
|
||||
width: float
|
||||
height: float
|
||||
elements: list[PageElement] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionOptions:
|
||||
do_ocr: bool = True
|
||||
do_table_structure: bool = True
|
||||
table_mode: str = "accurate"
|
||||
do_code_enrichment: bool = False
|
||||
do_formula_enrichment: bool = False
|
||||
do_picture_classification: bool = False
|
||||
do_picture_description: bool = False
|
||||
generate_picture_images: bool = False
|
||||
generate_page_images: bool = False
|
||||
images_scale: float = 1.0
|
||||
|
||||
def is_default(self) -> bool:
|
||||
return self == ConversionOptions()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionResult:
|
||||
page_count: int
|
||||
content_markdown: str
|
||||
content_html: str
|
||||
pages: list[PageDetail]
|
||||
skipped_items: int = 0
|
||||
_default_converter: DoclingConverter | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Element type detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping from Docling type to element type string.
|
||||
# Order matters: most specific types before their parents.
|
||||
_ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
||||
(TableItem, "table"),
|
||||
(PictureItem, "picture"),
|
||||
|
|
@ -113,7 +73,6 @@ _ELEMENT_TYPE_MAP: list[tuple[type, str]] = [
|
|||
|
||||
|
||||
def _get_element_type(item: DocItem) -> str:
|
||||
"""Determine element type via isinstance on Docling's type hierarchy."""
|
||||
for cls, type_name in _ELEMENT_TYPE_MAP:
|
||||
if isinstance(item, cls):
|
||||
return type_name
|
||||
|
|
@ -124,51 +83,50 @@ def _get_element_type(item: DocItem) -> str:
|
|||
# Pipeline factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_converter(options: ConversionOptions | None = None) -> DocumentConverter:
|
||||
"""Build a DocumentConverter with the given pipeline options."""
|
||||
opts = options or ConversionOptions()
|
||||
|
||||
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
||||
table_options = TableStructureOptions(
|
||||
do_cell_matching=True,
|
||||
mode=TableFormerMode.ACCURATE if opts.table_mode == "accurate" else TableFormerMode.FAST,
|
||||
mode=TableFormerMode.ACCURATE if options.table_mode == "accurate" else TableFormerMode.FAST,
|
||||
)
|
||||
|
||||
pipeline_options = PdfPipelineOptions(
|
||||
do_ocr=opts.do_ocr,
|
||||
do_table_structure=opts.do_table_structure,
|
||||
do_ocr=options.do_ocr,
|
||||
do_table_structure=options.do_table_structure,
|
||||
table_structure_options=table_options,
|
||||
do_code_enrichment=opts.do_code_enrichment,
|
||||
do_formula_enrichment=opts.do_formula_enrichment,
|
||||
do_picture_classification=opts.do_picture_classification,
|
||||
do_picture_description=opts.do_picture_description,
|
||||
generate_page_images=opts.generate_page_images,
|
||||
generate_picture_images=opts.generate_picture_images,
|
||||
images_scale=opts.images_scale,
|
||||
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,
|
||||
generate_page_images=options.generate_page_images,
|
||||
generate_picture_images=options.generate_picture_images,
|
||||
images_scale=options.images_scale,
|
||||
)
|
||||
|
||||
return DocumentConverter(
|
||||
return DoclingConverter(
|
||||
format_options={
|
||||
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_default_converter() -> DocumentConverter:
|
||||
def _get_default_converter() -> DoclingConverter:
|
||||
global _default_converter
|
||||
if _default_converter is None:
|
||||
_default_converter = build_converter()
|
||||
_default_converter = _build_docling_converter(ConversionOptions())
|
||||
return _default_converter
|
||||
|
||||
|
||||
def _select_converter(options: ConversionOptions) -> DoclingConverter:
|
||||
if options.is_default():
|
||||
return _get_default_converter()
|
||||
return _build_docling_converter(options)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||
"""Extract per-page element details with bounding boxes from Docling result.
|
||||
|
||||
Returns (pages, skipped_count) for transparent error reporting.
|
||||
"""
|
||||
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||
pages: dict[int, PageDetail] = {}
|
||||
document = doc_result.document
|
||||
skipped = 0
|
||||
|
|
@ -193,7 +151,6 @@ def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
|||
def _process_content_item(
|
||||
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail],
|
||||
) -> bool:
|
||||
"""Process a single content item and add it to the appropriate page."""
|
||||
if isinstance(item, GroupItem):
|
||||
return True
|
||||
|
||||
|
|
@ -204,9 +161,6 @@ def _process_content_item(
|
|||
try:
|
||||
page_no = prov.page_no
|
||||
if page_no not in pages:
|
||||
# Fallback: page was not found in document.pages (corrupted PDF or
|
||||
# Docling edge case). US Letter dimensions are used as a safe default.
|
||||
# This may cause slight bbox misalignment on non-Letter pages (e.g. A4).
|
||||
logger.warning(
|
||||
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
||||
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT,
|
||||
|
|
@ -242,49 +196,27 @@ def _process_content_item(
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main conversion entry point
|
||||
# Synchronous conversion (called via asyncio.to_thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _select_converter(options: ConversionOptions) -> DocumentConverter:
|
||||
"""Return the cached default converter or build a custom one."""
|
||||
if options.is_default():
|
||||
return get_default_converter()
|
||||
return build_converter(options)
|
||||
|
||||
|
||||
def _build_fallback_pages(doc, page_count: int) -> list[PageDetail]:
|
||||
"""Create empty PageDetail entries when extraction yields nothing."""
|
||||
return [
|
||||
PageDetail(
|
||||
page_number=i + 1,
|
||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
||||
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
|
||||
)
|
||||
for i in range(page_count)
|
||||
]
|
||||
|
||||
|
||||
def convert_document(
|
||||
file_path: str,
|
||||
options: ConversionOptions | None = None,
|
||||
) -> ConversionResult:
|
||||
"""Convert a document and return structured results.
|
||||
|
||||
This is the main entry point for document parsing. Runs synchronously
|
||||
(caller should use asyncio.to_thread for non-blocking execution).
|
||||
"""
|
||||
opts = options or ConversionOptions()
|
||||
|
||||
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
||||
with _converter_lock:
|
||||
conv = _select_converter(opts)
|
||||
conv = _select_converter(options)
|
||||
result = conv.convert(file_path)
|
||||
|
||||
doc = result.document
|
||||
page_count = len(doc.pages)
|
||||
pages_detail, skipped = extract_pages_detail(result)
|
||||
pages_detail, skipped = _extract_pages_detail(result)
|
||||
|
||||
if not pages_detail and page_count > 0:
|
||||
pages_detail = _build_fallback_pages(doc, page_count)
|
||||
pages_detail = [
|
||||
PageDetail(
|
||||
page_number=i + 1,
|
||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
||||
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
|
||||
)
|
||||
for i in range(page_count)
|
||||
]
|
||||
|
||||
if skipped > 0:
|
||||
logger.info("Parsed: %d pages, %d items skipped", page_count, skipped)
|
||||
|
|
@ -296,3 +228,16 @@ def convert_document(
|
|||
pages=pages_detail,
|
||||
skipped_items=skipped,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public adapter class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocalConverter:
|
||||
"""Adapter that runs Docling locally as a Python library."""
|
||||
|
||||
async def convert(
|
||||
self, file_path: str, options: ConversionOptions,
|
||||
) -> ConversionResult:
|
||||
return await asyncio.to_thread(_convert_sync, file_path, options)
|
||||
234
document-parser/infra/serve_converter.py
Normal file
234
document-parser/infra/serve_converter.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""Remote Docling Serve converter — delegates conversion via HTTP.
|
||||
|
||||
This adapter implements the DocumentConverter port by calling a remote
|
||||
Docling Serve instance's REST API (v1).
|
||||
|
||||
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
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from domain.value_objects import (
|
||||
ConversionOptions,
|
||||
ConversionResult,
|
||||
PageDetail,
|
||||
PageElement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_PREFIX = "/v1"
|
||||
_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:
|
||||
"""Adapter that delegates document conversion to a remote Docling Serve instance."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
):
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._timeout = timeout
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
if self._api_key:
|
||||
headers["X-Api-Key"] = self._api_key
|
||||
return headers
|
||||
|
||||
async def convert(
|
||||
self, file_path: str, options: ConversionOptions,
|
||||
) -> ConversionResult:
|
||||
"""Convert a document by uploading it to Docling Serve."""
|
||||
path = Path(file_path)
|
||||
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
|
||||
form_data = _build_form_data(options)
|
||||
url = f"{self._base_url}{_API_PREFIX}/convert/file"
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
with open(path, "rb") as f:
|
||||
response = await client.post(
|
||||
url,
|
||||
files={"files": (path.name, f, content_type)},
|
||||
data=form_data,
|
||||
headers=self._headers(),
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result_data = response.json()
|
||||
|
||||
return _parse_response(result_data)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Docling Serve is reachable."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base_url}/version",
|
||||
headers=self._headers(),
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
logger.warning("Docling Serve health check failed at %s", self._base_url, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _build_form_data(options: ConversionOptions) -> dict[str, str | list[str]]:
|
||||
"""Build form fields matching Docling Serve's multipart form contract.
|
||||
|
||||
Array fields (to_formats) are sent as lists — httpx encodes them as
|
||||
repeated form keys (to_formats=md&to_formats=html&to_formats=json).
|
||||
"""
|
||||
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:
|
||||
"""Parse Docling Serve v1 ConvertDocumentResponse into our domain ConversionResult."""
|
||||
document = data.get("document", {})
|
||||
|
||||
content_md = document.get("md_content") or ""
|
||||
content_html = document.get("html_content") or ""
|
||||
|
||||
# json_content contains the full DoclingDocument with pages, elements, bboxes
|
||||
json_content = document.get("json_content")
|
||||
if isinstance(json_content, str):
|
||||
try:
|
||||
json_content = json.loads(json_content)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse json_content as JSON, ignoring structured data")
|
||||
json_content = None
|
||||
|
||||
pages: list[PageDetail] = []
|
||||
if json_content:
|
||||
pages = _extract_pages_from_docling_document(json_content)
|
||||
|
||||
page_count = len(pages) if pages else 1
|
||||
|
||||
return ConversionResult(
|
||||
page_count=page_count,
|
||||
content_markdown=content_md,
|
||||
content_html=content_html,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
def _extract_pages_from_docling_document(doc: dict) -> list[PageDetail]:
|
||||
"""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] = {}
|
||||
|
||||
# Build page dimensions
|
||||
for page_key, page_data in doc.get("pages", {}).items():
|
||||
page_no = int(page_key)
|
||||
size = page_data.get("size", {})
|
||||
pages_dict[page_no] = PageDetail(
|
||||
page_number=page_no,
|
||||
width=size.get("width", 612.0),
|
||||
height=size.get("height", 792.0),
|
||||
)
|
||||
|
||||
# Process all element arrays
|
||||
for item in doc.get("texts", []):
|
||||
_add_element(item, pages_dict)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
|
||||
"""Add an element from a DoclingDocument array to the correct page."""
|
||||
label = item.get("label", "text")
|
||||
element_type = _LABEL_MAP.get(label, "text")
|
||||
content = item.get("text", "") or ""
|
||||
|
||||
for prov in item.get("prov", []):
|
||||
page_no = prov.get("page_no", 1)
|
||||
if page_no not in pages:
|
||||
pages[page_no] = PageDetail(
|
||||
page_number=page_no, width=612.0, height=792.0,
|
||||
)
|
||||
|
||||
bbox_data = prov.get("bbox", {})
|
||||
bbox = _extract_bbox(bbox_data, pages[page_no].height)
|
||||
|
||||
pages[page_no].elements.append(
|
||||
PageElement(type=element_type, bbox=bbox, content=content, level=0)
|
||||
)
|
||||
|
||||
|
||||
def _extract_bbox(bbox_data: dict, page_height: float) -> list[float]:
|
||||
"""Extract and normalize bbox to TOPLEFT [l, t, r, b] format."""
|
||||
if not isinstance(bbox_data, dict):
|
||||
return [0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
left = bbox_data.get("l", 0.0)
|
||||
top = bbox_data.get("t", 0.0)
|
||||
right = bbox_data.get("r", 0.0)
|
||||
bottom = bbox_data.get("b", 0.0)
|
||||
coord_origin = bbox_data.get("coord_origin", "TOPLEFT")
|
||||
|
||||
if coord_origin == "BOTTOMLEFT":
|
||||
# In BOTTOMLEFT: top has higher y, bottom has lower y
|
||||
# In TOPLEFT: flip both — new_top = page_height - old_top
|
||||
new_top = page_height - top
|
||||
new_bottom = page_height - bottom
|
||||
top, bottom = new_top, new_bottom
|
||||
|
||||
return [left, top, right, bottom]
|
||||
30
document-parser/infra/settings.py
Normal file
30
document-parser/infra/settings.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Centralized application settings — loaded from environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
conversion_engine: str = "local" # "local" or "remote"
|
||||
docling_serve_url: str = "http://localhost:5001"
|
||||
docling_serve_api_key: str | None = None
|
||||
conversion_timeout: int = 600
|
||||
upload_dir: str = "./uploads"
|
||||
db_path: str = "./data/docling_studio.db"
|
||||
cors_origins: list[str] = field(default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"])
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Settings:
|
||||
cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173")
|
||||
return cls(
|
||||
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
|
||||
docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"),
|
||||
docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"),
|
||||
conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "600")),
|
||||
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
|
||||
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
|
||||
cors_origins=[o.strip() for o in cors_raw.split(",")],
|
||||
)
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
"""Docling Studio — unified FastAPI backend.
|
||||
|
||||
Single service replacing both the Spring Boot backend and the document parser.
|
||||
Provides document management (upload, CRUD), analysis orchestration (async Docling
|
||||
processing), and PDF preview — all backed by SQLite.
|
||||
Single service providing document management (upload, CRUD), analysis
|
||||
orchestration (async Docling processing), and PDF preview — all backed
|
||||
by SQLite.
|
||||
|
||||
Conversion engine is selected via CONVERSION_ENGINE env var:
|
||||
- "local" → Docling runs in-process as a Python library (default)
|
||||
- "remote" → delegates to a Docling Serve instance via HTTP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -16,7 +19,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from api.analyses import router as analyses_router
|
||||
from api.documents import router as documents_router
|
||||
from infra.settings import Settings
|
||||
from persistence.database import init_db
|
||||
from services.analysis_service import AnalysisService
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
|
@ -24,12 +29,45 @@ logging.basicConfig(
|
|||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings & dependency wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
|
||||
def _build_converter():
|
||||
"""Build the converter adapter based on configuration."""
|
||||
if settings.conversion_engine == "remote":
|
||||
from infra.serve_converter import ServeConverter
|
||||
logger.info("Using remote Docling Serve at %s", settings.docling_serve_url)
|
||||
return ServeConverter(
|
||||
base_url=settings.docling_serve_url,
|
||||
api_key=settings.docling_serve_api_key,
|
||||
)
|
||||
else:
|
||||
from infra.local_converter import LocalConverter
|
||||
logger.info("Using local Docling converter")
|
||||
return LocalConverter()
|
||||
|
||||
|
||||
def _build_analysis_service() -> AnalysisService:
|
||||
converter = _build_converter()
|
||||
return AnalysisService(
|
||||
converter=converter,
|
||||
conversion_timeout=settings.conversion_timeout,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: initialize database. Shutdown: nothing special needed."""
|
||||
await init_db()
|
||||
logger.info("Docling Studio backend ready")
|
||||
app.state.analysis_service = _build_analysis_service()
|
||||
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
|
||||
yield
|
||||
|
||||
|
||||
|
|
@ -39,20 +77,14 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS — configurable via env, defaults for local dev
|
||||
allowed_origins = os.environ.get(
|
||||
"CORS_ORIGINS", "http://localhost:3000,http://localhost:5173"
|
||||
).split(",")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in allowed_origins],
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
# Mount routers
|
||||
app.include_router(documents_router)
|
||||
app.include_router(analyses_router)
|
||||
|
||||
|
|
@ -60,4 +92,4 @@ app.include_router(analyses_router)
|
|||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "engine": settings.conversion_engine}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from domain.models import AnalysisJob, AnalysisStatus
|
||||
from persistence.database import get_connection
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
"""Parse an ISO-format datetime string back into a datetime object."""
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
def _row_to_job(row) -> AnalysisJob:
|
||||
return AnalysisJob(
|
||||
id=row["id"],
|
||||
|
|
@ -15,9 +24,9 @@ def _row_to_job(row) -> AnalysisJob:
|
|||
content_html=row["content_html"],
|
||||
pages_json=row["pages_json"],
|
||||
error_message=row["error_message"],
|
||||
started_at=row["started_at"],
|
||||
completed_at=row["completed_at"],
|
||||
created_at=row["created_at"],
|
||||
started_at=_parse_dt(row["started_at"]),
|
||||
completed_at=_parse_dt(row["completed_at"]),
|
||||
created_at=_parse_dt(row["created_at"]) or datetime.now(),
|
||||
document_filename=row["filename"] if "filename" in row.keys() else None, # noqa: SIM118
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from domain.models import Document
|
||||
from persistence.database import get_connection
|
||||
|
||||
|
||||
def _row_to_document(row) -> Document:
|
||||
created = row["created_at"]
|
||||
if isinstance(created, str):
|
||||
created = datetime.fromisoformat(created)
|
||||
return Document(
|
||||
id=row["id"],
|
||||
filename=row["filename"],
|
||||
|
|
@ -14,7 +19,7 @@ def _row_to_document(row) -> Document:
|
|||
file_size=row["file_size"],
|
||||
page_count=row["page_count"],
|
||||
storage_path=row["storage_path"],
|
||||
created_at=row["created_at"],
|
||||
created_at=created,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ python-multipart>=0.0.12
|
|||
pdf2image>=1.17.0,<2.0.0
|
||||
pillow>=10.0.0,<11.0.0
|
||||
aiosqlite>=0.20.0,<1.0.0
|
||||
httpx>=0.27.0,<1.0.0
|
||||
|
|
|
|||
|
|
@ -1,108 +1,125 @@
|
|||
"""Analysis service — async document parsing orchestration."""
|
||||
"""Analysis service — async document parsing orchestration.
|
||||
|
||||
Uses an injected DocumentConverter (port) so the service is decoupled
|
||||
from the conversion implementation (local Docling lib vs remote Docling Serve).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from domain.models import AnalysisJob
|
||||
from domain.parsing import ConversionOptions, ConversionResult, convert_document
|
||||
from domain.value_objects import ConversionOptions, ConversionResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from domain.ports import DocumentConverter
|
||||
from persistence import analysis_repo, document_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum time (seconds) allowed for a single document conversion.
|
||||
CONVERSION_TIMEOUT = int(__import__("os").environ.get("CONVERSION_TIMEOUT", "600"))
|
||||
|
||||
class AnalysisService:
|
||||
"""Orchestrates document analysis using an injected converter."""
|
||||
|
||||
def __init__(self, converter: DocumentConverter, conversion_timeout: int = 600):
|
||||
self._converter = converter
|
||||
self._conversion_timeout = conversion_timeout
|
||||
|
||||
async def create(self, document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob:
|
||||
"""Create a new analysis job and launch background processing."""
|
||||
doc = await document_repo.find_by_id(document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"Document not found: {document_id}")
|
||||
|
||||
job = AnalysisJob(document_id=document_id)
|
||||
job.document_filename = doc.filename
|
||||
await analysis_repo.insert(job)
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
|
||||
)
|
||||
task.add_done_callback(functools.partial(_on_task_done, job_id=job.id))
|
||||
|
||||
return job
|
||||
|
||||
async def find_all(self) -> list[AnalysisJob]:
|
||||
return await analysis_repo.find_all()
|
||||
|
||||
async def find_by_id(self, job_id: str) -> AnalysisJob | None:
|
||||
return await analysis_repo.find_by_id(job_id)
|
||||
|
||||
async def delete(self, job_id: str) -> bool:
|
||||
return await analysis_repo.delete(job_id)
|
||||
|
||||
async def _run_analysis(
|
||||
self, job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
|
||||
) -> None:
|
||||
"""Background task: run conversion and update job status."""
|
||||
try:
|
||||
job = await analysis_repo.find_by_id(job_id)
|
||||
if not job:
|
||||
logger.error("Analysis job %s not found", job_id)
|
||||
return
|
||||
|
||||
job.mark_running()
|
||||
await analysis_repo.update_status(job)
|
||||
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
||||
|
||||
options = ConversionOptions(**(pipeline_options or {}))
|
||||
|
||||
result: ConversionResult = await asyncio.wait_for(
|
||||
self._converter.convert(file_path, options),
|
||||
timeout=self._conversion_timeout,
|
||||
)
|
||||
|
||||
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||
|
||||
job.mark_completed(
|
||||
markdown=result.content_markdown,
|
||||
html=result.content_html,
|
||||
pages_json=pages_json,
|
||||
)
|
||||
await analysis_repo.update_status(job)
|
||||
|
||||
if result.page_count:
|
||||
await document_repo.update_page_count(job.document_id, result.page_count)
|
||||
|
||||
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("Analysis timed out after %ds: %s", self._conversion_timeout, job_id)
|
||||
await _mark_failed(job_id, f"Conversion timed out after {self._conversion_timeout}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Analysis failed: %s", job_id)
|
||||
await _mark_failed(job_id, str(e))
|
||||
|
||||
|
||||
async def create(document_id: str, *, pipeline_options: dict | None = None) -> AnalysisJob:
|
||||
"""Create a new analysis job and launch background processing."""
|
||||
doc = await document_repo.find_by_id(document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"Document not found: {document_id}")
|
||||
|
||||
job = AnalysisJob(document_id=document_id)
|
||||
job.document_filename = doc.filename
|
||||
await analysis_repo.insert(job)
|
||||
|
||||
# Fire background task with error logging callback
|
||||
task = asyncio.create_task(
|
||||
_run_analysis(job.id, doc.storage_path, doc.filename, pipeline_options)
|
||||
)
|
||||
task.add_done_callback(_on_task_done)
|
||||
|
||||
return job
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _on_task_done(task: asyncio.Task) -> None:
|
||||
"""Log unhandled exceptions from background analysis tasks."""
|
||||
def _on_task_done(task: asyncio.Task, *, job_id: str) -> None:
|
||||
"""Log unhandled exceptions from background analysis tasks and mark job as FAILED."""
|
||||
if task.cancelled():
|
||||
logger.warning("Analysis task was cancelled")
|
||||
logger.warning("Analysis task was cancelled: %s", job_id)
|
||||
_schedule_mark_failed(job_id, "Task was cancelled")
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc:
|
||||
logger.error("Unhandled exception in analysis task: %s", exc, exc_info=True)
|
||||
logger.error("Unhandled exception in analysis task %s: %s", job_id, exc, exc_info=True)
|
||||
_schedule_mark_failed(job_id, str(exc))
|
||||
|
||||
|
||||
async def find_all() -> list[AnalysisJob]:
|
||||
return await analysis_repo.find_all()
|
||||
|
||||
|
||||
async def find_by_id(job_id: str) -> AnalysisJob | None:
|
||||
return await analysis_repo.find_by_id(job_id)
|
||||
|
||||
|
||||
async def delete(job_id: str) -> bool:
|
||||
return await analysis_repo.delete(job_id)
|
||||
|
||||
|
||||
async def _run_analysis(
|
||||
job_id: str, file_path: str, filename: str, pipeline_options: dict | None = None,
|
||||
) -> None:
|
||||
"""Background task: run Docling conversion and update job status."""
|
||||
try:
|
||||
job = await analysis_repo.find_by_id(job_id)
|
||||
if not job:
|
||||
logger.error("Analysis job %s not found", job_id)
|
||||
return
|
||||
|
||||
job.mark_running()
|
||||
await analysis_repo.update_status(job)
|
||||
logger.info("Analysis started: %s (file: %s)", job_id, filename)
|
||||
|
||||
# Build conversion options from pipeline dict
|
||||
options = ConversionOptions(**(pipeline_options or {}))
|
||||
|
||||
# Run blocking Docling conversion in a thread with timeout
|
||||
result: ConversionResult = await asyncio.wait_for(
|
||||
asyncio.to_thread(convert_document, file_path, options),
|
||||
timeout=CONVERSION_TIMEOUT,
|
||||
)
|
||||
|
||||
pages_json = json.dumps([asdict(p) for p in result.pages])
|
||||
|
||||
job.mark_completed(
|
||||
markdown=result.content_markdown,
|
||||
html=result.content_html,
|
||||
pages_json=pages_json,
|
||||
)
|
||||
await analysis_repo.update_status(job)
|
||||
|
||||
# Update document page count if available
|
||||
if result.page_count:
|
||||
await document_repo.update_page_count(job.document_id, result.page_count)
|
||||
|
||||
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("Analysis timed out after %ds: %s", CONVERSION_TIMEOUT, job_id)
|
||||
await _mark_failed(job_id, f"Conversion timed out after {CONVERSION_TIMEOUT}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Analysis failed: %s", job_id)
|
||||
await _mark_failed(job_id, str(e))
|
||||
def _schedule_mark_failed(job_id: str, error: str) -> None:
|
||||
"""Schedule _mark_failed as a tracked background task."""
|
||||
t = asyncio.ensure_future(_mark_failed(job_id, error))
|
||||
_background_tasks.add(t)
|
||||
t.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
async def _mark_failed(job_id: str, error: str) -> None:
|
||||
|
|
|
|||
|
|
@ -70,10 +70,14 @@ async def delete(doc_id: str) -> bool:
|
|||
# Delete associated analyses first (cascade)
|
||||
await analysis_repo.delete_by_document(doc_id)
|
||||
|
||||
# Delete file from disk
|
||||
# Delete file from disk (only if inside UPLOAD_DIR)
|
||||
try:
|
||||
if os.path.exists(doc.storage_path):
|
||||
os.unlink(doc.storage_path)
|
||||
real_path = os.path.realpath(doc.storage_path)
|
||||
real_upload_dir = os.path.realpath(UPLOAD_DIR)
|
||||
if real_path.startswith(real_upload_dir + os.sep) and os.path.exists(real_path):
|
||||
os.unlink(real_path)
|
||||
elif os.path.exists(doc.storage_path):
|
||||
logger.warning("Refused to delete file outside upload dir: %s", doc.storage_path)
|
||||
except OSError:
|
||||
logger.warning("Could not delete file: %s", doc.storage_path)
|
||||
|
||||
|
|
|
|||
71
document-parser/tests/test_analysis_service.py
Normal file
71
document-parser/tests/test_analysis_service.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Tests for AnalysisService — focus on _on_task_done callback (bug #1 fix)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.analysis_service import _on_task_done
|
||||
|
||||
|
||||
class TestOnTaskDone:
|
||||
"""Bug #1: _on_task_done must call _mark_failed when the task raises."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_marks_job_failed(self):
|
||||
"""When a background task raises, the job should be marked FAILED."""
|
||||
job_id = "job-123"
|
||||
|
||||
# Create a task that raises
|
||||
async def failing_task():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
task = asyncio.create_task(failing_task())
|
||||
await asyncio.sleep(0) # let the task fail
|
||||
|
||||
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
|
||||
_on_task_done(task, job_id=job_id)
|
||||
# ensure_future schedules it; give the event loop a tick
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_mark.assert_called_once_with(job_id, "boom")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_task_marks_job_failed(self):
|
||||
"""When a background task is cancelled, the job should be marked FAILED."""
|
||||
job_id = "job-456"
|
||||
|
||||
async def slow_task():
|
||||
await asyncio.sleep(999)
|
||||
|
||||
import contextlib
|
||||
|
||||
task = asyncio.create_task(slow_task())
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
|
||||
_on_task_done(task, job_id=job_id)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_mark.assert_called_once_with(job_id, "Task was cancelled")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_task_does_not_mark_failed(self):
|
||||
"""When a background task succeeds, _mark_failed should not be called."""
|
||||
job_id = "job-789"
|
||||
|
||||
async def ok_task():
|
||||
return "done"
|
||||
|
||||
task = asyncio.create_task(ok_task())
|
||||
await task
|
||||
|
||||
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
|
||||
_on_task_done(task, job_id=job_id)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
mock_mark.assert_not_called()
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for FastAPI API endpoints using TestClient."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -14,11 +14,23 @@ def client():
|
|||
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:
|
||||
def test_health(self, client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "engine" in data
|
||||
|
||||
|
||||
class TestDocumentEndpoints:
|
||||
|
|
@ -102,11 +114,10 @@ class TestDocumentEndpoints:
|
|||
|
||||
|
||||
class TestAnalysisEndpoints:
|
||||
@patch("services.analysis_service.find_all", new_callable=AsyncMock)
|
||||
def test_list_analyses(self, mock_find_all, client):
|
||||
mock_find_all.return_value = [
|
||||
def test_list_analyses(self, client, mock_analysis_service):
|
||||
mock_analysis_service.find_all = AsyncMock(return_value=[
|
||||
AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
|
||||
]
|
||||
])
|
||||
|
||||
resp = client.get("/api/analyses")
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -117,11 +128,10 @@ class TestAnalysisEndpoints:
|
|||
assert data[0]["documentFilename"] == "test.pdf"
|
||||
assert data[0]["status"] == "PENDING"
|
||||
|
||||
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
|
||||
def test_get_analysis(self, mock_find, client):
|
||||
def test_get_analysis(self, client, mock_analysis_service):
|
||||
job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
|
||||
job.mark_running()
|
||||
mock_find.return_value = job
|
||||
mock_analysis_service.find_by_id = AsyncMock(return_value=job)
|
||||
|
||||
resp = client.get("/api/analyses/j1")
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -129,31 +139,28 @@ class TestAnalysisEndpoints:
|
|||
assert data["status"] == "RUNNING"
|
||||
assert data["startedAt"] is not None
|
||||
|
||||
@patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
|
||||
def test_get_analysis_not_found(self, mock_find, client):
|
||||
mock_find.return_value = None
|
||||
def test_get_analysis_not_found(self, client, mock_analysis_service):
|
||||
mock_analysis_service.find_by_id = AsyncMock(return_value=None)
|
||||
|
||||
resp = client.get("/api/analyses/missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis(self, mock_create, client):
|
||||
mock_create.return_value = AnalysisJob(
|
||||
def test_create_analysis(self, client, mock_analysis_service):
|
||||
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
|
||||
id="j1", document_id="d1", document_filename="test.pdf",
|
||||
)
|
||||
))
|
||||
|
||||
resp = client.post("/api/analyses", json={"documentId": "d1"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "j1"
|
||||
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("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis_with_pipeline_options(self, mock_create, client):
|
||||
mock_create.return_value = AnalysisJob(
|
||||
def test_create_analysis_with_pipeline_options(self, client, mock_analysis_service):
|
||||
mock_analysis_service.create = AsyncMock(return_value=AnalysisJob(
|
||||
id="j2", document_id="d1", document_filename="test.pdf",
|
||||
)
|
||||
))
|
||||
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
|
|
@ -174,7 +181,7 @@ class TestAnalysisEndpoints:
|
|||
data = resp.json()
|
||||
assert data["id"] == "j2"
|
||||
|
||||
call_kwargs = mock_create.call_args
|
||||
call_kwargs = mock_analysis_service.create.call_args
|
||||
opts = call_kwargs.kwargs["pipeline_options"]
|
||||
assert opts["do_ocr"] is False
|
||||
assert opts["table_mode"] == "fast"
|
||||
|
|
@ -182,12 +189,11 @@ class TestAnalysisEndpoints:
|
|||
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):
|
||||
def test_create_analysis_with_partial_pipeline_options(self, client, mock_analysis_service):
|
||||
"""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",
|
||||
)
|
||||
))
|
||||
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
|
|
@ -195,38 +201,35 @@ class TestAnalysisEndpoints:
|
|||
})
|
||||
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
|
||||
# Defaults
|
||||
assert opts["do_table_structure"] is True
|
||||
assert opts["table_mode"] == "accurate"
|
||||
assert opts["do_code_enrichment"] is False
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_create_analysis_document_not_found(self, mock_create, client):
|
||||
mock_create.side_effect = ValueError("Document not found: d99")
|
||||
def test_create_analysis_document_not_found(self, client, mock_analysis_service):
|
||||
mock_analysis_service.create = AsyncMock(side_effect=ValueError("Document not found: d99"))
|
||||
|
||||
resp = client.post("/api/analyses", json={"documentId": "d99"})
|
||||
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": ""})
|
||||
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": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
@patch("services.analysis_service.delete", new_callable=AsyncMock)
|
||||
def test_delete_analysis(self, mock_delete, client):
|
||||
mock_delete.return_value = True
|
||||
def test_delete_analysis(self, client, mock_analysis_service):
|
||||
mock_analysis_service.delete = AsyncMock(return_value=True)
|
||||
|
||||
resp = client.delete("/api/analyses/j1")
|
||||
assert resp.status_code == 204
|
||||
|
||||
@patch("services.analysis_service.delete", new_callable=AsyncMock)
|
||||
def test_delete_analysis_not_found(self, mock_delete, client):
|
||||
mock_delete.return_value = False
|
||||
def test_delete_analysis_not_found(self, client, mock_analysis_service):
|
||||
mock_analysis_service.delete = AsyncMock(return_value=False)
|
||||
|
||||
resp = client.delete("/api/analyses/missing")
|
||||
assert resp.status_code == 404
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ from docling.datamodel.pipeline_options import (
|
|||
TableFormerMode,
|
||||
)
|
||||
|
||||
from domain.parsing import (
|
||||
ConversionOptions,
|
||||
build_converter,
|
||||
convert_document,
|
||||
from domain.value_objects import ConversionOptions
|
||||
from infra.local_converter import (
|
||||
_build_docling_converter as build_converter,
|
||||
)
|
||||
from infra.local_converter import (
|
||||
_convert_sync as convert_document,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -30,7 +32,7 @@ class TestBuildConverter:
|
|||
return fmt_opt.pipeline_options
|
||||
|
||||
def test_defaults(self):
|
||||
conv = build_converter()
|
||||
conv = build_converter(ConversionOptions())
|
||||
opts = self._get_pipeline_options(conv)
|
||||
assert opts.do_ocr is True
|
||||
assert opts.do_table_structure is True
|
||||
|
|
@ -131,8 +133,8 @@ class TestBuildConverter:
|
|||
class TestConvertDocumentRouting:
|
||||
"""Verify convert_document uses default converter for default opts, custom otherwise."""
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_default_converter_with_all_defaults(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -143,13 +145,13 @@ class TestConvertDocumentRouting:
|
|||
mock_conv.convert.return_value = mock_result
|
||||
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_build.assert_not_called()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_ocr_disabled(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -165,8 +167,8 @@ class TestConvertDocumentRouting:
|
|||
mock_build.assert_called_once()
|
||||
mock_get_default.assert_not_called()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_table_mode_fast(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -182,8 +184,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_code_enrichment_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -199,8 +201,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_formula_enrichment_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -215,8 +217,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_picture_options_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -231,8 +233,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_generate_images_on(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -247,8 +249,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once()
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_uses_custom_converter_when_images_scale_changed(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -264,8 +266,8 @@ class TestConvertDocumentRouting:
|
|||
|
||||
mock_build.assert_called_once_with(opts)
|
||||
|
||||
@patch("domain.parsing.get_default_converter")
|
||||
@patch("domain.parsing.build_converter")
|
||||
@patch("infra.local_converter._get_default_converter")
|
||||
@patch("infra.local_converter._build_docling_converter")
|
||||
def test_forwards_all_options_to_build_converter(self, mock_build, mock_get_default):
|
||||
mock_conv = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
|
|
@ -312,25 +314,21 @@ class TestServiceForwardsPipelineOptions:
|
|||
|
||||
@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,
|
||||
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()
|
||||
# Patch _run_analysis as a coroutine that we can inspect
|
||||
mock_run.return_value = None
|
||||
|
||||
from services import analysis_service
|
||||
mock_converter = AsyncMock()
|
||||
from services.analysis_service import AnalysisService
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
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(...)
|
||||
await svc.create("d1", pipeline_options=opts)
|
||||
mock_task.assert_called_once()
|
||||
|
||||
@patch("services.analysis_service.document_repo")
|
||||
|
|
@ -342,32 +340,36 @@ class TestServiceForwardsPipelineOptions:
|
|||
mock_doc_repo.find_by_id = AsyncMock(return_value=mock_doc)
|
||||
mock_analysis_repo.insert = AsyncMock()
|
||||
|
||||
from services import analysis_service
|
||||
mock_converter = AsyncMock()
|
||||
from services.analysis_service import AnalysisService
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
with patch("services.analysis_service.asyncio.create_task") as mock_task:
|
||||
await analysis_service.create("d1")
|
||||
await svc.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,
|
||||
self, mock_doc_repo, mock_analysis_repo, mock_job,
|
||||
):
|
||||
from domain.parsing import ConversionResult, PageDetail
|
||||
from domain.value_objects 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(
|
||||
|
||||
mock_converter = AsyncMock()
|
||||
mock_converter.convert.return_value = ConversionResult(
|
||||
page_count=1,
|
||||
content_markdown="# Test",
|
||||
content_html="<h1>Test</h1>",
|
||||
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
||||
)
|
||||
|
||||
from services.analysis_service import _run_analysis
|
||||
from services.analysis_service import AnalysisService
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
opts = {
|
||||
"do_ocr": False,
|
||||
|
|
@ -381,10 +383,10 @@ class TestServiceForwardsPipelineOptions:
|
|||
"images_scale": 2.0,
|
||||
}
|
||||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", opts)
|
||||
|
||||
mock_convert.assert_called_once()
|
||||
call_args = mock_convert.call_args
|
||||
mock_converter.convert.assert_called_once()
|
||||
call_args = mock_converter.convert.call_args
|
||||
assert call_args[0][0] == "/tmp/test.pdf"
|
||||
conv_opts = call_args[0][1]
|
||||
assert conv_opts.do_ocr is False
|
||||
|
|
@ -395,47 +397,50 @@ class TestServiceForwardsPipelineOptions:
|
|||
|
||||
@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,
|
||||
self, mock_doc_repo, mock_analysis_repo, mock_job,
|
||||
):
|
||||
from domain.parsing import ConversionResult, PageDetail
|
||||
from domain.value_objects 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(
|
||||
|
||||
mock_converter = AsyncMock()
|
||||
mock_converter.convert.return_value = ConversionResult(
|
||||
page_count=1,
|
||||
content_markdown="",
|
||||
content_html="",
|
||||
pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
|
||||
)
|
||||
|
||||
from services.analysis_service import _run_analysis
|
||||
from services.analysis_service import AnalysisService
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||
await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
|
||||
|
||||
# Called with file_path and default ConversionOptions
|
||||
mock_convert.assert_called_once()
|
||||
call_args = mock_convert.call_args
|
||||
mock_converter.convert.assert_called_once()
|
||||
call_args = mock_converter.convert.call_args
|
||||
assert call_args[0][0] == "/tmp/test.pdf"
|
||||
assert call_args[0][1] == ConversionOptions()
|
||||
|
||||
@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,
|
||||
self, 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
|
||||
mock_converter = AsyncMock()
|
||||
mock_converter.convert.side_effect = RuntimeError("Docling crashed")
|
||||
|
||||
await _run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"do_ocr": False})
|
||||
from services.analysis_service import AnalysisService
|
||||
svc = AnalysisService(converter=mock_converter)
|
||||
|
||||
await svc._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
|
||||
|
|
@ -458,26 +463,35 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
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):
|
||||
@pytest.fixture
|
||||
def mock_svc(self, client):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from main import app
|
||||
mock = MagicMock()
|
||||
original = getattr(app.state, "analysis_service", None)
|
||||
app.state.analysis_service = mock
|
||||
yield mock
|
||||
app.state.analysis_service = original
|
||||
|
||||
def test_no_pipeline_options_sends_none(self, client, mock_svc):
|
||||
from domain.models import AnalysisJob
|
||||
mock_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"})
|
||||
|
||||
mock_create.assert_called_once_with("d1", pipeline_options=None)
|
||||
mock_svc.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):
|
||||
def test_empty_pipeline_options_object_uses_defaults(self, client, mock_svc):
|
||||
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",
|
||||
"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_table_structure"] is True
|
||||
assert opts["table_mode"] == "accurate"
|
||||
|
|
@ -485,20 +499,18 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
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):
|
||||
def test_partial_pipeline_options_merges_with_defaults(self, client, mock_svc):
|
||||
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",
|
||||
"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["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
|
||||
|
|
@ -508,10 +520,9 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
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):
|
||||
def test_full_pipeline_options(self, client, mock_svc):
|
||||
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 = {
|
||||
"documentId": "d1",
|
||||
|
|
@ -532,25 +543,22 @@ class TestAnalysisEndpointPipelineOptions:
|
|||
resp = client.post("/api/analyses", json=payload)
|
||||
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"]
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_invalid_pipeline_option_type_rejected(self, mock_create, client):
|
||||
def test_invalid_pipeline_option_type_rejected(self, client, mock_svc):
|
||||
resp = client.post("/api/analyses", json={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": "not-a-bool"},
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("services.analysis_service.create", new_callable=AsyncMock)
|
||||
def test_unknown_pipeline_option_ignored(self, mock_create, client):
|
||||
def test_unknown_pipeline_option_ignored(self, client, mock_svc):
|
||||
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={
|
||||
"documentId": "d1",
|
||||
"pipelineOptions": {"do_ocr": True, "unknown_field": True},
|
||||
})
|
||||
# Pydantic ignores extra fields by default
|
||||
assert resp.status_code == 200
|
||||
|
|
|
|||
387
document-parser/tests/test_serve_converter.py
Normal file
387
document-parser/tests/test_serve_converter.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"""Tests for the ServeConverter adapter (Docling Serve HTTP client)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from domain.value_objects import ConversionOptions, ConversionResult
|
||||
from infra.serve_converter import (
|
||||
ServeConverter,
|
||||
_build_form_data,
|
||||
_extract_bbox,
|
||||
_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 set(data["to_formats"]) == {"md", "html", "json"}
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseResponse:
|
||||
def test_minimal_response(self):
|
||||
data = {
|
||||
"document": {
|
||||
"md_content": "# Hello",
|
||||
"html_content": "<h1>Hello</h1>",
|
||||
"json_content": {
|
||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
||||
"texts": [],
|
||||
"tables": [],
|
||||
"pictures": [],
|
||||
},
|
||||
}
|
||||
}
|
||||
result = _parse_response(data)
|
||||
assert isinstance(result, ConversionResult)
|
||||
assert result.content_markdown == "# Hello"
|
||||
assert result.content_html == "<h1>Hello</h1>"
|
||||
assert result.page_count == 1
|
||||
assert result.pages[0].width == 612.0
|
||||
|
||||
def test_response_with_elements(self):
|
||||
data = {
|
||||
"document": {
|
||||
"md_content": "# Title\nText",
|
||||
"html_content": "<h1>Title</h1><p>Text</p>",
|
||||
"json_content": {
|
||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.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 len(result.pages[0].elements) == 2
|
||||
assert result.pages[0].elements[0].type == "title"
|
||||
assert result.pages[0].elements[0].content == "Title"
|
||||
assert result.pages[0].elements[0].bbox == [10, 20, 200, 40]
|
||||
assert result.pages[0].elements[1].type == "text"
|
||||
|
||||
def test_multi_page(self):
|
||||
data = {
|
||||
"document": {
|
||||
"md_content": "",
|
||||
"html_content": "",
|
||||
"json_content": {
|
||||
"pages": {
|
||||
"1": {"size": {"width": 612.0, "height": 792.0}},
|
||||
"2": {"size": {"width": 595.0, "height": 842.0}},
|
||||
},
|
||||
"texts": [], "tables": [], "pictures": [],
|
||||
},
|
||||
}
|
||||
}
|
||||
result = _parse_response(data)
|
||||
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_json_content_malformed_string_falls_back(self):
|
||||
"""Bug #5: malformed JSON string in json_content must not crash."""
|
||||
data = {
|
||||
"document": {
|
||||
"md_content": "# Hello",
|
||||
"html_content": "<h1>Hello</h1>",
|
||||
"json_content": "NOT VALID JSON {{{",
|
||||
}
|
||||
}
|
||||
result = _parse_response(data)
|
||||
assert isinstance(result, ConversionResult)
|
||||
assert result.content_markdown == "# Hello"
|
||||
assert result.pages == []
|
||||
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 — bbox extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractBbox:
|
||||
def test_topleft_passthrough(self):
|
||||
bbox = _extract_bbox({"l": 10, "t": 20, "r": 100, "b": 50, "coord_origin": "TOPLEFT"}, 792.0)
|
||||
assert bbox == [10, 20, 100, 50]
|
||||
|
||||
def test_bottomleft_conversion(self):
|
||||
# In BOTTOMLEFT: t (top of box) has higher y than b (bottom of box)
|
||||
bbox = _extract_bbox({"l": 10, "t": 772, "r": 100, "b": 742, "coord_origin": "BOTTOMLEFT"}, 792.0)
|
||||
# new_top = 792 - 772 = 20, new_bottom = 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — ServeConverter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestServeConverter:
|
||||
def test_headers_with_api_key(self):
|
||||
conv = ServeConverter(base_url="http://localhost:5001", api_key="secret")
|
||||
assert conv._headers() == {"X-Api-Key": "secret"}
|
||||
|
||||
def test_headers_without_api_key(self):
|
||||
conv = ServeConverter(base_url="http://localhost:5001")
|
||||
assert conv._headers() == {}
|
||||
|
||||
def test_base_url_trailing_slash_stripped(self):
|
||||
conv = ServeConverter(base_url="http://localhost:5001/")
|
||||
assert conv._base_url == "http://localhost:5001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — HTTP calls (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestServeConverterConvert:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_conversion(self, tmp_path):
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"%PDF-1.4 fake content")
|
||||
|
||||
serve_response = {
|
||||
"document": {
|
||||
"md_content": "# Converted",
|
||||
"html_content": "<h1>Converted</h1>",
|
||||
"json_content": {
|
||||
"pages": {"1": {"size": {"width": 612.0, "height": 792.0}}},
|
||||
"texts": [
|
||||
{"label": "title", "text": "Converted", "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}]},
|
||||
],
|
||||
"tables": [],
|
||||
"pictures": [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = serve_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
conv = ServeConverter(base_url="http://localhost:5001", api_key="test-key")
|
||||
|
||||
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await conv.convert(str(test_file), ConversionOptions())
|
||||
|
||||
assert isinstance(result, ConversionResult)
|
||||
assert result.content_markdown == "# Converted"
|
||||
assert result.page_count == 1
|
||||
assert len(result.pages[0].elements) == 1
|
||||
assert result.pages[0].elements[0].type == "title"
|
||||
|
||||
# Verify form fields sent as dict with list for repeated keys
|
||||
call_kwargs = mock_client.post.call_args
|
||||
sent_data = call_kwargs.kwargs.get("data", {})
|
||||
assert sent_data["do_ocr"] == "true"
|
||||
assert set(sent_data["to_formats"]) == {"md", "html", "json"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_raises(self, tmp_path):
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"%PDF-1.4 fake content")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=MagicMock(status_code=500),
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
conv = ServeConverter(base_url="http://localhost:5001")
|
||||
|
||||
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client), \
|
||||
pytest.raises(httpx.HTTPStatusError):
|
||||
await conv.convert(str(test_file), ConversionOptions())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_success(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
conv = ServeConverter(base_url="http://localhost:5001")
|
||||
|
||||
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client):
|
||||
assert await conv.health_check() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_failure(self):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
conv = ServeConverter(base_url="http://localhost:5001")
|
||||
|
||||
with patch("infra.serve_converter.httpx.AsyncClient", return_value=mock_client):
|
||||
assert await conv.health_check() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration — converter wiring in main.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConverterWiring:
|
||||
def test_local_engine_builds_local_converter(self):
|
||||
from infra.local_converter import LocalConverter
|
||||
from infra.settings import Settings
|
||||
|
||||
with patch("main.settings", Settings(conversion_engine="local")):
|
||||
from main import _build_converter
|
||||
converter = _build_converter()
|
||||
assert isinstance(converter, LocalConverter)
|
||||
|
||||
def test_remote_engine_builds_serve_converter(self):
|
||||
from infra.settings import Settings
|
||||
|
||||
with patch("main.settings", Settings(conversion_engine="remote", docling_serve_url="http://serve:5001")):
|
||||
from main import _build_converter
|
||||
converter = _build_converter()
|
||||
assert isinstance(converter, ServeConverter)
|
||||
assert converter._base_url == "http://serve:5001"
|
||||
|
||||
def test_remote_engine_passes_api_key(self):
|
||||
from infra.settings import Settings
|
||||
|
||||
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()
|
||||
assert isinstance(converter, ServeConverter)
|
||||
assert converter._api_key == "my-key"
|
||||
|
|
@ -9,6 +9,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
const running = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollingTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
const currentPages = computed<Page[]>(() => {
|
||||
if (!currentAnalysis.value?.pagesJson) return []
|
||||
|
|
@ -72,6 +74,13 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
running.value = false
|
||||
}
|
||||
}, 2000)
|
||||
pollingTimeout.value = setTimeout(() => {
|
||||
if (pollingInterval.value) {
|
||||
error.value = 'Analysis timed out'
|
||||
stopPolling()
|
||||
running.value = false
|
||||
}
|
||||
}, MAX_POLLING_DURATION)
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
|
|
@ -79,6 +88,10 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||
clearInterval(pollingInterval.value)
|
||||
pollingInterval.value = null
|
||||
}
|
||||
if (pollingTimeout.value) {
|
||||
clearTimeout(pollingTimeout.value)
|
||||
pollingTimeout.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ref } from 'vue'
|
|||
import type { Document } from '../../shared/types'
|
||||
import * as api from './api'
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||
|
||||
export const useDocumentStore = defineStore('document', () => {
|
||||
const documents = ref<Document[]>([])
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
|
@ -24,6 +26,10 @@ export const useDocumentStore = defineStore('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
|
||||
error.value = null
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
</svg>
|
||||
<span class="upload-text">{{ t('upload.drop') }}</span>
|
||||
<span class="upload-hint">{{ t('upload.maxSize') }}</span>
|
||||
<span v-if="store.error" class="upload-error">{{ store.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -42,8 +43,13 @@ async function onFileSelect(e: Event) {
|
|||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
try {
|
||||
store.clearError()
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
} catch {
|
||||
// error is already set in store.upload
|
||||
}
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
|
@ -51,9 +57,14 @@ async function onFileSelect(e: Event) {
|
|||
async function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file && file.type === 'application/pdf') {
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
if (file && (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'))) {
|
||||
try {
|
||||
store.clearError()
|
||||
const doc = await store.upload(file)
|
||||
if (doc) emit('uploaded', doc.id)
|
||||
} catch {
|
||||
// error is already set in store.upload
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -103,6 +114,12 @@ async function onDrop(e: DragEvent) {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
font-size: 13px;
|
||||
color: var(--error, #e53e3e);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,29 @@ import { defineStore } from 'pinia'
|
|||
import { ref, watch, watchEffect } from 'vue'
|
||||
import type { Locale, Theme } from '../../shared/types'
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// localStorage unavailable (private browsing, quota exceeded)
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const apiUrl = ref('http://localhost:8000')
|
||||
const theme = ref<Theme>((localStorage.getItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((localStorage.getItem('docling-locale') as Locale) || 'fr')
|
||||
const theme = ref<Theme>((safeGetItem('docling-theme') as Theme) || 'dark')
|
||||
const locale = ref<Locale>((safeGetItem('docling-locale') as Locale) || 'fr')
|
||||
|
||||
watch(theme, (v) => localStorage.setItem('docling-theme', v))
|
||||
watch(locale, (v) => localStorage.setItem('docling-locale', v))
|
||||
watch(theme, (v) => safeSetItem('docling-theme', v))
|
||||
watch(locale, (v) => safeSetItem('docling-locale', v))
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('light', theme.value === 'light')
|
||||
|
|
|
|||
Loading…
Reference in a new issue