diff --git a/docker-compose.yml b/docker-compose.yml index c800876cc..d9c6088f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,19 @@ services: profiles: - unstructured + # docling-serve: OCR-strong document parsing (photographed / scanned / + # handwritten text). Opt-in via the "docling" profile. Point the MCP server at + # it with ENABLE_DOCLING=true + DOCLING_API_URL=http://docling:5001 (and/or + # DOCUMENT_OCR_PROVIDER=docling for scanned-PDF OCR). CPU image; the first run + # downloads models and CPU OCR is slow. + docling: + image: quay.io/docling-project/docling-serve:latest@sha256:56189f190892cc03a4a5c10ba9e56c421ac2524554aa2d36f0f4bb6564132524 + restart: always + ports: + - 127.0.0.1:5001:5001 + profiles: + - docling + mcp: build: . restart: always diff --git a/docs/ADR-031-docling-document-parsing-backend.md b/docs/ADR-031-docling-document-parsing-backend.md new file mode 100644 index 000000000..e8524fd7f --- /dev/null +++ b/docs/ADR-031-docling-document-parsing-backend.md @@ -0,0 +1,97 @@ +# ADR-031: Docling document-parsing backend (docling-serve) + +## Status + +Accepted — 2026-07-01 + +## Context + +Files read via WebDAV (`nc_webdav_read_file`) are returned as base64, decoded +UTF-8 text, or — when document processing is enabled — text extracted by the +pluggable processor registry (`document_processors/`). The registered HTTP OCR +option, `unstructured`, does poorly on photographed, scanned and especially +**handwritten** documents. + +[docling](https://github.com/docling-project/docling) has substantially stronger +OCR for exactly that content and is deployable as a standalone HTTP service, +[docling-serve](https://github.com/docling-project/docling-serve). We want to use +external docling-serve instances (per `DOCLING_API_URL`) alongside `unstructured` +without adding ML dependencies to the MCP server image and without regressing the +existing PDF pipeline. + +Relevant existing architecture (reused, not replaced): + +- The registry has **two independent routing paths**: `find_processor()` picks by + **priority** for images/non-PDF; `_process_pdf()` runs a tiered pipeline + (`fast → structured → ocr`) for PDFs, with the `ocr` tier gated on + `document_ocr_enabled`. +- `classifier.classify_from_text()` already **auto-detects a missing/unusable PDF + text layer** (scanned → recommend `ocr`), so born-digital PDFs stay on the cheap + local tiers and only genuine scans reach OCR. +- The `ocr` tier (`OcrProcessor`) already has **pluggable backends** (`_OcrBackend`: + gateway, Mistral) selected by `document_ocr_provider`, returning + `(text, page_boundaries, block_spans)`. +- `registry.process(processor_name=...)` already supports a **forced processor** + that bypasses tiering and MIME auto-selection. + +## Decision + +Add docling at three touchpoints, sharing one docling-serve HTTP client +(`document_processors/docling_serve.py`). `POST /v1/convert/file` (synchronous +multipart) is the client; `GET /health` is the probe. + +1. **Images → docling (automatic).** A standalone `DoclingProcessor` + (`supported_mime_types` = images only, `tier="fast"`) is registered at + **priority 20** (above `unstructured`'s 10) in `initialize_document_processors()` + when `ENABLE_DOCLING=true` **and** `DOCLING_API_URL` is set. Images therefore + always route to docling when enabled. Gated only by `ENABLE_DOCUMENT_PROCESSING` + + `ENABLE_DOCLING` (the image/`find_processor` path has no OCR gate). + +2. **Scanned PDFs → docling (automatic, opt-in).** A `_DoclingServeBackend` + (`_OcrBackend`) is added to `ocr.py` and selected by + `DOCUMENT_OCR_PROVIDER=docling`. With `DOCUMENT_OCR_ENABLED=true`, PDFs the + classifier flags as scanned/no-text-layer escalate to the OCR tier and are + transcribed by docling. It requests `to_formats=json,md` and reconstructs + per-page `page_boundaries` by grouping `DoclingDocument.texts[].prov[].page_no`, + falling back to a single whole-text page when provenance is absent. + +3. **Text-layer PDFs → docling (on demand).** `nc_webdav_read_file` gains a + `force_processor` argument threaded through `parse_document(processor_name=...)` + to the registry's forced path. `force_processor="docling"` re-parses any file + with docling even when it has a usable text layer — for tables/figures the text + layer misses. `DoclingProcessor.process()` handles PDFs (deriving + `from_formats` from the MIME type) even though PDFs are excluded from its + `supported_mime_types` (the forced path ignores `supports()`). An unknown/ + unconfigured processor name raises a `ToolError` with the available names. + +### Key design points + +- **Images-only `supported_mime_types` + `tier="fast"`.** Keeps docling out of the + automatic PDF tier selection (`_pdf_processor_for_tier` matches on tier **and** + `supports("application/pdf")`), so enabling docling never reroutes every PDF + through it or collides with the existing `ocr` tier. PDFs reach docling only via + touchpoint 2 (OCR backend) or touchpoint 3 (explicit force). +- **`auto` never selects docling.** docling needs an explicit self-hosted URL, so + it is chosen only by `DOCUMENT_OCR_PROVIDER=docling`. +- **Registration guarded on `DOCLING_API_URL`.** A bare `ENABLE_DOCLING` without a + URL registers nothing, so it can't shadow other image processors with a dead + endpoint (mirrors the custom-processor guard). +- **`block_spans` left empty.** docling's block bboxes don't follow the gateway's + normalized `[0,1]` contract, so highlighting falls back to pymupdf (same as the + Mistral backend). +- **Office formats stay with `unstructured`** — intentional non-goal; docling is + scoped to images/scans/handwriting here. +- **Synchronous only.** docling-serve's sync convert has a ~2 min server ceiling; + async submit/poll (`/v1/convert/file/async`) is future work for very large scans. + +## Consequences + +- New env: `ENABLE_DOCLING`, `DOCLING_API_URL`, `DOCLING_TIMEOUT`, + `DOCLING_OCR_LANG`, `DOCLING_DO_OCR`; `docling` added to the + `document_ocr_provider` enum. A `docling` docker-compose profile runs + docling-serve for testing. +- OCR language codes are engine-dependent (docling default EasyOCR: `en,de`; + Tesseract-backed: `eng,deu`) — operator-tunable, documented, not hard-coded. +- No new Python dependencies (HTTP via the existing `httpx`). +- Existing behavior is unchanged when `ENABLE_DOCLING` is unset and + `DOCUMENT_OCR_PROVIDER` ≠ `docling`. diff --git a/docs/configuration.md b/docs/configuration.md index 94b9fdd75..92be77fca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -633,19 +633,68 @@ model, and execution mode: ```dotenv DOCUMENT_OCR_ENABLED=true # route scanned/no-text-layer PDFs to OCR (default: false) -DOCUMENT_OCR_PROVIDER=auto # "auto" | "gateway" | "mistral" | "none" +DOCUMENT_OCR_PROVIDER=auto # "auto" | "gateway" | "mistral" | "docling" | "none" DOCUMENT_OCR_MODEL=mistral/mistral-ocr-latest # provider-namespaced model id ``` - **`DOCUMENT_OCR_PROVIDER`** selects the backend: `gateway` posts to the Astrolabe Cloud model gateway's `POST /v1/ocr` (no provider keys in the pod; the gateway routes on the model's `/` prefix, so it serves Mistral, surya, etc.); - `mistral` calls the Mistral OCR API directly (`MISTRAL_API_KEY`); `auto` prefers - the gateway (if `EMBEDDING_GATEWAY_URL` is set) then direct Mistral; `none` - disables OCR. + `mistral` calls the Mistral OCR API directly (`MISTRAL_API_KEY`); `docling` + posts scanned/no-text-layer PDFs to a self-hosted docling-serve instance + (`DOCLING_API_URL`); `auto` prefers the gateway (if `EMBEDDING_GATEWAY_URL` is + set) then direct Mistral (`auto` never selects docling — it needs an explicit + self-hosted URL); `none` disables OCR. - **`DOCUMENT_OCR_MODEL`** is the provider-namespaced model id — e.g. `mistral/mistral-ocr-latest` (Mistral) or `surya/surya-ocr-2` (surya, via the gateway). The gateway routes on the prefix; the direct Mistral backend strips it. + (Ignored by the `docling` backend, which uses the docling-serve instance's own + OCR engine.) + +#### Docling (docling-serve) — photographed / scanned / handwritten text + +[docling](https://github.com/docling-project/docling) has notably stronger OCR +than `unstructured` for photographed, scanned and **handwritten** documents. The +MCP server talks to an external +[docling-serve](https://github.com/docling-project/docling-serve) instance over +HTTP — no ML dependencies are added to the server image. Run one via the +`docling` docker-compose profile (`docker compose --profile docling up -d`). + +```dotenv +ENABLE_DOCLING=false # master switch for the docling touchpoints +DOCLING_API_URL=http://docling:5001 # docling-serve base URL (required) +DOCLING_TIMEOUT=120 # image/force conversion timeout (seconds) +DOCLING_OCR_LANG=en,de # engine-dependent codes (EasyOCR: en,de; Tesseract: eng,deu) +DOCLING_DO_OCR=true # run OCR (vs. text-layer extraction only) +``` + +Docling plugs in at three points: + +- **Images (automatic).** With `ENABLE_DOCLING=true` + `DOCLING_API_URL` set, + image files (`image/jpeg`, `image/png`, `image/tiff`, `image/bmp`, `image/gif`, + `image/webp`) always route to docling — it registers at a higher priority than + `unstructured`. `DOCLING_DO_OCR` toggles OCR on this image path only (the scanned-PDF + OCR backend always OCRs). Requires + `ENABLE_DOCUMENT_PROCESSING=true`. If `DOCLING_API_URL` is unset the processor is + not registered, so a bare `ENABLE_DOCLING` never shadows other image processors + with a dead endpoint. +- **Scanned PDFs (automatic).** Set `DOCUMENT_OCR_ENABLED=true` + + `DOCUMENT_OCR_PROVIDER=docling`. PDFs whose text layer the tier-0 classifier + finds missing/unusable escalate to the OCR tier and are transcribed by docling. + Born-digital (text-layer) PDFs still use the cheap local `fast`/`structured` + tiers — docling is only paid for genuine scans. +- **Text-layer PDFs (on demand).** Pass `force_processor="docling"` to the + `nc_webdav_read_file` MCP tool to re-parse *any* file with docling even when it + already has a text layer — useful when that layer misses tables/figures or is + incomplete. Docling returns markdown, preserving table structure. An unknown or + unconfigured processor name returns a clear tool error. + +Office formats (DOCX/PPTX/XLSX) deliberately stay with `unstructured` — docling +is scoped to the image/scan/handwriting use case here. OCR language codes are +engine-dependent: the docling-serve default engine (EasyOCR) uses two-letter +codes (`en,de`); a Tesseract-backed instance wants `eng,deu`. The synchronous +convert endpoint has a server-side ceiling (~2 min); very large scans are future +work (async submit/poll). See `docs/ADR-031-docling-document-parsing-backend.md`. #### OCR execution mode: synchronous vs batch (Deck #332) diff --git a/env.sample b/env.sample index 0925a1322..ddb27c456 100644 --- a/env.sample +++ b/env.sample @@ -232,6 +232,22 @@ NEXTCLOUD_PASSWORD= #CUSTOM_PROCESSOR_API_KEY= #CUSTOM_PROCESSOR_TIMEOUT=60 #CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png +# +# Docling (docling-serve, strong OCR for photographed/scanned/handwritten text): +# - Images always route to docling when enabled (priority over unstructured). +# - Force docling on a text-layer PDF (tables / partial text) per call via the +# nc_webdav_read_file `force_processor="docling"` argument. +# - Route scanned/no-text-layer PDFs to docling automatically by additionally +# setting DOCUMENT_OCR_ENABLED=true and DOCUMENT_OCR_PROVIDER=docling. +# OCR language codes are engine-dependent (docling default EasyOCR uses en,de; +# a Tesseract-backed instance wants eng,deu). +#ENABLE_DOCLING=false +#DOCLING_API_URL=http://docling:5001 +#DOCLING_TIMEOUT=120 +#DOCLING_OCR_LANG=en,de +#DOCLING_DO_OCR=true +#DOCUMENT_OCR_ENABLED=false +#DOCUMENT_OCR_PROVIDER=docling # ===== SSL/TLS ===== # For Nextcloud behind reverse proxies with self-signed or private CA certificates diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index d4f337aa3..c72897fd9 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -155,7 +155,8 @@ def initialize_document_processors(): """Initialize and register document processors based on configuration. This function reads the environment configuration and registers available - processors (Unstructured, Tesseract, Custom HTTP) with the global registry. + processors (Unstructured, Tesseract, Custom HTTP, Docling) with the global + registry. """ config = get_document_processor_config() @@ -256,6 +257,29 @@ def initialize_document_processors(): except Exception as e: logger.warning("Failed to register Custom processor: %s", e) + # Register Docling processor (docling-serve HTTP). High priority so images + # always route to docling when enabled; images-only for auto-selection, but + # force-selectable by name (e.g. to re-parse a text-layer PDF with tables). + if "docling" in config["processors"]: + docling_config = config["processors"]["docling"] + try: + from nextcloud_mcp_server.document_processors.docling_serve import ( # noqa: PLC0415 + DoclingProcessor, + ) + + processor = DoclingProcessor( + api_url=docling_config["api_url"], + timeout=docling_config["timeout"], + ocr_lang=docling_config["ocr_lang"], + do_ocr=docling_config["do_ocr"], + progress_interval=docling_config.get("progress_interval", 10), + ) + registry.register(processor, priority=20) # Above unstructured (10) + logger.info("Registered Docling processor: %s", docling_config["api_url"]) + registered_count += 1 + except Exception as e: + logger.warning("Failed to register Docling processor: %s", e) + if registered_count > 0: logger.info( "Document processing initialized with %s processor(s): %s", diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 04d0798df..33218aac4 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -178,8 +178,10 @@ "document_tier1_engine": "pypdfium2", "document_ocr_enabled": False, # OCR backend: "auto" picks gateway (if EMBEDDING_GATEWAY_URL) else mistral - # (if MISTRAL_API_KEY); "gateway"/"mistral" force one; "none" disables. The - # gateway routes on the model's "/" prefix, so it serves Mistral, + # (if MISTRAL_API_KEY); "gateway"/"mistral" force one; "docling" routes scanned + # PDFs to a docling-serve instance (DOCLING_API_URL); "none" disables. "auto" + # never selects docling (it needs a self-hosted URL, so it must be explicit). + # The gateway routes on the model's "/" prefix, so it serves Mistral, # surya (in-cluster GPU over the tailnet), etc. — one configurable OCR tier. "document_ocr_provider": "auto", # Provider-namespaced OCR model id (gateway routes on the prefix; the direct @@ -253,6 +255,23 @@ "custom_processor_name": "custom", "custom_processor_api_key": None, "custom_processor_timeout": 60, + # Docling document-parsing backend (docling-serve HTTP API). One docling-serve + # instance feeds two touchpoints: the images-only DoclingProcessor + # (find_processor path, priority 20) and — when DOCUMENT_OCR_PROVIDER=docling — + # the PDF OCR backend for scanned/no-text-layer PDFs. The same processor can be + # force-selected per call to parse a text-layer PDF (tables/partial text). URL + # unset -> the image processor is not registered and the OCR backend resolves to + # None. See ADR-031. + "enable_docling": False, + "docling_api_url": None, + "docling_timeout": 120, + # docling-serve OCR language codes. The default engine (EasyOCR) uses 2-letter + # codes ("en","de"); a Tesseract-backed instance wants "eng","deu". Engine- + # dependent, so keep it operator-tunable (see ADR-031). + "docling_ocr_lang": "en,de", + # Run OCR on IMAGES routed to the DoclingProcessor (find_processor path). The + # docling OCR *backend* (scanned PDFs) always OCRs regardless of this flag. + "docling_do_ocr": True, # Tag-based file exclusion (issue #710): comma-separated list of # Nextcloud system tag names. Files/folders carrying any of these tags # are hidden from WebDAV MCP tools. Empty = feature off. @@ -712,6 +731,23 @@ def get_document_processor_config() -> dict[str, Any]: "supported_types": supported_types, } + # Docling configuration (docling-serve HTTP API). Registered only when a URL is + # set, so a bare ENABLE_DOCLING doesn't shadow other image processors with a + # dead endpoint (mirrors the custom_url guard above). The standalone processor + # auto-serves images; PDFs go through the OCR backend (provider=docling) or an + # explicit per-call force_processor override. + if _dynaconf.get("ENABLE_DOCLING"): + docling_url = _dynaconf.get("DOCLING_API_URL") + if docling_url: + lang_str = _dynaconf.get("DOCLING_OCR_LANG") or "" + config["processors"]["docling"] = { + "api_url": docling_url, + "timeout": _dynaconf.get("DOCLING_TIMEOUT"), + "ocr_lang": [s.strip() for s in lang_str.split(",") if s.strip()], + "do_ocr": _dynaconf.get("DOCLING_DO_OCR"), + "progress_interval": _dynaconf.get("PROGRESS_INTERVAL"), + } + return config @@ -995,6 +1031,13 @@ class Settings: # fast->structured (pymupdf). 0 disables. See classifier._control_char_ratio. document_glyph_corruption_ratio: float = 0.02 + # Docling backend (docling-serve HTTP API). Shared by the images-only + # DoclingProcessor (find_processor path) and the PDF OCR backend + # (document_ocr_provider="docling"). docling_api_url unset -> the OCR backend + # resolves to None (docling OCR off) and the image processor is not registered. + docling_api_url: str | None = None + docling_ocr_lang: str = "en,de" + # Observability settings metrics_enabled: bool = True metrics_port: int = 9090 @@ -1162,7 +1205,7 @@ def __post_init__(self): "mcp_role": {"api", "worker", "all"}, "collection_metadata_source": {"qdrant", "api"}, "document_tier1_engine": {"pypdfium2", "pymupdf"}, - "document_ocr_provider": {"auto", "gateway", "mistral", "none"}, + "document_ocr_provider": {"auto", "gateway", "mistral", "docling", "none"}, "document_ocr_mode": {"sync", "batch"}, } for _field, _allowed in _enum_fields.items(): @@ -1738,6 +1781,11 @@ def get_settings() -> Settings: "document_ocr_min_page_chars": "DOCUMENT_OCR_MIN_PAGE_CHARS", "document_ocr_detect_scanned": "DOCUMENT_OCR_DETECT_SCANNED", "document_glyph_corruption_ratio": "DOCUMENT_GLYPH_CORRUPTION_RATIO", + # Docling backend (shared by the OCR backend + images processor). Note: + # DOCLING_DO_OCR is intentionally NOT here -- it's read via dynaconf for the + # image processor only (the OCR backend always OCRs), like the unstructured_* keys. + "docling_api_url": "DOCLING_API_URL", + "docling_ocr_lang": "DOCLING_OCR_LANG", # Observability settings "metrics_enabled": "METRICS_ENABLED", "metrics_port": "METRICS_PORT", diff --git a/nextcloud_mcp_server/document_processors/docling_serve.py b/nextcloud_mcp_server/document_processors/docling_serve.py new file mode 100644 index 000000000..7ea41dff2 --- /dev/null +++ b/nextcloud_mcp_server/document_processors/docling_serve.py @@ -0,0 +1,315 @@ +"""Document processing via a docling-serve HTTP instance. + +`docling `_ parses PDFs, images and +office formats with strong OCR (incl. photographed / scanned / handwritten text +where ``unstructured`` struggles). This module talks to an external +`docling-serve `_ instance over +HTTP (``DOCLING_API_URL``) -- no heavy ML dependencies live in the MCP server. + +Two touchpoints share the :func:`convert_file` client here: + + * :class:`DoclingProcessor` -- the images-only processor registered on the + ``find_processor`` priority path (``app.py``). Its ``process()`` also handles + PDFs/office formats so it can be *force-selected* by name + (``registry.process(processor_name="docling")``) to re-parse a text-layer PDF + (tables / partial text) that the classifier would otherwise leave to the fast + tier. It is deliberately NOT auto-selected for PDFs (``supported_mime_types`` + is images-only), so enabling docling never reroutes every PDF through it. + * ``document_processors.ocr._DoclingServeBackend`` -- the PDF OCR-tier backend + (``DOCUMENT_OCR_PROVIDER=docling``) for scanned/no-text-layer PDFs. + +docling-serve API (v1): ``POST /v1/convert/file`` (multipart, synchronous) returns +``{"document": {"md_content", "text_content", "json_content", ...}, "status": ...}``. +``GET /health`` is the liveness probe. The synchronous endpoint has a server-side +ceiling (~2 min); async submit/poll is future work. +""" + +import io +import logging +import time +from collections.abc import Awaitable, Callable +from typing import Any + +import anyio +import httpx + +from .base import DocumentProcessor, ProcessingResult, ProcessorError + +logger = logging.getLogger(__name__) + +# MIME types the standalone DoclingProcessor auto-serves (find_processor path). +# PDFs are intentionally excluded here (see module docstring) but are still +# handled by process() when the processor is force-selected by name. +DOCLING_IMAGE_TYPES = { + "image/jpeg", + "image/png", + "image/tiff", + "image/bmp", + "image/gif", + "image/webp", +} + +# docling-serve conversion statuses treated as usable output. +_OK_STATUSES = {"success", "partial_success"} + + +def _from_format_for_mime(content_type: str | None) -> str | None: + """docling ``from_formats`` hint for a MIME type, or ``None`` to let docling + infer from the filename (office formats etc.).""" + if not content_type: + return None + base = content_type.split(";")[0].strip().lower() + if base in DOCLING_IMAGE_TYPES: + return "image" + if base == "application/pdf": + return "pdf" + return None + + +def _document_text(document: dict[str, Any]) -> str: + """Best available flat text from a docling ``document`` payload. + + Prefers markdown (keeps table structure -- the reason to force docling on a + text-layer PDF) and falls back to plain text. + """ + return document.get("md_content") or document.get("text_content") or "" + + +def docling_pages(json_content: Any) -> list[tuple[int, str]]: + """Group a ``DoclingDocument.json_content`` into ``[(page_index, text)]``. + + docling-serve returns flat ``md_content``/``text_content`` with no per-page + split, but ``json_content.texts[].prov[].page_no`` carries provenance page + numbers (PDF). Group text items by that 1-based page number (dropping items + with no provenance) and return 0-based ``(index, text)`` tuples ready for + :func:`document_processors.ocr._pages_to_text`. Empty when no page provenance + is available -- the caller then synthesizes a single whole-text page. + """ + if not isinstance(json_content, dict): + return [] + texts = json_content.get("texts") or [] + by_page: dict[int, list[str]] = {} + for item in texts: + if not isinstance(item, dict): + continue + prov = item.get("prov") or [] + page_no = prov[0].get("page_no") if prov and isinstance(prov[0], dict) else None + text = item.get("text") or "" + if not isinstance(page_no, int) or not text: + continue + by_page.setdefault(page_no, []).append(text) + # page_no is 1-based; _pages_to_text expects 0-based indices (it emits page = + # index + 1). Sort so boundaries index in document order. + return [ + (page_no - 1, "\n\n".join(parts)) for page_no, parts in sorted(by_page.items()) + ] + + +async def convert_file( + api_url: str, + content: bytes, + content_type: str | None, + *, + filename: str | None = None, + to_formats: list[str], + do_ocr: bool = True, + ocr_lang: list[str] | None = None, + timeout: float = 120.0, +) -> dict[str, Any]: + """POST one document to docling-serve ``/v1/convert/file`` and return its + ``document`` payload (``md_content``/``text_content``/``json_content``/...). + + Raises :class:`ProcessorError` on HTTP error, a non-usable ``status`` + (anything but success/partial_success), or empty output -- so callers get a + single failure type to map to their fallback. + """ + files = { + "files": ( + filename or "document", + io.BytesIO(content), + content_type or "application/octet-stream", + ) + } + # docling-serve reads repeated multipart fields for list params; httpx emits a + # part per list item. Booleans go as lowercase strings for FastAPI coercion. + data: dict[str, Any] = { + "to_formats": list(to_formats), + "do_ocr": "true" if do_ocr else "false", + } + from_format = _from_format_for_mime(content_type) + if from_format: + data["from_formats"] = [from_format] + if ocr_lang: + data["ocr_lang"] = list(ocr_lang) + + url = f"{api_url.rstrip('/')}/v1/convert/file" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url, files=files, data=data) + response.raise_for_status() + body = response.json() + except httpx.HTTPError as e: + logger.error("docling-serve HTTP error: %s", e) + raise ProcessorError(f"HTTP error: {e}") from e + except ValueError as e: + # response.json() raises ValueError (JSONDecodeError) on a non-JSON body -- + # e.g. an HTML error page from an intermediary proxy. Honor the documented + # single-failure-type (ProcessorError) contract rather than leaking it. + logger.error("docling-serve returned a non-JSON body: %s", e) + raise ProcessorError(f"invalid JSON response: {e}") from e + + # A valid-JSON but non-object body (a bare list/string from a misbehaving proxy) + # would make the .get() calls below raise AttributeError; fail as ProcessorError. + if not isinstance(body, dict): + raise ProcessorError( + f"unexpected docling response shape: {type(body).__name__}" + ) + + status = str(body.get("status") or "").lower() + document = body.get("document") or {} + if status and status not in _OK_STATUSES: + errors = body.get("errors") or [] + raise ProcessorError(f"docling conversion status={status!r} errors={errors}") + if not _document_text(document): + raise ProcessorError(f"docling returned no text (status={status!r})") + return document + + +async def health(api_url: str, *, timeout: float = 5.0) -> bool: + """docling-serve liveness (``GET /health`` -> 200).""" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(f"{api_url.rstrip('/')}/health") + return response.status_code == 200 + except Exception as e: # noqa: BLE001 -- health probe never raises + logger.warning("docling health check failed: %s", e) + return False + + +class DoclingProcessor(DocumentProcessor): + """Images-only auto processor backed by a docling-serve instance. + + Auto-selected for images (``find_processor`` priority path); ``process()`` also + handles PDFs/office formats so it can be force-selected by name to re-parse a + text-layer PDF. Extracts markdown (keeps tables) via ``/v1/convert/file``. + """ + + def __init__( + self, + api_url: str, + *, + timeout: int = 120, + ocr_lang: list[str] | None = None, + do_ocr: bool = True, + progress_interval: int = 10, + ) -> None: + self.api_url = api_url + self.timeout = timeout + self.ocr_lang = ocr_lang or ["en", "de"] + self.do_ocr = do_ocr + self.progress_interval = progress_interval + logger.info( + "Initialized DoclingProcessor: %s, ocr_lang=%s, do_ocr=%s", + api_url, + self.ocr_lang, + do_ocr, + ) + + @property + def name(self) -> str: + return "docling" + + @property + def tier(self) -> str: + # A single-shot extraction; images have no escalation ladder. Kept off the + # PDF tier ladder ("fast"/"structured"/"ocr") on purpose -- supported_mime_types + # is images-only, so it is never auto-selected for PDFs. + return "fast" + + @property + def supported_mime_types(self) -> set[str]: + return DOCLING_IMAGE_TYPES + + async def _convert( + self, content: bytes, content_type: str, filename: str | None + ) -> ProcessingResult: + document = await convert_file( + self.api_url, + content, + content_type, + filename=filename, + to_formats=["md"], + do_ocr=self.do_ocr, + ocr_lang=self.ocr_lang, + timeout=self.timeout, + ) + text = _document_text(document) + return ProcessingResult( + text=text, + metadata={ + "parsing_method": "docling", + "text_length": len(text), + }, + processor=self.name, + ) + + async def process( + self, + content: bytes, + content_type: str, + filename: str | None = None, + options: dict[str, Any] | None = None, + progress_callback: ( + Callable[[float, float | None, str | None], Awaitable[None]] | None + ) = None, + ) -> ProcessingResult: + if progress_callback is None: + return await self._convert(content, content_type, filename) + + # Report progress heartbeats while the (potentially slow) OCR conversion + # runs -- mirrors UnstructuredProcessor. + stop_event = anyio.Event() + start_time = time.time() + result: ProcessingResult | None = None + + async def capture_result() -> None: + nonlocal result + try: + result = await self._convert(content, content_type, filename) + finally: + stop_event.set() + + async with anyio.create_task_group() as tg: + tg.start_soon(capture_result) + tg.start_soon( + self._run_progress_poller, stop_event, progress_callback, start_time + ) + + return result # type: ignore[return-value] + + async def _run_progress_poller( + self, + stop_event: anyio.Event, + progress_callback: Callable[[float, float | None, str | None], Awaitable[None]], + start_time: float, + ) -> None: + while not stop_event.is_set(): + try: + with anyio.fail_after(self.progress_interval): + await stop_event.wait() + break + except TimeoutError: + if stop_event.is_set(): + break + elapsed = int(time.time() - start_time) + try: + await progress_callback( + float(elapsed), + None, + f"Processing document with docling... ({elapsed}s elapsed)", + ) + except Exception as e: # noqa: BLE001 -- progress is best-effort + logger.warning("Failed to send docling progress update: %s", e) + + async def health_check(self) -> bool: + return await health(self.api_url) diff --git a/nextcloud_mcp_server/document_processors/ocr.py b/nextcloud_mcp_server/document_processors/ocr.py index 41dc215c4..5b2aef6f6 100644 --- a/nextcloud_mcp_server/document_processors/ocr.py +++ b/nextcloud_mcp_server/document_processors/ocr.py @@ -321,6 +321,47 @@ async def ocr( return _pages_to_text(pages) +class _DoclingServeBackend(_OcrBackend): + """Routes scanned/no-text-layer PDFs to a docling-serve instance + (``DOCLING_API_URL``), for self-hosters who prefer docling's OCR over the + gateway/Mistral backends (esp. photographed/handwritten text).""" + + def __init__(self, api_url: str, ocr_lang: list[str] | None) -> None: + self._api_url = api_url + self._ocr_lang = ocr_lang or None + + async def ocr( + self, content: bytes, mime_type: str + ) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]: + # Lazy import keeps docling_serve off the ocr module-load path. + from .docling_serve import convert_file, docling_pages # noqa: PLC0415 + + # Resolved per call (get_settings builds fresh) so test monkeypatching of + # the timeout is honoured, matching _GatewayOcrBackend. + ocr_timeout = get_settings().document_ocr_timeout_seconds + document = await convert_file( + self._api_url, + content, + mime_type, + to_formats=["md", "json"], + # This IS the OCR tier -- always OCR. (DOCLING_DO_OCR tunes only the + # image processor; honoring it here would silently no-OCR scanned PDFs.) + do_ocr=True, + ocr_lang=self._ocr_lang, + timeout=ocr_timeout, + ) + pages = docling_pages(document.get("json_content")) + if not pages: + # No per-page provenance in the DoclingDocument: fall back to a single + # whole-text page. convert_file guarantees non-empty text, so this + # still satisfies the _pages_to_text contract (end_offset == len(text)). + whole = document.get("md_content") or document.get("text_content") or "" + pages = [(0, whole)] + # docling has no normalized [0,1] block bbox contract, so block_spans stays + # empty (like the Mistral backend) and highlighting falls back to pymupdf. + return _pages_to_text(pages) + + def _build_gateway_token_provider(settings: Settings) -> Any: """Build the M2M ``GatewayTokenProvider`` from settings, or ``None`` when no client-id is configured (unauthenticated gateway). Shared by the sync OCR @@ -413,6 +454,14 @@ def build_ocr_backend( settings.mistral_base_url, ) + # docling is explicit-only: "auto" never selects it (it needs a self-hosted + # URL that auto can't presume), so this branch omits "auto" deliberately. + if provider == "docling" and settings.docling_api_url: + lang = [ + s.strip() for s in (settings.docling_ocr_lang or "").split(",") if s.strip() + ] + return _DoclingServeBackend(settings.docling_api_url, lang or None) + # An EXPLICIT provider that's missing its config is an operator error -- warn # loudly (once, since the backend is resolved+cached) rather than silently # disabling OCR. "auto"/"none" fall through to None quietly by design. @@ -426,6 +475,11 @@ def build_ocr_backend( "DOCUMENT_OCR_PROVIDER=mistral but MISTRAL_API_KEY is unset; " "OCR is disabled" ) + elif provider == "docling": + logger.warning( + "DOCUMENT_OCR_PROVIDER=docling but DOCLING_API_URL is unset; " + "OCR is disabled" + ) return None diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index ebb04b7ee..94d7ffbfc 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -87,7 +87,9 @@ async def nc_webdav_list_directory( ) @require_scopes("files.read") @instrument_tool - async def nc_webdav_read_file(path: str, ctx: Context): + async def nc_webdav_read_file( + path: str, ctx: Context, force_processor: str | None = None + ): """Read the content of a file from NextCloud. Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the @@ -95,6 +97,12 @@ async def nc_webdav_read_file(path: str, ctx: Context): Args: path: Full path to the file to read + force_processor: Force a specific document processor by name instead of + auto-selecting. Set to ``"docling"`` to parse the file with a + docling-serve instance even when it is a PDF that already has a text + layer -- useful when the text layer misses tables/figures or is + incomplete. Requires that processor to be enabled/registered; + raises ``ToolError`` otherwise. ``None`` = auto-select. Returns: Dict with path, content, content_type, size, and optional parsing metadata @@ -116,21 +124,47 @@ async def nc_webdav_read_file(path: str, ctx: Context): # ingest-layer concern and, before this, broke Windows startup via a # Unix-only ``import resource`` (#877). It is only needed when a file is # actually read and parsed. + from nextcloud_mcp_server.document_processors import ( # noqa: PLC0415 + get_registry, + ) from nextcloud_mcp_server.utils.document_parser import ( # noqa: PLC0415 is_parseable_document, parse_document, ) - # Check if this is a parseable document (PDF, DOCX, etc.) - # is_parseable_document() checks if document processing is enabled - if is_parseable_document(content_type): + # force_processor is client/LLM-controlled: validate it against the + # registered-processor allowlist (a dict-key lookup, never interpolated + # into a URL/path) and surface a clear error with the available names + # rather than the opaque base64 fallback parse_document would otherwise + # return for an unknown/unconfigured processor. + if force_processor is not None: + registry = get_registry() + if registry.get_processor(force_processor) is None: + available = ", ".join(registry.list_processors()) or "none" + raise ToolError( + f"Unknown document processor {force_processor!r}. Ensure it is " + f"enabled (e.g. ENABLE_DOCLING + DOCLING_API_URL for 'docling'). " + f"Available: {available}" + ) + + # Parse when the type is auto-parseable OR the caller forced a processor. + # is_parseable_document() also checks that document processing is enabled. + if force_processor is not None or is_parseable_document(content_type): try: - logger.info("Parsing document %r of type %r", path, content_type) + logger.info( + "Parsing document %r of type %r%s", + path, + content_type, + f" with forced processor {force_processor!r}" + if force_processor + else "", + ) parsed_text, metadata = await parse_document( content, content_type, filename=path, progress_callback=ctx.report_progress, + processor_name=force_processor, ) return { "path": path, diff --git a/nextcloud_mcp_server/utils/document_parser.py b/nextcloud_mcp_server/utils/document_parser.py index 3c0d3aed5..3532592a6 100644 --- a/nextcloud_mcp_server/utils/document_parser.py +++ b/nextcloud_mcp_server/utils/document_parser.py @@ -42,6 +42,7 @@ async def parse_document( progress_callback: Optional[ Callable[[float, Optional[float], Optional[str]], Awaitable[None]] ] = None, + processor_name: Optional[str] = None, ) -> Tuple[str, dict]: """Parse a document using registered processors. @@ -53,6 +54,10 @@ async def parse_document( content_type: The MIME type of the document filename: Optional filename to help with format detection progress_callback: Optional async callback for progress updates during long operations + processor_name: Force a specific registered processor by name (e.g. + "docling"), bypassing MIME/tier auto-selection. Use to parse a + text-layer PDF with docling (tables / partial text). ``None`` = + auto-select. Returns: Tuple of (parsed_text, metadata) where: @@ -72,14 +77,19 @@ async def parse_document( registry = get_registry() - logger.debug("Parsing document of type '%s'", content_type) + logger.debug( + "Parsing document of type '%s'%s", + content_type, + f" with forced processor '{processor_name}'" if processor_name else "", + ) try: - # Process using registry (auto-selects processor based on MIME type) + # Process using registry (auto-selects by MIME, or forces processor_name) result = await registry.process( content=content, content_type=content_type, filename=filename, + processor_name=processor_name, progress_callback=progress_callback, ) diff --git a/tests/integration/test_docling_api.py b/tests/integration/test_docling_api.py new file mode 100644 index 000000000..a999b7b71 --- /dev/null +++ b/tests/integration/test_docling_api.py @@ -0,0 +1,150 @@ +"""Integration tests for the docling-serve document-parsing backend. + +Gated on ``ENABLE_DOCLING=true`` (and a reachable ``DOCLING_API_URL``). Run the +docling-serve compose profile first: + + docker compose --profile docling up -d docling + +The first run downloads OCR models and CPU inference is slow, so these tests +allow a generous time budget. +""" + +import json +import logging +import os +import uuid +from io import BytesIO + +import pytest +from mcp.client.session import ClientSession +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) + +_DOCLING_ENABLED = os.getenv("ENABLE_DOCLING", "false").lower() == "true" + + +@pytest.fixture +async def test_base_path(nc_client: NextcloudClient): + test_dir = f"mcp_test_docling_{uuid.uuid4().hex[:8]}" + await nc_client.webdav.create_directory(test_dir) + yield test_dir + try: + await nc_client.webdav.delete_resource(test_dir) + except Exception: + pass # Ignore cleanup errors + + +def _read_result(mcp_result) -> dict: + content = mcp_result.content[0] + text = content.text if hasattr(content, "text") else str(content) + return json.loads(text) + + +def create_text_image(text: str) -> bytes: + """A white PNG with large black text -- legible to docling's OCR engine.""" + from PIL import Image, ImageDraw, ImageFont + + img = Image.new("RGB", (900, 240), "white") + draw = ImageDraw.Draw(img) + try: + font = ImageFont.truetype("DejaVuSans.ttf", 56) + except Exception: + font = ImageFont.load_default() + draw.text((30, 80), text, fill="black", font=font) + buffer = BytesIO() + img.save(buffer, format="PNG") + return buffer.getvalue() + + +def create_text_pdf(text: str) -> bytes: + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + c.drawString(100, 750, text) + c.save() + buffer.seek(0) + return buffer.getvalue() + + +@pytest.mark.skipif(not _DOCLING_ENABLED, reason="Docling is not enabled") +async def test_docling_image_parsing( + nc_client: NextcloudClient, test_base_path: str, nc_mcp_client: ClientSession +): + """An image auto-routes to docling (priority over unstructured) and its text is + OCR'd back out through nc_webdav_read_file.""" + test_file = f"{test_base_path}/docling_image.png" + marker = "DoclingOcrHello" + try: + await nc_client.webdav.write_file( + test_file, create_text_image(marker), content_type="image/png" + ) + mcp_result = await nc_mcp_client.call_tool( + "nc_webdav_read_file", arguments={"path": test_file} + ) + result = _read_result(mcp_result) + + assert result.get("parsed") is True + assert result["parsing_metadata"]["parsing_method"] == "docling" + content = result["content"] + assert isinstance(content, str) and content + # OCR is imperfect; assert on a distinctive substring rather than equality. + assert "docling" in content.lower() + finally: + try: + await nc_client.webdav.delete_resource(test_file) + except Exception: + pass + + +@pytest.mark.skipif(not _DOCLING_ENABLED, reason="Docling is not enabled") +async def test_docling_force_on_text_layer_pdf( + nc_client: NextcloudClient, test_base_path: str, nc_mcp_client: ClientSession +): + """force_processor="docling" re-parses a PDF that already has a text layer + (the override for tables / incomplete text).""" + test_file = f"{test_base_path}/docling_force.pdf" + test_text = "This text-layer PDF is force-parsed with docling" + try: + await nc_client.webdav.write_file( + test_file, create_text_pdf(test_text), content_type="application/pdf" + ) + mcp_result = await nc_mcp_client.call_tool( + "nc_webdav_read_file", + arguments={"path": test_file, "force_processor": "docling"}, + ) + result = _read_result(mcp_result) + + assert result.get("parsed") is True + assert result["parsing_metadata"]["parsing_method"] == "docling" + assert "docling" in result["content"].lower() + finally: + try: + await nc_client.webdav.delete_resource(test_file) + except Exception: + pass + + +@pytest.mark.skipif(not _DOCLING_ENABLED, reason="Docling is not enabled") +async def test_docling_unknown_force_processor_errors( + nc_client: NextcloudClient, test_base_path: str, nc_mcp_client: ClientSession +): + """An unknown forced processor name is a clear tool error, not a base64 dump.""" + test_file = f"{test_base_path}/docling_bad_force.pdf" + try: + await nc_client.webdav.write_file( + test_file, create_text_pdf("hello"), content_type="application/pdf" + ) + with pytest.raises(Exception) as exc_info: + await nc_mcp_client.call_tool( + "nc_webdav_read_file", + arguments={"path": test_file, "force_processor": "does-not-exist"}, + ) + assert "does-not-exist" in str(exc_info.value) + finally: + try: + await nc_client.webdav.delete_resource(test_file) + except Exception: + pass diff --git a/tests/unit/test_decomposition_config.py b/tests/unit/test_decomposition_config.py index 9f6bd9cfd..453b6f591 100644 --- a/tests/unit/test_decomposition_config.py +++ b/tests/unit/test_decomposition_config.py @@ -95,6 +95,12 @@ def test_invalid_ingest_queue_rejected(self): with pytest.raises(ValueError, match="INGEST_QUEUE"): Settings(ingest_queue="kafka") + def test_docling_ocr_provider_accepted(self): + # docling is a valid (explicit-only) OCR provider, ADR-031. + assert ( + Settings(document_ocr_provider="docling").document_ocr_provider == "docling" + ) + class TestIngestQueueResolution: def test_postgres_requires_postgres_url(self): diff --git a/tests/unit/test_docling_processor.py b/tests/unit/test_docling_processor.py new file mode 100644 index 000000000..f8f89b483 --- /dev/null +++ b/tests/unit/test_docling_processor.py @@ -0,0 +1,330 @@ +"""Unit tests for the docling-serve client + DoclingProcessor (mocked HTTP).""" + +import httpx +import pytest + +from nextcloud_mcp_server.document_processors import docling_serve +from nextcloud_mcp_server.document_processors.base import ( + DocumentProcessor, + ProcessingResult, + ProcessorError, +) +from nextcloud_mcp_server.document_processors.docling_serve import ( + DoclingProcessor, + docling_pages, +) +from nextcloud_mcp_server.document_processors.registry import ProcessorRegistry + +pytestmark = pytest.mark.unit + + +def _mock_client(mocker, *, json=None, status_code=200, raise_http=None): + """A monkeypatchable httpx.AsyncClient stand-in returning one canned response.""" + resp = mocker.Mock() + resp.status_code = status_code + resp.json = mocker.Mock(return_value=json or {}) + if raise_http is not None: + resp.raise_for_status = mocker.Mock(side_effect=raise_http) + else: + resp.raise_for_status = mocker.Mock() + + client = mocker.MagicMock() + client.__aenter__ = mocker.AsyncMock(return_value=client) + client.__aexit__ = mocker.AsyncMock(return_value=False) + client.post = mocker.AsyncMock(return_value=resp) + client.get = mocker.AsyncMock(return_value=resp) + return client + + +# --- docling_pages ----------------------------------------------------------- + + +def test_docling_pages_groups_by_page_no(): + json_content = { + "texts": [ + {"text": "Alpha", "prov": [{"page_no": 1}]}, + {"text": "Bravo", "prov": [{"page_no": 1}]}, + {"text": "Charlie", "prov": [{"page_no": 2}]}, + {"text": "no-prov", "prov": []}, # dropped + {"text": "", "prov": [{"page_no": 3}]}, # empty dropped + ] + } + pages = docling_pages(json_content) + # 0-based indices (ready for _pages_to_text), grouped + ordered by page_no. + assert pages == [(0, "Alpha\n\nBravo"), (1, "Charlie")] + + +def test_docling_pages_empty_without_provenance(): + assert docling_pages({"texts": [{"text": "x"}]}) == [] + assert docling_pages(None) == [] + assert docling_pages({}) == [] + + +# --- convert_file ------------------------------------------------------------ + + +async def test_convert_file_sends_multipart_options(mocker, monkeypatch): + client = _mock_client( + mocker, json={"status": "success", "document": {"md_content": "# Hi\n\nbody"}} + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + document = await docling_serve.convert_file( + "https://docling:5001/", + b"\x89PNG...", + "image/png", + filename="scan.png", + to_formats=["md"], + do_ocr=True, + ocr_lang=["en", "de"], + ) + assert document["md_content"] == "# Hi\n\nbody" + + # URL is normalized (no double slash) and options are form fields. + args, kwargs = client.post.call_args + assert args[0] == "https://docling:5001/v1/convert/file" + data = kwargs["data"] + assert data["to_formats"] == ["md"] + assert data["from_formats"] == ["image"] # derived from image/png + assert data["do_ocr"] == "true" + assert data["ocr_lang"] == ["en", "de"] + assert "files" in kwargs + + +async def test_convert_file_pdf_from_format(mocker, monkeypatch): + client = _mock_client( + mocker, json={"status": "success", "document": {"text_content": "text"}} + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + await docling_serve.convert_file( + "https://docling:5001", + b"%PDF-1.7", + "application/pdf", + to_formats=["md", "json"], + do_ocr=True, + ) + _, kwargs = client.post.call_args + assert kwargs["data"]["from_formats"] == ["pdf"] + + +async def test_convert_file_http_error_raises_processor_error(mocker, monkeypatch): + err = httpx.HTTPStatusError("500", request=mocker.Mock(), response=mocker.Mock()) + client = _mock_client(mocker, raise_http=err) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + with pytest.raises(ProcessorError): + await docling_serve.convert_file( + "https://docling:5001", b"x", "image/png", to_formats=["md"] + ) + + +async def test_convert_file_failure_status_raises(mocker, monkeypatch): + client = _mock_client( + mocker, json={"status": "failure", "document": {}, "errors": ["boom"]} + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + with pytest.raises(ProcessorError): + await docling_serve.convert_file( + "https://docling:5001", b"x", "image/png", to_formats=["md"] + ) + + +async def test_convert_file_empty_text_raises(mocker, monkeypatch): + client = _mock_client( + mocker, json={"status": "success", "document": {"md_content": ""}} + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + with pytest.raises(ProcessorError): + await docling_serve.convert_file( + "https://docling:5001", b"x", "image/png", to_formats=["md"] + ) + + +async def test_convert_file_non_dict_body_raises(mocker, monkeypatch): + """A valid-JSON but non-object body (bare list from a misbehaving proxy) must + become a ProcessorError, not an AttributeError on body.get(...).""" + client = _mock_client(mocker, json=["unexpected"]) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + with pytest.raises(ProcessorError): + await docling_serve.convert_file( + "https://docling:5001", b"x", "image/png", to_formats=["md"] + ) + + +async def test_convert_file_non_json_body_raises(mocker, monkeypatch): + """A non-JSON body (response.json() raises ValueError) must become a + ProcessorError, honoring the single-failure-type contract.""" + resp = mocker.Mock() + resp.raise_for_status = mocker.Mock() + resp.json = mocker.Mock(side_effect=ValueError("Expecting value")) + client = mocker.MagicMock() + client.__aenter__ = mocker.AsyncMock(return_value=client) + client.__aexit__ = mocker.AsyncMock(return_value=False) + client.post = mocker.AsyncMock(return_value=resp) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + with pytest.raises(ProcessorError): + await docling_serve.convert_file( + "https://docling:5001", b"x", "image/png", to_formats=["md"] + ) + + +# --- DoclingProcessor -------------------------------------------------------- + + +def test_supported_mime_types_images_only(): + proc = DoclingProcessor("https://docling:5001") + assert "image/png" in proc.supported_mime_types + # PDFs are deliberately excluded from auto-selection (handled by the OCR + # backend or an explicit force_processor), so it never hijacks PDF tiering. + assert "application/pdf" not in proc.supported_mime_types + assert proc.name == "docling" + assert proc.tier == "fast" + + +async def test_process_image_returns_markdown(mocker, monkeypatch): + client = _mock_client( + mocker, + json={"status": "success", "document": {"md_content": "handwritten text"}}, + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + proc = DoclingProcessor("https://docling:5001", ocr_lang=["en", "de"]) + result = await proc.process(b"\x89PNG", "image/png", filename="note.png") + assert isinstance(result, ProcessingResult) + assert result.processor == "docling" + assert result.text == "handwritten text" + assert result.metadata["parsing_method"] == "docling" + + +async def test_process_pdf_when_forced(mocker, monkeypatch): + """process() handles a PDF (from_formats=pdf) even though PDFs aren't in + supported_mime_types -- this is the forced-processor override path.""" + client = _mock_client( + mocker, + json={"status": "success", "document": {"md_content": "| a | b |\n| 1 | 2 |"}}, + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + proc = DoclingProcessor("https://docling:5001") + result = await proc.process(b"%PDF-1.7", "application/pdf", filename="tables.pdf") + assert "| a | b |" in result.text + _, kwargs = client.post.call_args + assert kwargs["data"]["from_formats"] == ["pdf"] + + +async def test_process_with_progress_callback_returns_text(mocker, monkeypatch): + """The concurrent progress-poller path (always taken via nc_webdav_read_file, + which passes ctx.report_progress) still returns the converted text.""" + client = _mock_client( + mocker, json={"status": "success", "document": {"md_content": "ocr text"}} + ) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + async def progress_cb(progress, total, message): + pass + + proc = DoclingProcessor("https://docling:5001", progress_interval=1) + result = await proc.process( + b"\x89PNG", "image/png", filename="n.png", progress_callback=progress_cb + ) + assert result.processor == "docling" + assert result.text == "ocr text" + + +async def test_process_http_error_raises(mocker, monkeypatch): + err = httpx.HTTPStatusError("500", request=mocker.Mock(), response=mocker.Mock()) + client = _mock_client(mocker, raise_http=err) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + + proc = DoclingProcessor("https://docling:5001") + with pytest.raises(ProcessorError): + await proc.process(b"x", "image/png") + + +async def test_health_check_ok(mocker, monkeypatch): + ok = _mock_client(mocker, status_code=200) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: ok) + assert await DoclingProcessor("https://docling:5001").health_check() is True + + +async def test_health_check_error_is_false(mocker, monkeypatch): + client = mocker.MagicMock() + client.__aenter__ = mocker.AsyncMock(return_value=client) + client.__aexit__ = mocker.AsyncMock(return_value=False) + client.get = mocker.AsyncMock(side_effect=httpx.ConnectError("down")) + monkeypatch.setattr(docling_serve.httpx, "AsyncClient", lambda *a, **k: client) + assert await DoclingProcessor("https://docling:5001").health_check() is False + + +# --- registry routing -------------------------------------------------------- + + +class _FakeImageProc(DocumentProcessor): + """A lower-priority processor that also handles images (like unstructured).""" + + @property + def name(self) -> str: + return "fake-images" + + @property + def supported_mime_types(self) -> set[str]: + return {"image/png", "application/pdf"} + + async def process( + self, content, content_type, filename=None, options=None, progress_callback=None + ): + return ProcessingResult(text="fake", metadata={}, processor=self.name) + + async def health_check(self) -> bool: + return True + + +def test_docling_wins_image_routing_but_not_pdf(): + registry = ProcessorRegistry() + registry.register(_FakeImageProc(), priority=10) # unstructured-like + registry.register(DoclingProcessor("https://docling:5001"), priority=20) + + # Images route to docling (higher priority). + assert registry.find_processor("image/png").name == "docling" + # PDFs are unaffected -- docling is images-only, so the fake still wins there, + # and docling never appears as a PDF tier candidate for any tier. + assert registry.find_processor("application/pdf").name == "fake-images" + for t in ("fast", "structured", "ocr"): + picked = registry._pdf_processor_for_tier(t) + assert picked is None or picked.name != "docling" + + +def test_docling_force_selectable_by_name(): + registry = ProcessorRegistry() + registry.register(DoclingProcessor("https://docling:5001"), priority=20) + # get_processor("docling") is what the forced-processor path resolves; it must + # be found even for a PDF (supports() is bypassed on the forced path). + assert registry.get_processor("docling") is not None + + +async def test_parse_document_threads_processor_name(monkeypatch): + """parse_document forwards processor_name to registry.process -- the wire that + lets nc_webdav_read_file(force_processor="docling") reach the forced path.""" + from nextcloud_mcp_server.utils import document_parser + + captured: dict = {} + + async def _fake_process(**kwargs): + captured.update(kwargs) + return ProcessingResult(text="ok", metadata={}, processor="docling") + + monkeypatch.setattr( + document_parser, "get_document_processor_config", lambda: {"enabled": True} + ) + monkeypatch.setattr(document_parser.get_registry(), "process", _fake_process) + + text, meta = await document_parser.parse_document( + b"x", "application/pdf", "f.pdf", processor_name="docling" + ) + assert captured["processor_name"] == "docling" + assert text == "ok" diff --git a/tests/unit/test_document_processor_config.py b/tests/unit/test_document_processor_config.py index 29cc91be3..fde4d3915 100644 --- a/tests/unit/test_document_processor_config.py +++ b/tests/unit/test_document_processor_config.py @@ -99,6 +99,45 @@ def test_custom_processor_config(self): os.environ.pop("CUSTOM_PROCESSOR_TIMEOUT", None) os.environ.pop("CUSTOM_PROCESSOR_TYPES", None) + def test_docling_processor_config(self): + """Docling registers only when a URL is set; values are parsed correctly.""" + os.environ["ENABLE_DOCLING"] = "true" + os.environ["DOCLING_API_URL"] = "https://docling:5001" + os.environ["DOCLING_OCR_LANG"] = "en,de" + os.environ["DOCLING_TIMEOUT"] = "90" + os.environ["DOCLING_DO_OCR"] = "true" + + try: + _reload_config() + config = get_document_processor_config() + assert "docling" in config["processors"] + dcfg = config["processors"]["docling"] + assert dcfg["api_url"] == "https://docling:5001" + assert dcfg["ocr_lang"] == ["en", "de"] + assert dcfg["timeout"] == 90 + assert dcfg["do_ocr"] is True + finally: + for key in ( + "ENABLE_DOCLING", + "DOCLING_API_URL", + "DOCLING_OCR_LANG", + "DOCLING_TIMEOUT", + "DOCLING_DO_OCR", + ): + os.environ.pop(key, None) + + def test_docling_absent_without_url(self): + """A bare ENABLE_DOCLING (no URL) must NOT register the processor, so it + can't shadow other image processors with a dead endpoint.""" + os.environ["ENABLE_DOCLING"] = "true" + os.environ.pop("DOCLING_API_URL", None) + try: + _reload_config() + config = get_document_processor_config() + assert "docling" not in config["processors"] + finally: + os.environ.pop("ENABLE_DOCLING", None) + def test_multiple_processors(self): """Test configuration with multiple processors enabled.""" os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true" diff --git a/tests/unit/test_ocr_processor.py b/tests/unit/test_ocr_processor.py index 202fc516d..ad3f1e259 100644 --- a/tests/unit/test_ocr_processor.py +++ b/tests/unit/test_ocr_processor.py @@ -30,6 +30,8 @@ def _settings(**kw) -> Any: # a Settings stand-in (only the read fields matter) embedding_gateway_scope=None, mistral_api_key=None, mistral_base_url=None, + docling_api_url=None, + docling_ocr_lang="en,de", ) base.update(kw) return SimpleNamespace(**base) @@ -239,6 +241,83 @@ def test_build_backend_auto_none_configured(): assert ocr.build_ocr_backend(_settings()) is None +def test_build_backend_docling(): + b = ocr.build_ocr_backend( + _settings( + document_ocr_provider="docling", docling_api_url="https://docling:5001" + ) + ) + assert isinstance(b, ocr._DoclingServeBackend) + assert b._api_url == "https://docling:5001" + assert b._ocr_lang == ["en", "de"] + + +def test_build_backend_docling_missing_url(): + # Explicit docling without a URL -> None (warned), never a live backend. + assert ocr.build_ocr_backend(_settings(document_ocr_provider="docling")) is None + + +def test_build_backend_auto_never_selects_docling(): + # "auto" must never pick docling even with a URL present -- docling needs an + # explicit DOCUMENT_OCR_PROVIDER=docling (a self-hosted URL auto can't presume). + assert ( + ocr.build_ocr_backend( + _settings( + document_ocr_provider="auto", docling_api_url="https://docling:5001" + ) + ) + is None + ) + + +async def test_docling_backend_builds_page_boundaries(monkeypatch): + """The docling OCR backend groups DoclingDocument.texts by page provenance into + contiguous page_boundaries that index exactly into the returned text.""" + from nextcloud_mcp_server.document_processors import docling_serve + + async def _fake_convert(api_url, content, content_type, **kw): + assert kw["to_formats"] == ["md", "json"] + return { + "md_content": "ignored-flat", + "json_content": { + "texts": [ + {"text": "Page one", "prov": [{"page_no": 1}]}, + {"text": "Page two", "prov": [{"page_no": 2}]}, + ] + }, + } + + monkeypatch.setattr(docling_serve, "convert_file", _fake_convert) + monkeypatch.setattr(ocr, "get_settings", lambda: _settings()) + + backend = ocr._DoclingServeBackend("https://docling:5001", ["en", "de"]) + text, boundaries, spans = await backend.ocr(b"%PDF-1.7", "application/pdf") + assert text == "Page one\n\nPage two" + assert [b["page"] for b in boundaries] == [1, 2] + assert boundaries[-1]["end_offset"] == len(text) + # docling has no normalized [0,1] block bbox contract -> no block spans. + assert spans == [] + + +async def test_docling_backend_single_page_fallback(monkeypatch): + """With no per-page provenance, the backend falls back to one whole-text page + (still satisfying end_offset == len(text)).""" + from nextcloud_mcp_server.document_processors import docling_serve + + async def _fake_convert(api_url, content, content_type, **kw): + return {"md_content": "whole doc text", "json_content": {"texts": []}} + + monkeypatch.setattr(docling_serve, "convert_file", _fake_convert) + monkeypatch.setattr(ocr, "get_settings", lambda: _settings()) + + backend = ocr._DoclingServeBackend("https://docling:5001", None) + text, boundaries, spans = await backend.ocr(b"%PDF", "application/pdf") + assert text == "whole doc text" + assert len(boundaries) == 1 + assert boundaries[0]["end_offset"] == len(text) + assert spans == [] + + @pytest.mark.parametrize( "kw, expect_client", [