fix(document-processors): correct cascade escalation metric + review nits

Address round-1 review on PR #914:
- Attribute the OCR hop in a fast->structured->ocr inline cascade to
  from_tier="structured" (not a second "fast" escalation), so
  astrolabe_document_escalation_total per-tier counts stay accurate.
- Add test_inline_fast_structured_ocr_cascade pinning that two-hop path and the
  metric attribution.
- Note in classify_from_text that its doc-level control ratio is over full_text
  (all pages), not the sampled subset classify_pdf uses.
- Clarify that corrupt_glyphs never lands in the suppressed-escalation counter.
- Dedupe the glyph-corrupt test string into tests/fixtures/glyph_corruption.py.
- Use pytest.approx for the control-char-ratio zero checks (SonarCloud S1244).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-16 20:17:56 +02:00
co-authored by Claude Opus 4.8
parent cf7209cd85
commit d5286e39d6
6 changed files with 65 additions and 18 deletions
+8 -7
View File
@@ -14,6 +14,7 @@ import pymupdf
import pytest
from nextcloud_mcp_server.document_processors import classifier as clf
from tests.fixtures.glyph_corruption import GLYPH_CORRUPT_TEXT
pytestmark = pytest.mark.unit
@@ -324,17 +325,17 @@ def test_scan_coverage_shorter_than_pages_aligns_without_crash():
# --- glyph-corruption signal (broken /ToUnicode -> structured escalation) -----
# pypdfium2-style leak: a uniform glyph/Caesar offset turns clean prose into
# alphabetic-but-wrong tokens (normal spacing + token length => HIGH text_quality)
# while digits/punctuation map to C0 control bytes. The control-char ratio is the
# only signal that catches this; _text_quality scores it ~1.0.
_GLYPH_CORRUPT = "WKH \x0f TXLFN \x10 EURZQ \x11 IRA MXPSV \x0f RYHU \x10 GRJ " * 6
# A uniform glyph/Caesar offset turns clean prose into alphabetic-but-wrong tokens
# (normal spacing + token length => HIGH text_quality) while digits/punctuation map
# to C0 control bytes. The control-char ratio is the only signal that catches this;
# _text_quality scores it ~1.0. Shared with the registry tiering tests.
_GLYPH_CORRUPT = GLYPH_CORRUPT_TEXT
def test_control_char_ratio_clean_is_zero():
assert clf._control_char_ratio("the quick brown fox") == 0.0
assert clf._control_char_ratio("the quick brown fox") == pytest.approx(0.0)
# legitimate whitespace controls (tab/newline/CR/form-feed/vtab) don't count
assert clf._control_char_ratio("a\tb\nc\r\nd\f\ve") == 0.0
assert clf._control_char_ratio("a\tb\nc\r\nd\f\ve") == pytest.approx(0.0)
def test_control_char_ratio_detects_glyph_leak():
+27 -5
View File
@@ -4,7 +4,7 @@ Covers: default fast-tier routing, the pymupdf rollback toggle, classification
recording derived from the extraction, and OCR escalation (on/off).
"""
from unittest.mock import MagicMock
from unittest.mock import MagicMock, call
import pytest
@@ -15,6 +15,7 @@ from nextcloud_mcp_server.document_processors.base import (
)
from nextcloud_mcp_server.document_processors.escalation import EscalationDecision
from nextcloud_mcp_server.document_processors.registry import ProcessorRegistry
from tests.fixtures.glyph_corruption import GLYPH_CORRUPT_TEXT
pytestmark = pytest.mark.unit
@@ -250,10 +251,10 @@ async def test_no_ocr_escalation_when_disabled(monkeypatch):
# --- glyph-corruption escalation + full-ladder parity ------------------------
# A fast-tier text layer that looks like words (normal spacing/token lengths ->
# HIGH text_quality) but leaks C0 control chars: the broken-/ToUnicode signature
# the control-char ratio catches. Decodes to a pangram under a -3 shift.
_GLYPH = "WKH \x0f TXLFN \x10 EURZQ \x11 IRA MXPSV \x0f RYHU \x10 GRJ " * 6
# A fast-tier text layer that looks like words (HIGH text_quality) but leaks C0
# control chars -- the broken-/ToUnicode signature the control-char ratio catches.
# Shared with the classifier tests so the two can't diverge.
_GLYPH = GLYPH_CORRUPT_TEXT
async def test_glyph_corrupt_escalates_fast_to_structured(monkeypatch):
@@ -311,6 +312,27 @@ async def test_inline_empty_skips_structured_straight_to_ocr(monkeypatch):
esc.assert_called_once_with("fast", "ocr", "empty_text")
async def test_inline_fast_structured_ocr_cascade(monkeypatch):
# Full cascade: a junk-but-non-empty fast layer hops to structured, the
# structured re-extract is empty (a doc that was ALSO scanned), so it then
# hops to OCR. The second hop must be attributed from_tier="structured",
# NOT a second "fast" escalation.
monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
esc = MagicMock()
monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
r = _registry(
(_Fake("fast", "fast", text="x" * 40), 20), # quality ~0, non-empty
(_Fake("structured", "structured", text=""), 10), # re-extract empty
(_Fake("ocr", "ocr", text="ocr recovered text"), 5),
)
res = await r.process(b"%PDF-1.7", "application/pdf")
assert res.processor == "ocr"
assert esc.call_args_list == [
call("fast", "structured", "low_confidence"),
call("structured", "ocr", "empty_text"),
]
def test_evaluate_escalation_glyph_corrupt_goes_structured(monkeypatch):
# External path mirrors the inline path: glyph-corrupt -> structured, never OCR.
monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())