Files
mcp-nextcloud/nextcloud_mcp_server/document_processors/pypdfium2_fast.py
T
Chris CoutinhoandClaude Opus 4.8 bc957b5bc7 fix(review): _enum_fields validation, gate classify_from_text flags, OCR warnings
Address PR #858 review round 3:

- document_tier1_engine / document_ocr_provider now validate + normalize via
  Settings.__post_init__ _enum_fields (the repo's canonical opt-in-enum pattern;
  case-insensitive) instead of dynaconf Validators. A typo now raises ValueError
  at load and "Gateway" normalizes to "gateway".
- classify_from_text gates no_text_layer/bad_text_layer on ocr_frac >=
  OCR_PAGE_FRACTION, matching classify_pdf -- a "fast"-routed doc with a few junk
  pages no longer emits a misleading flag (keeps the shadow vs hot-path
  classification metrics consistent).
- build_ocr_backend warns when an EXPLICIT provider is misconfigured
  (gateway without EMBEDDING_GATEWAY_URL, mistral without MISTRAL_API_KEY)
  instead of silently returning None.
- Pypdfium2FastProcessor.health_check probes the import; documented why
  OcrProcessor.health_check is unconditionally True (lazy per-tenant backends).
- Removed the leftover per-boundary / per-chunk debug logging loops.

Tests: enum normalization + rejection for the two new settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:35:26 +02:00

129 lines
4.4 KiB
Python

"""Tier-1 fast PDF text extractor (pypdfium2).
A permissively-licensed (Apache/BSD-2) fast path that extracts a PDF's text
layer + page boundaries WITHOUT pymupdf4llm's expensive O(n^2) table/graphics
analysis. For born-digital PDFs (the tier-0 classifier's ``fast`` verdict) this
returns clean text in well under a second -- including the form/table PDFs that
timed out under pymupdf4llm (e.g. ``Student 1a.pdf``: 120s timeout -> ~1s here).
bbox is re-derived from the PDF bytes + ``page_boundaries`` by
``search/pdf_highlighter``, so this processor only needs to emit ``text`` and
``metadata["page_boundaries"]`` for chunk highlighting to keep working.
It deliberately does NOT recover tables/layout; a low-quality result is meant to
escalate to the ``structured`` tier (pymupdf4llm, graphics_limit-guarded) via the
registry (B2 escalation wiring).
"""
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import anyio
from .base import DocumentProcessor, ProcessingResult
logger = logging.getLogger(__name__)
def _extract(content: bytes) -> tuple[str, dict[str, Any]]:
"""Extract concatenated text + metadata from a PDF (runs in a worker thread).
``page_boundaries`` offsets index into the returned text, which is the page
texts joined with no separator so the offsets stay exact (the contract
``search/pdf_highlighter`` and the chunker rely on).
"""
import pypdfium2 as pdfium # noqa: PLC0415 -- keep the native import lazy
pdf = pdfium.PdfDocument(content)
try:
page_texts: list[str] = []
for i in range(len(pdf)):
page = pdf[i]
textpage = page.get_textpage()
try:
page_texts.append(textpage.get_text_bounded() or "")
finally:
textpage.close()
page.close()
doc_meta = pdf.get_metadata_dict() or {}
finally:
pdf.close()
page_boundaries: list[dict[str, Any]] = []
offset = 0
for n, text in enumerate(page_texts, start=1):
page_boundaries.append(
{"page": n, "start_offset": offset, "end_offset": offset + len(text)}
)
offset += len(text)
full_text = "".join(page_texts)
metadata: dict[str, Any] = {
"page_count": len(page_texts),
"page_boundaries": page_boundaries,
}
title = doc_meta.get("Title")
if title:
metadata["title"] = title
return full_text, metadata
class Pypdfium2FastProcessor(DocumentProcessor):
"""Tier-1 fast PDF text extractor backed by pypdfium2."""
@property
def name(self) -> str:
return "pypdfium2_fast"
@property
def tier(self) -> str:
return "fast"
@property
def supported_mime_types(self) -> set[str]:
return {"application/pdf"}
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:
await progress_callback(0, 100, "Extracting text (pypdfium2)")
try:
full_text, metadata = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
_extract, content
)
except Exception as e:
# Fast path is best-effort: a failure here escalates rather than
# crashing the pipeline. pypdfium2 has no O(n^2) bomb, so this is a
# genuinely malformed PDF, not a resource blowup.
logger.warning(
"pypdfium2 fast extract failed for %s: %s", filename or "<bytes>", e
)
return ProcessingResult(
text="",
metadata={"parse_failed_reason": "error"},
processor=self.name,
success=False,
error=f"{type(e).__name__}: {e}",
)
metadata["file_size"] = len(content)
if progress_callback:
await progress_callback(100, 100, "Done")
return ProcessingResult(text=full_text, metadata=metadata, processor=self.name)
async def health_check(self) -> bool:
try:
import pypdfium2 # noqa: F401, PLC0415 -- availability probe
return True
except Exception:
return False