feat(ingest): record suppressed OCR escalations (what-if-OCR signal)

OCR is the paid, opt-in tier (DOCUMENT_OCR_ENABLED, default off). The per-tier
escalation gate already declines to hop to OCR when it's disabled (the pre-OCR
tier is terminal — no surprise cost), but that left operators blind to how much
OCR demand exists.

evaluate_escalation now returns a structured EscalationDecision:
- "hop"        — a higher tier can run; the caller raises EscalateError (queue-hop).
- "suppressed" — the ideal next tier (e.g. ocr) exists but is DISABLED; the caller
                 indexes the current tier's output as terminal and records the
                 would-be hop on the new astrolabe_document_escalation_suppressed_total
                 {from_tier,to_tier,reason} counter instead of hopping.
- None         — index as-is (good text, or no such tier at all).

So with OCR off, escalation_suppressed_total{to_tier="ocr"} is the latent OCR
demand an operator weighs before enabling OCR; enabling it converts these into
real document_escalation_total{to_tier="ocr"} hops. next_available_tier gains an
ignore_enabled flag to compute the *ideal* (enabled-gate-ignored) target.

Tests: registry suppressed vs hop vs terminal (incl. structured-hop-not-suppressed
when OCR off but structured available); _parse_pdf_tier records suppressed +
indexes without raising.

