feat: tier-0 document classifier in shadow mode
First step of the tiered document-processor effort (Deck #203): a cheap, local pre-pass that recommends which extraction tier a PDF should start in, emitting metrics WITHOUT changing routing yet -- so we gather per-tenant doc-mix data before turning escalation on. document_processors/classifier.py: classify_pdf(content) -> DocClassification. Page-sampled (bounded on large docs), <~1s. Cheap signals only -- text-layer chars, a text-quality score (catches the "Student 147" failure where a text layer exists but is mashed/space-less junk), and image coverage. A page that is mostly a raster image routes to OCR: its content (handwriting, stamps) isn't in any text layer. Deliberately no get_drawings/graphics-density signal -- it's slow on the exact pages it'd flag, the hotfix's graphics_limit already makes the parse safe, and the (future) tier-1 quality gate catches lost tables. Validated on the sample corpus: born-digital 2-col arxiv and a digital student record -> fast (tier 1); a scanned+handwritten form -> ocr (tier 3). Wiring (vector/processor.py): _shadow_classify runs the classifier on PDFs in a worker thread, best-effort (never blocks/fails indexing), gated by the new DOCUMENT_CLASSIFY_ENABLED setting. Metrics: astrolabe_document_classified_total {recommended_tier}, astrolabe_document_classifier_flag_total{flag}, astrolabe_document_text_quality histogram. 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
dd335275ac
commit
044c1da750
@@ -0,0 +1,62 @@
|
||||
"""Tests for the tier-0 shadow-classification wiring in the processor.
|
||||
|
||||
Shadow mode = observability only: it emits classification metrics but must never
|
||||
block or fail indexing, and only applies to PDFs.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.document_processors.classifier import DocClassification
|
||||
from nextcloud_mcp_server.vector import processor as proc
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _classification() -> DocClassification:
|
||||
return DocClassification(
|
||||
page_count=2,
|
||||
sampled_pages=2,
|
||||
total_chars=100,
|
||||
mean_text_quality=0.9,
|
||||
ocr_page_fraction=0.0,
|
||||
recommended_tier="fast",
|
||||
flags={"image_heavy"},
|
||||
)
|
||||
|
||||
|
||||
async def test_shadow_classify_records_metrics(monkeypatch):
|
||||
monkeypatch.setattr(proc, "classify_pdf", lambda content: _classification())
|
||||
rec = MagicMock()
|
||||
monkeypatch.setattr(proc, "record_document_classification", rec)
|
||||
|
||||
await proc._shadow_classify(b"%PDF-1.7", "application/pdf", "f.pdf")
|
||||
|
||||
rec.assert_called_once_with("fast", {"image_heavy"}, 0.9)
|
||||
|
||||
|
||||
async def test_shadow_classify_skips_non_pdf(monkeypatch):
|
||||
called = MagicMock()
|
||||
monkeypatch.setattr(proc, "classify_pdf", called)
|
||||
rec = MagicMock()
|
||||
monkeypatch.setattr(proc, "record_document_classification", rec)
|
||||
|
||||
await proc._shadow_classify(b"plain", "text/plain", "f.txt")
|
||||
|
||||
called.assert_not_called()
|
||||
rec.assert_not_called()
|
||||
|
||||
|
||||
async def test_shadow_classify_swallows_errors(monkeypatch):
|
||||
def boom(content):
|
||||
raise ValueError("bad pdf")
|
||||
|
||||
monkeypatch.setattr(proc, "classify_pdf", boom)
|
||||
rec = MagicMock()
|
||||
monkeypatch.setattr(proc, "record_document_classification", rec)
|
||||
|
||||
# Must not raise -- shadow classification is best-effort, off the index path.
|
||||
await proc._shadow_classify(b"%PDF-1.7", "application/pdf", "f.pdf")
|
||||
|
||||
rec.assert_not_called()
|
||||
Reference in New Issue
Block a user