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:
co-authored by
Claude Opus 4.8
parent
967298ddbe
commit
c48a797896
@@ -5,10 +5,16 @@ import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from nextcloud_mcp_server.observability.metrics import record_document_parse
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.observability.metrics import (
|
||||
record_document_classification,
|
||||
record_document_escalation,
|
||||
record_document_parse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
from .classifier import classify_from_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -140,7 +146,7 @@ class ProcessorRegistry:
|
||||
Raises:
|
||||
ProcessorError: If no processor found or processing fails
|
||||
"""
|
||||
# Find processor
|
||||
# Forced processor bypasses tiering.
|
||||
if processor_name:
|
||||
processor = self.get_processor(processor_name)
|
||||
if not processor:
|
||||
@@ -148,14 +154,142 @@ class ProcessorRegistry:
|
||||
f"Processor '{processor_name}' not found. "
|
||||
f"Available: {', '.join(self.list_processors())}"
|
||||
)
|
||||
else:
|
||||
return await self._run_processor(
|
||||
processor, content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
# PDFs go through the tiered pipeline (tier-0 classify -> tier-1 fast ->
|
||||
# tier-3 OCR escalation). Everything else uses priority selection.
|
||||
if content_type.split(";")[0].strip().lower() == "application/pdf":
|
||||
return await self._process_pdf(
|
||||
content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
processor = self.find_processor(content_type)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"No processor found for type: {content_type}. "
|
||||
f"Registered processors: {', '.join(self.list_processors())}"
|
||||
)
|
||||
return await self._run_processor(
|
||||
processor, content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
def _pdf_processor_for_tier(self, tier: str) -> DocumentProcessor | None:
|
||||
"""First registered processor of ``tier`` that handles PDFs."""
|
||||
for name in self._priority_order:
|
||||
processor = self._processors[name][0]
|
||||
if processor.tier == tier and processor.supports("application/pdf"):
|
||||
return processor
|
||||
return None
|
||||
|
||||
async def _process_pdf(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: str | None,
|
||||
options: dict[str, Any] | None,
|
||||
progress_callback: (
|
||||
Callable[[float, float | None, str | None], Awaitable[None]] | None
|
||||
),
|
||||
) -> ProcessingResult:
|
||||
"""Tiered PDF pipeline.
|
||||
|
||||
pypdfium2 ``fast`` extracts first; classification is then derived from
|
||||
that text (no PDF re-open), and a scanned/no-text-layer doc escalates to
|
||||
the ``ocr`` tier when enabled. ``document_tier1_engine="pymupdf"`` is a
|
||||
deprecated rollback that pins the structured engine instead.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if settings.document_tier1_engine == "pymupdf":
|
||||
processor = self._pdf_processor_for_tier(
|
||||
"structured"
|
||||
) or self.find_processor(content_type)
|
||||
if processor is None:
|
||||
raise ProcessorError("No PDF processor registered")
|
||||
return await self._run_processor(
|
||||
processor, content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
fast = self._pdf_processor_for_tier("fast")
|
||||
if fast is None:
|
||||
processor = self.find_processor(content_type)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"No processor found for type: {content_type}. "
|
||||
f"Registered processors: {', '.join(self.list_processors())}"
|
||||
if processor is None:
|
||||
raise ProcessorError("No PDF processor registered")
|
||||
return await self._run_processor(
|
||||
processor, content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
result = await self._run_processor(
|
||||
fast, content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
# Tier-0 classification from the extraction (cheap: no PDF re-open).
|
||||
classification = None
|
||||
if settings.document_classify_enabled and result.success:
|
||||
try:
|
||||
classification = classify_from_text(
|
||||
result.text, result.metadata.get("page_boundaries") or []
|
||||
)
|
||||
record_document_classification(
|
||||
classification.recommended_tier,
|
||||
classification.flags,
|
||||
classification.mean_text_quality,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Tier-0 classification failed for %s",
|
||||
filename or "<bytes>",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Escalate scanned / no-text-layer PDFs to OCR (tier-3) when enabled and
|
||||
# a provider is registered. The fast tier is terminal otherwise.
|
||||
if (
|
||||
classification is not None
|
||||
and classification.recommended_tier == "ocr"
|
||||
and settings.document_ocr_enabled
|
||||
):
|
||||
ocr = self._pdf_processor_for_tier("ocr")
|
||||
if ocr is not None:
|
||||
reason = (
|
||||
"empty_text"
|
||||
if classification.total_chars == 0
|
||||
else "low_confidence"
|
||||
)
|
||||
record_document_escalation("fast", "ocr", reason)
|
||||
logger.info(
|
||||
"Escalating %s fast->ocr (reason=%s)",
|
||||
filename or "<bytes>",
|
||||
reason,
|
||||
)
|
||||
return await self._run_processor(
|
||||
ocr,
|
||||
content,
|
||||
content_type,
|
||||
filename,
|
||||
options,
|
||||
progress_callback,
|
||||
escalated=True,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _run_processor(
|
||||
self,
|
||||
processor: DocumentProcessor,
|
||||
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,
|
||||
*,
|
||||
escalated: bool = False,
|
||||
) -> ProcessingResult:
|
||||
"""Run one processor with the per-processor span + parse metrics."""
|
||||
tier = processor.tier
|
||||
logger.info(
|
||||
"Processing with '%s' processor",
|
||||
@@ -167,11 +301,6 @@ class ProcessorRegistry:
|
||||
},
|
||||
)
|
||||
|
||||
# Process (instrumented: per-processor span + parse metrics).
|
||||
# NOTE: when the tiered pipeline (docling/OCR/LLM) lands, escalation
|
||||
# decisions are recorded here via record_document_escalation() and an
|
||||
# add_span_event("document.escalation", ...) -- the escalated=False
|
||||
# attribute and the metric are wired ahead of that.
|
||||
byte_size = len(content)
|
||||
start_time = time.time()
|
||||
with trace_operation(
|
||||
@@ -181,7 +310,7 @@ class ProcessorRegistry:
|
||||
"processor.tier": tier,
|
||||
"mime_type": content_type,
|
||||
"byte_size": byte_size,
|
||||
"escalated": False,
|
||||
"escalated": escalated,
|
||||
},
|
||||
record_exception=True,
|
||||
) as span:
|
||||
|
||||
Reference in New Issue
Block a user