feat: tiered PDF processor with pypdfium2 fast path (deprecate pymupdf4llm)

Replaces single-engine pymupdf4llm extraction with a tiered pipeline (Deck #205,
follows the tier-0 classifier #855). pypdfium2 becomes the default and only
hot-path PDF extractor; pymupdf4llm is deprecated to a rollback toggle.

Why: pymupdf4llm's O(n^2) find_tables drove the OOM (#852) and the form-PDF
parse timeouts (#856), carries AGPL/commercial licensing liability, and -- per
the benchmarks -- recovers near-zero usable tables on the real corpus. pypdfium2
(Apache/BSD) extracts the same text far faster (Student 1a.pdf: 120s timeout ->
0.2s) with no table-detection bomb.

- document_processors/pypdfium2_fast.py: tier-1 "fast" processor emitting text +
  exact page_boundaries (the pdf_highlighter contract). pymupdf processor is now
  tier "structured" (the rollback engine), registered but not default.
- registry: tiered routing in ProcessorRegistry. tier-1 fast extracts, then
  classification is DERIVED from that text (classifier.classify_from_text -- no
  PDF re-open), records the classification metrics, and escalates scanned /
  no-text-layer docs to the "ocr" tier when document_ocr_enabled (default off;
  no provider yet, so fast is terminal). Wires record_document_escalation + the
  real "escalated" span attribute (was hardcoded False).
- Removes the separate _shadow_classify pass from vector/processor.py -- it
  re-opened every PDF and re-extracted text (~0.5-1.3s/doc of pure duplicated
  CPU that lowered throughput); classification now rides the tier-1 extraction.
- Settings: document_tier1_engine ("pypdfium2" default | "pymupdf" rollback,
  enum-validated), document_ocr_enabled (default false).

Tests: pypdfium2 extractor, registry tiering (fast routing, rollback, classify
recording, OCR escalation on/off), classify_from_text. Full unit suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-05 01:32:14 +02:00
co-authored by Claude Opus 4.8
parent 967298ddbe
commit c48a797896
13 changed files with 608 additions and 135 deletions
+2 -37
View File
@@ -17,12 +17,10 @@ from nextcloud_mcp_server.acl_hash import compute_acl_hash
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.document_processors import get_registry
from nextcloud_mcp_server.document_processors.classifier import classify_pdf
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
from nextcloud_mcp_server.models.deck import DeckCard
from nextcloud_mcp_server.observability.metrics import (
record_document_chunks,
record_document_classification,
record_document_parse_failed,
record_embedding,
record_qdrant_operation,
@@ -166,36 +164,6 @@ async def processor_task(
logger.info("Processor %s stopped", worker_id)
async def _shadow_classify(content: bytes, content_type: str, file_path: str) -> None:
"""Tier-0 classification in SHADOW mode: emit metrics, change no routing.
Best-effort and out of the indexing critical path -- it must never block or
fail indexing. PDFs only (the classifier is PDF-specific). The cheap pre-pass
runs in a worker thread so it doesn't stall the event loop.
"""
if content_type != "application/pdf":
return
try:
c = await anyio.to_thread.run_sync(classify_pdf, content) # type: ignore[attr-defined]
record_document_classification(c.recommended_tier, c.flags, c.mean_text_quality)
logger.debug(
"Tier-0 classified %s: tier=%s flags=%s quality=%s",
file_path,
c.recommended_tier,
sorted(c.flags),
c.mean_text_quality,
)
except Exception:
# Best-effort: shadow classification must never break indexing, but log
# at WARNING (not DEBUG) so a systematic failure -- a pymupdf bug, memory
# pressure on every PDF -- stays visible at the production LOG_LEVEL=INFO.
logger.warning(
"Tier-0 classification failed for %s (shadow mode, indexing unaffected)",
file_path,
exc_info=True,
)
async def process_document(
doc_task: DocumentTask, nc_client: NextcloudClient, *, max_retries: int = 3
):
@@ -566,11 +534,8 @@ async def _index_document(
"vector_sync.file_size": len(content_bytes),
},
):
# Tier-0 shadow classification (observability only; no routing change).
if settings.document_classify_enabled:
await _shadow_classify(content_bytes, content_type, file_path)
# Use document processor registry to extract text
# The registry runs the tiered PDF pipeline (tier-0 classify ->
# tier-1 fast -> OCR escalation) and records classification metrics.
registry = get_registry()
try: