Source code for compass.services.docling

"""COMPASS Docling-based services"""

import time
import math
import logging
import contextlib
import multiprocessing
from io import BytesIO
from pathlib import Path
from urllib.parse import urljoin, urlparse

from bs4 import BeautifulSoup
from elm.web.document import MDDocument
from pdf2image import convert_from_bytes, pdfinfo_from_bytes

from compass.services.cpu import (
    TIMEOUT_PARAMS,
    FileLoader,
    _force_shutdown_processes,
)
from compass.exceptions import COMPASSImportError, COMPASSValueError
from compass.utilities.logs import configure_docling_subprocess_logging


try:
    from docling_core.types.doc import DoclingDocument
    from docling_rs.datamodel.base_models import (
        ConversionStatus,
        DocumentStream,
        InputFormat,
    )
    from docling_rs.document_converter import (
        ConversionResult,
        DocumentConverter,
        PdfFormatOption,
    )
    from docling_rs.datamodel.pipeline_options import (
        PdfPipelineOptions,
        TableStructureOptions,
    )
    from docling_rs.exceptions import ConversionError
except ModuleNotFoundError as exc:
    if exc.name not in {"docling_rs", "docling_core"}:
        raise

    msg = (
        "The Docling backend requires docling-rs and docling-core. "
        "These dependencies are installed by default except on macOS. "
        "Use COMPASS_FILE_LOAD_BACKEND=elm when they are unavailable, "
        "or install them explicitly with "
        'pip install "docling-rs>=1.104.3,<2" "docling-core>=2.101.1,<3".'
    )
    raise COMPASSImportError(msg) from exc


logger = logging.getLogger(__name__)