Deck #324 (parent #323).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-13 15:16:03 +02:00
co-authored by Claude Opus 4.8
parent 6db48830af
commit a27ddb2d5a
6 changed files with 227 additions and 33 deletions
@@ -21,11 +21,36 @@ mapping lives in the queue layer, which imports :class:`EscalateError` from here
from __future__ import annotations
from dataclasses import dataclass
# Cheapest-first. ``llm`` is reserved (see base.DocumentProcessor.tier) and not
# wired yet, so it is intentionally absent from the live ladder.
TIER_LADDER: tuple[str, ...] = ("fast", "structured", "ocr")
@dataclass(frozen=True)
class EscalationDecision:
"""Outcome of the post-parse quality gate (``ProcessorRegistry.evaluate_escalation``).
``kind``:
* ``"hop"`` — the parse is too poor and a higher tier *can run*; the caller
raises :class:`EscalateError` to requeue the document onto ``to_tier``.
* ``"suppressed"`` — the parse would escalate to ``to_tier`` (the *ideal*
next tier), but that tier is **disabled** (e.g. OCR off). The caller does
NOT hop — it indexes the current tier's output as terminal — and records
the would-be escalation so operators see the latent demand ("what-if OCR
were enabled"). Enabling the tier turns these into real ``"hop"`` events.
A ``None`` return from ``evaluate_escalation`` (not an instance of this class)
means "index as-is, nothing to escalate" — good text, or no higher tier
exists at all (no processor registered for it).
"""
kind: str # "hop" | "suppressed"
to_tier: str
reason: str # empty_text | low_confidence
def next_tier(current: str) -> str | None:
"""The next tier above ``current`` in the ladder, or ``None`` if terminal.
@@ -15,7 +15,7 @@ from nextcloud_mcp_server.observability.tracing import trace_operation
from .base import DocumentProcessor, ProcessingResult, ProcessorError
from .classifier import DocClassification, classify_from_text, image_coverage_per_page
from .escalation import TIER_LADDER
from .escalation import TIER_LADDER, EscalationDecision
logger = logging.getLogger(__name__)
@@ -390,22 +390,34 @@ class ProcessorRegistry:
)
return classification
def _tier_available(self, tier: str, settings: Any) -> bool:
"""Whether ``tier`` can actually run a PDF parse right now.
def _tier_available(
self, tier: str, settings: Any, *, ignore_enabled: bool = False
) -> bool:
"""Whether ``tier`` can run a PDF parse right now.
A tier is available when it has a registered PDF processor and is
enabled; the ``ocr`` tier additionally requires ``DOCUMENT_OCR_ENABLED``
(so OCR stays opt-in and a misconfigured tenant never escalates to a
backend it hasn't turned on).
``ignore_enabled`` drops only the *enabled* gate (not the registered-
processor requirement): it answers "would this tier run if it were turned
on?" — used to compute the *ideal* escalation target for the
what-if-OCR suppressed-escalation signal.
"""
if self._pdf_processor_for_tier(tier) is None:
return False
if tier == "ocr" and not settings.document_ocr_enabled:
if not ignore_enabled and tier == "ocr" and not settings.document_ocr_enabled:
return False
return True
def next_available_tier(
self, current_tier: str, settings: Any, *, minimum: str | None = None
self,
current_tier: str,
settings: Any,
*,
minimum: str | None = None,
ignore_enabled: bool = False,
) -> str | None:
"""First escalation target above ``current_tier`` that can actually run.
@@ -413,6 +425,8 @@ class ProcessorRegistry:
``minimum``'s rung, when given) and returns the first
:meth:`_tier_available` tier. ``None`` means no higher tier can run --
``current_tier`` is then terminal and its result is indexed as-is.
``ignore_enabled`` is forwarded to :meth:`_tier_available` to find the
*ideal* target ignoring the OCR-enabled gate (see ``evaluate_escalation``).
"""
try:
cur_idx = TIER_LADDER.index(current_tier)
@@ -425,7 +439,7 @@ class ProcessorRegistry:
except ValueError:
pass
for tier in TIER_LADDER[start_idx:]:
if self._tier_available(tier, settings):
if self._tier_available(tier, settings, ignore_enabled=ignore_enabled):
return tier
return None
@@ -475,25 +489,33 @@ class ProcessorRegistry:
settings: Any,
*,
filename: str | None = None,
) -> tuple[str, str] | None:
) -> EscalationDecision | None:
"""Decide whether ``current_tier``'s result must escalate (external path).
Returns ``(to_tier, reason)`` when the parse is too poor to index and a
higher tier can run, else ``None`` (index the result as-is). Reuses the
tier-0 classifier as the post-parse quality gate, so the escalation
signal is identical to the inline pipeline's.
Returns an :class:`EscalationDecision` (``"hop"`` or ``"suppressed"``)
when the classifier judges the parse too poor to index, else ``None``
(index as-is). Reuses the tier-0 classifier as the post-parse quality
gate, so the signal is identical to the inline pipeline's.
A hard parse FAILURE (``result.success`` False) is never escalated: a
corrupt/encrypted PDF one engine can't open usually defeats the others
too (OCR reads the same bytes), so the caller marks it failed instead.
Routing of the target tier:
Target-tier routing:
- ``total_chars == 0`` (scanned / no text layer) -> target the ``ocr``
tier directly. Text-extractor tiers (``structured``) cannot conjure
text from a pure raster scan, so a structured hop would just be wasted.
- low-confidence but non-empty layer -> escalate to the next rung, so a
different in-cluster extractor can try before paying for OCR.
Hop vs suppressed: if the ideal target tier can run, return a ``"hop"``.
If it can't run **only because it's disabled** (OCR off — the *ideal*
tier exists ignoring the enabled gate, but the *available* one does not),
return ``"suppressed"`` so the caller records the would-be hop and indexes
the current tier's output as terminal (OCR stays opt-in + cost-free, but
the latent demand is observable). If no higher tier exists *at all* (no
processor registered), it's genuinely terminal -> ``None``.
"""
classification = self._classify_result(
result,
@@ -508,14 +530,22 @@ class ProcessorRegistry:
if classification.page_count <= 0:
return None
if classification.total_chars == 0:
to_tier = self.next_available_tier(current_tier, settings, minimum="ocr")
minimum: str | None = "ocr"
reason = "empty_text"
else:
to_tier = self.next_available_tier(current_tier, settings)
minimum = None
reason = "low_confidence"
if to_tier is None:
return None
return (to_tier, reason)
to_tier = self.next_available_tier(current_tier, settings, minimum=minimum)
if to_tier is not None:
return EscalationDecision("hop", to_tier, reason)
# No tier can run as configured. Distinguish "disabled (e.g. OCR off)"
# from "no such tier at all" by re-resolving ignoring the enabled gate.
ideal = self.next_available_tier(
current_tier, settings, minimum=minimum, ignore_enabled=True
)
if ideal is not None:
return EscalationDecision("suppressed", ideal, reason)
return None
async def _run_processor(
self,