[docs] async def read_docling_web_file( doc_bytes, url, source_uri=None, pdf_pipeline_options=None, **kwargs ): """Read a web file using Docling in a Process Pool Parameters ---------- doc_bytes : bytes Raw document payload forwarded to the Docling parser. url : str Filename or URL of the file to read. source_uri : str, optional Original remote URL for the file. If specified, this is used to resolve relative HTML links while ``url`` is still used as the stream name for format inference. By default, ``None``. pdf_pipeline_options : dict, optional Dictionary of keyword-value arguments to pass to ``docling_rs.PdfPipelineOptions`` initializer. Table structure and native OCR default to enabled. ``document_timeout`` sets both the native conversion budget and the subprocess deadline. By default, ``None``. **kwargs Additional keyword arguments passed to Docling's :meth:`~docling_core.types.doc.DoclingDocument.export_to_markdown` method. Returns ------- elm.web.document.MDDocument Parsed document. """ return await FileLoader.call( _read_docling_catch_error, doc_bytes, file_source=url, source_uri=source_uri, pdf_pipeline_options=pdf_pipeline_options, **kwargs, )
[docs] async def read_docling_local_file(fp, **kwargs): """Read a web file using Docling in a Process Pool Parameters ---------- fp : path-like Path to local file to read. **kwargs Additional keyword arguments passed to Docling's :meth:`~docling_core.types.doc.DoclingDocument.export_to_markdown` method. Returns ------- elm.web.document.MDDocument Parsed document. bytes Raw bytes of the PDF file. """ return await FileLoader.call(_read_file_docling, fp, **kwargs)
def _read_docling_catch_error( doc_bytes, file_source, headers=None, source_uri=None, pdf_pipeline_options=None, **kwargs, ): """Utility to return empty docs on error""" try: return _read_docling( doc_bytes=doc_bytes, file_source=file_source, headers=headers, source_uri=source_uri, pdf_pipeline_options=pdf_pipeline_options, **kwargs, ) except Exception: # ruff:ignore[blind-except] return MDDocument(pages=[], attrs={"doc_type": "unknown"}) def _read_docling( doc_bytes, file_source, headers=None, source_uri=None, pdf_pipeline_options=None, **kwargs, ): """Read documents using Docling with an optional hard deadline""" pdf_pipeline_options = dict(pdf_pipeline_options or {}) docling_timeout = pdf_pipeline_options.get( "document_timeout", 60 * 60 / 1.1 ) _validate_docling_timeout(docling_timeout) return _run_docling_in_subprocess( _read_docling_without_timeout, args=(doc_bytes, file_source), kwargs={ "headers": headers, "source_uri": source_uri, "pdf_pipeline_options": pdf_pipeline_options, **kwargs, }, timeout=docling_timeout * 1.1, ) def _read_docling_without_timeout( doc_bytes, file_source, headers=None, source_uri=None, pdf_pipeline_options=None, **kwargs, ): """Read documents using Docling without an in-process deadline""" file_source = str(file_source) source_uri = file_source if source_uri is None else str(source_uri) if headers is not None: headers = dict(headers) kwargs.pop("pytesseract_exe_fp", None) # safeguard configured_options = dict(pdf_pipeline_options or {}) document_timeout = configured_options.pop("document_timeout", None) configured_options.setdefault("do_table_structure", True) configured_options.setdefault("do_ocr", True) configured_options.setdefault( "table_structure_options", TableStructureOptions(do_cell_matching=True) ) if isinstance(configured_options["table_structure_options"], dict): configured_options["table_structure_options"] = TableStructureOptions( **configured_options["table_structure_options"] ) pipeline_options = PdfPipelineOptions(**configured_options) doc_converter = DocumentConverter( format_options={ InputFormat.PDF: PdfFormatOption( pipeline_options=pipeline_options ), }, document_timeout=document_timeout, ) start_time = time.perf_counter() input_format = _docling_input_format(file_source, headers) if input_format == InputFormat.HTML: doc_bytes = _resolve_docling_html_links(doc_bytes, source_uri) stream = DocumentStream( name=file_source, stream=BytesIO(doc_bytes), format=input_format ) conv_result = _run_conversion_with_fallback( doc_converter, stream, input_format, pipeline_options, doc_bytes, file_source, ) doc_text = conv_result.document.export_to_markdown(**kwargs) if ( input_format == InputFormat.PDF and pipeline_options.do_ocr and conv_result.status.value == "success" and not doc_text.strip() ): logger.info( "Native PDF conversion returned no text; rendering pages " "for Docling-RS OCR" ) conv_result = _read_docling_pdf_images( doc_converter, doc_bytes, file_source ) doc_text = conv_result.document.export_to_markdown(**kwargs) conversion_time_seconds = time.perf_counter() - start_time attrs = { "doc_filename": conv_result.input.file.stem, "doc_type": input_format.value if input_format else "unknown", "conversion_time_seconds": conversion_time_seconds, "conversion_status": conv_result.status.value, "num_pages": len(conv_result.document.pages), "from_ocr": configured_options["do_ocr"], } return MDDocument([doc_text], attrs=attrs, remove_comments=False) def _run_conversion_with_fallback( doc_converter, stream, input_format, pipeline_options, doc_bytes, file_source, ): """Run conversion with fallback for pdfium error""" try: conv_result = doc_converter.convert(stream) except ConversionError as error: if ( input_format != InputFormat.PDF or not pipeline_options.do_ocr or "pdfium support is not compiled in" not in str(error) ): raise logger.info( "Native PDF reader unavailable; retrying with Poppler renders" ) conv_result = _read_docling_pdf_images( doc_converter, doc_bytes, file_source ) return conv_result def _read_docling_pdf_images(converter, doc_bytes, file_source): """Retry empty PDFs with Poppler renders and native OCR""" page_count = pdfinfo_from_bytes(doc_bytes)["Pages"] documents = [] conversion_status = ConversionStatus.SUCCESS for page_number in range(1, page_count + 1): images = convert_from_bytes( doc_bytes, first_page=page_number, last_page=page_number ) try: for image in images: with BytesIO() as stream: image.save(stream, format="PNG") stream.seek(0) result = converter.convert( DocumentStream( name=f"{Path(file_source).stem}-{page_number}.png", stream=stream, format=InputFormat.IMAGE, ) ) documents.append(result.document) if result.status != ConversionStatus.SUCCESS: conversion_status = ConversionStatus.PARTIAL_SUCCESS finally: for image in images: image.close() return ConversionResult( status=conversion_status, input_name=file_source, document=DoclingDocument.concatenate(documents), ) def _docling_input_format(file_source, headers): """Infer the native stream format from headers or its name""" content_type = next( ( value.partition(";")[0].strip().lower() for key, value in (headers or {}).items() if key.lower() == "content-type" ), None, ) content_formats = { "application/pdf": InputFormat.PDF, "text/html": InputFormat.HTML, "text/markdown": InputFormat.MD, "text/plain": InputFormat.MD, } if content_type in content_formats: return content_formats[content_type] if content_type and content_type.startswith("image/"): return InputFormat.IMAGE extension = Path(urlparse(file_source).path).suffix.lower().lstrip(".") aliases = { "htm": "html", "txt": "md", "jpg": "image", "jpeg": "image", "png": "image", "tif": "image", "tiff": "image", "bmp": "image", } extension = aliases.get(extension, extension) return next( ( input_format for input_format in InputFormat if input_format.value == extension ), None, ) def _resolve_docling_html_links(doc_bytes, source_uri): """Resolve HTML references against their original source URL""" document = BeautifulSoup(doc_bytes, "html.parser") base = document.find("base", href=True) base_uri = urljoin(source_uri, base["href"]) if base else source_uri changed = False for element in document.find_all(["a", "img"]): attribute = "href" if element.name == "a" else "src" reference = element.get(attribute) if reference: resolved = urljoin(base_uri, reference) if resolved != reference: element[attribute] = resolved changed = True return str(document).encode("utf-8") if changed else doc_bytes def _run_docling_in_subprocess(fn, *, args, kwargs, timeout): """Run one Docling conversion in a disposable child process""" mp_context = multiprocessing.get_context( "fork" if "fork" in multiprocessing.get_all_start_methods() else "spawn" ) receiver, sender = mp_context.Pipe(duplex=False) process = mp_context.Process( target=_run_docling_subprocess, args=(sender, fn, args, kwargs) ) process.start() sender.close() try: status, payload = _receive_docling_result(receiver, process, timeout) finally: receiver.close() _shutdown_docling_process(process) if status == "success": logger.info("Docling conversion ran successfully in subprocess") return payload msg = f"Docling conversion subprocess failed: {payload}" logger.error(msg) raise ConversionError(msg) def _run_docling_subprocess(sender, fn, args, kwargs): """Execute a Docling conversion and send its result to the worker""" configure_docling_subprocess_logging(sender) try: sender.send(("success", fn(*args, **kwargs))) except Exception as error: # ruff:ignore[blind-except] with contextlib.suppress(BrokenPipeError, EOFError, OSError): sender.send(("error", f"{type(error).__name__}: {error}")) finally: sender.close() # complexipy: ignore def _receive_docling_result(receiver, process, timeout): """Receive a child conversion result before its deadline expires""" deadline = time.monotonic() + timeout while True: remaining = deadline - time.monotonic() if remaining <= 0: msg = f"Docling conversion exceeded {timeout} seconds" raise TimeoutError(msg) if receiver.poll(min(remaining, 10)): try: status, payload = receiver.recv() except EOFError as error: msg = "Docling conversion subprocess exited without a result" raise ConversionError(msg) from error if status == "log": logger.handle(payload) continue return status, payload if not process.is_alive(): if receiver.poll(): continue msg = "Docling conversion subprocess exited without a result" raise ConversionError(msg) def _shutdown_docling_process(process): """Join or force-stop a completed or timed-out Docling process""" process.join(timeout=TIMEOUT_PARAMS["shutdown_timeout"]) if process.is_alive(): _force_shutdown_processes( [process], timeout=TIMEOUT_PARAMS["force_shutdown_timeout"] ) def _validate_docling_timeout(timeout): """Validate the configured Docling deadline""" if ( isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0 ): msg = "`document_timeout` must be a positive number of seconds" raise COMPASSValueError(msg) def _read_file_docling(fp, **kwargs): """Read a local file using Docling""" fp = Path(fp) doc_bytes = fp.read_bytes() doc = _read_docling_catch_error( doc_bytes, str(fp).replace(".txt", ".md"), headers=None, **kwargs ) return doc, doc_bytes