Classifying OCR Extraction Errors for Manual Review

Once the categorization gate has typed an extraction failure, the record still has to reach a human — and the recurring production bug is that every flagged record lands in one undifferentiated “review” bucket, so a two-minute treasury FX check sits behind hundreds of low-confidence re-keys and reviewers approve blind. This page covers the manual-review triage layer: the deterministic router that turns a categorized failure into a queue-specific review ticket carrying priority, SLA, and just enough context to decide in one look. It is the human-triage detail delegated by the Receipt Error Categorization gate, which is itself the control point inside the broader Receipt Ingestion & OCR Data Extraction framework. Categorization decides what kind of failure a record is; this layer decides who reviews it, how urgently, and with what evidence — without ever mutating the original payload.

Why Standard Approaches Fail

Three named failure modes account for almost every stalled or mis-routed review queue in production accounts-payable and corporate-travel pipelines:

  • Single-queue dumping. A naive pipeline routes every non-clean record to a shared “needs review” inbox. Because the queue mixes a CURRENCY_CONVERSION_DRIFT that only treasury can adjudicate with a flood of OCR_CONFIDENCE_BELOW_THRESHOLD re-keys that a data clerk clears, high-severity records inherit the SLA of the slowest item in the bucket and freeze reimbursements past close.
  • Context starvation. The review ticket carries only a record_id. The reviewer has no source_hash to pull the original image, no pointer to the specific field that tripped, and no violation set — so they either re-derive the failure from scratch or rubber-stamp it. Context starvation is what turns a manual-review step into a compliance liability rather than a control.
  • Severity flattening on collision. A record that is simultaneously low-confidence and missing a mandatory field gets routed by the first rule the loop happens to see, not by severity. The same receipt then appears in two queues, or in the softer one, fracturing the audit trail — the same category-collision hazard the categorization gate resolves upstream, re-introduced at the routing boundary.

The design goal is a router that is deterministic (same categorized record, same queue, same ticket key), that assigns exactly one owning queue by severity while preserving the full violation set as evidence, and that attaches a self-sufficient context bundle so the reviewer never has to reconstruct the failure.

Manual-review triage — routing a categorized failure to one owning reviewer queue A CategorizedRecord carrying its category, severity, violation set, decision_hash and source_hash enters the manual-review router. The router is table-driven and keys off the single highest-severity terminal category to select exactly one owning queue by severity, so a record that tripped several rules still routes once. Five reviewer queues are ranked by priority and SLA: compliance hold (P1, 2h — hard stop, frozen payout), treasury FX (P2, 8h — adjudicate currency drift), AP exception (P2, 8h — supply a missing field), travel-policy audit (P3, 24h — reconcile line items), and manual vision re-key (P4, 24h — verify against the raw image). A context bundle is attached to every ticket: source_hash resolving to the raw image, the failed fields, the full violation set, and the severity, so the reviewer decides in one look without a round trip. Every ticket is mirrored to an append-only audit ledger; the decision_hash links the automated and human decisions into one chain of custody, and ticket_key is a SHA-256 of the decision_hash so replayed batches reuse the existing ticket instead of duplicating it. Manual-review triage — every flagged record resolves to exactly one owning queue CategorizedRecord category · severity violations[] decision_hash source_hash Manual-review router table-driven, version-pinned key on terminal category → one owning queue by severity Context bundle · every ticket source_hash → image failed_fields violations (evidence) severity decide in one look — no round trip, no mutation Reviewer queues — priority · SLA ranked P1 · SLA 2h compliance_hold hard stop · frozen payout · unmapped fallback P2 · SLA 8h treasury_fx adjudicate currency-conversion drift P2 · SLA 8h ap_exception supply the missing mandatory field P3 · SLA 24h travel_policy_audit reconcile line items against the total P4 · SLA 24h manual_vision_rekey verify low-confidence fields vs. raw image Append-only audit ledger decision_hash links auto-decision → human decision · ticket_key = SHA-256(decision_hash) ⇒ idempotent, never double-queued

Architecture & Algorithm

The triage layer consumes the CategorizedRecord emitted by the categorization gate — its category, severity, ordered violations set, decision_hash, and source_hash — and maps it to exactly one ReviewTicket. Routing is table-driven so the queue contract is auditable and version-pinned; the ticket key is derived from the upstream decision_hash, which makes ticket creation idempotent (re-processing the same batch never double-queues a receipt). The module below is self-contained and runnable, uses Pydantic v2 for boundary validation, streams through a generator to keep memory flat across a full close-cycle batch, and emits structured JSON audit metadata on every ticket.

from __future__ import annotations

import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone
from enum import Enum
from itertools import islice
from typing import Iterator, Optional

from pydantic import BaseModel, ConfigDict, Field

TRIAGE_VERSION = "manual-review-triage/2026.07"

# --- Structured audit logging -------------------------------------------------

class _TicketJSONFormatter(logging.Formatter):
    """One machine-readable JSON object per triage decision so a SIEM or
    compliance warehouse ingests it without regex parsing."""

    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "stage": "manual_review_triage",
            "record_id": getattr(record, "record_id", "UNKNOWN"),
            "review_queue": getattr(record, "review_queue", "UNKNOWN"),
            "priority": getattr(record, "priority", "P4"),
            "ticket_key": getattr(record, "ticket_key", ""),
            "decision_hash": getattr(record, "decision_hash", ""),
            "triage_version": getattr(record, "triage_version", ""),
            "msg": record.getMessage(),
        }
        return json.dumps(payload, separators=(",", ":"))


logger = logging.getLogger("expense.manual_review_triage")
if not logger.handlers:
    _handler = logging.StreamHandler()
    _handler.setFormatter(_TicketJSONFormatter())
    logger.addHandler(_handler)
    logger.setLevel(logging.INFO)


# --- Inputs and taxonomy ------------------------------------------------------

class ReviewQueue(str, Enum):
    MANUAL_VISION_REKEY = "manual_vision_rekey"   # verify against raw image
    AP_EXCEPTION = "ap_exception"                 # supply a missing field
    TREASURY_FX = "treasury_fx"                   # adjudicate currency drift
    TRAVEL_POLICY_AUDIT = "travel_policy_audit"   # reconcile line items
    COMPLIANCE_HOLD = "compliance_hold"           # hard-stop, frozen payout


class CategorizedRecord(BaseModel):
    """The upstream contract emitted by the categorization gate. Extra keys are
    rejected so a contract change surfaces here instead of propagating."""

    model_config = ConfigDict(extra="forbid")

    record_id: str
    category: str
    severity: str                       # CRITICAL | HIGH | MEDIUM | LOW | INFO
    violations: list[str]               # full tripped-rule set, for evidence
    decision_hash: str
    source_hash: str = ""               # SHA-256 of the original artifact
    failed_fields: list[str] = Field(default_factory=list)


class ReviewTicket(BaseModel):
    record_id: str
    review_queue: ReviewQueue
    priority: str                       # P1 (fastest) .. P4
    sla_deadline_utc: str
    review_reason: str
    evidence: dict                      # self-sufficient context bundle
    decision_hash: str
    ticket_key: str
    triage_version: str
    created_at_utc: str


# --- Routing contract ---------------------------------------------------------
# category -> (queue, priority, SLA hours, human-readable review reason).
# Severity is carried on the record; priority/SLA are the queue's operating
# contract and are what the reviewer's dashboard sorts on.
_ROUTES: dict[str, tuple[ReviewQueue, str, int, str]] = {
    "POLICY_PRECHECK_VIOLATION":     (ReviewQueue.COMPLIANCE_HOLD,     "P1", 2,  "Confirm the hard-stop breach before any release."),
    "SCHEMA_VALIDATION_FAILURE":     (ReviewQueue.COMPLIANCE_HOLD,     "P1", 2,  "Upstream contract broke; do not release."),
    "MISSING_MANDATORY_FIELD":       (ReviewQueue.AP_EXCEPTION,        "P2", 8,  "Supply the missing field from the source image."),
    "CURRENCY_CONVERSION_DRIFT":     (ReviewQueue.TREASURY_FX,         "P2", 8,  "Re-derive the converted amount at the booking rate."),
    "LINE_ITEM_ARITHMETIC_MISMATCH": (ReviewQueue.TRAVEL_POLICY_AUDIT, "P3", 24, "Reconcile line items and tax against the total."),
    "OCR_CONFIDENCE_BELOW_THRESHOLD":(ReviewQueue.MANUAL_VISION_REKEY, "P4", 24, "Verify low-confidence fields against the raw image."),
}

# Fallback for any category not in the contract: fail closed, not open.
_FALLBACK: tuple[ReviewQueue, str, int, str] = (
    ReviewQueue.COMPLIANCE_HOLD, "P1", 2, "Unmapped category; route to compliance and freeze.",
)


class ManualReviewTriage:
    """Map one CategorizedRecord to exactly one ReviewTicket, deterministically."""

    def __init__(self, *, triage_version: str = TRIAGE_VERSION) -> None:
        self._triage_version = triage_version

    def _ticket_key(self, decision_hash: str) -> str:
        # Derived from the upstream decision hash so re-processing the same
        # record produces the same ticket key -> idempotent, never double-queued.
        seed = f"{decision_hash}|{self._triage_version}"
        return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]

    def triage(self, record: CategorizedRecord) -> ReviewTicket:
        queue, priority, sla_hours, reason = _ROUTES.get(record.category, _FALLBACK)
        now = datetime.now(timezone.utc)
        # Evidence bundle: everything the reviewer needs without a round trip.
        evidence = {
            "source_hash": record.source_hash,      # resolves to the raw image
            "failed_fields": record.failed_fields,  # what to look at first
            "violations": record.violations,        # full audit context
            "severity": record.severity,
            "category": record.category,
        }
        ticket = ReviewTicket(
            record_id=record.record_id,
            review_queue=queue,
            priority=priority,
            sla_deadline_utc=(now + timedelta(hours=sla_hours)).isoformat(),
            review_reason=reason,
            evidence=evidence,
            decision_hash=record.decision_hash,
            ticket_key=self._ticket_key(record.decision_hash),
            triage_version=self._triage_version,
            created_at_utc=now.isoformat(),
        )
        logger.info(
            "review_ticket_created",
            extra={
                "record_id": ticket.record_id,
                "review_queue": ticket.review_queue.value,
                "priority": ticket.priority,
                "ticket_key": ticket.ticket_key,
                "decision_hash": ticket.decision_hash,
                "triage_version": ticket.triage_version,
            },
        )
        return ticket

    def triage_stream(
        self, records: Iterator[dict], *, chunk_size: int = 500
    ) -> Iterator[ReviewTicket]:
        """Yield tickets in bounded chunks so memory stays flat at close-cycle
        scale; CLEAN_PASS / INFO records are skipped — they never need a human."""
        while chunk := list(islice(records, chunk_size)):
            for raw in chunk:
                record = CategorizedRecord.model_validate(raw)
                if record.severity == "INFO":
                    continue
                yield self.triage(record)


if __name__ == "__main__":
    triage = ManualReviewTriage()
    flagged = [
        {"record_id": "R2", "category": "OCR_CONFIDENCE_BELOW_THRESHOLD",
         "severity": "LOW", "violations": ["OCR_CONFIDENCE_BELOW_THRESHOLD"],
         "decision_hash": "d2", "source_hash": "def", "failed_fields": ["total_amount"]},
        {"record_id": "R3", "category": "MISSING_MANDATORY_FIELD",
         "severity": "HIGH", "violations": ["MISSING_MANDATORY_FIELD",
         "OCR_CONFIDENCE_BELOW_THRESHOLD"], "decision_hash": "d3",
         "source_hash": "ghi", "failed_fields": ["merchant"]},
        {"record_id": "R4", "category": "POLICY_PRECHECK_VIOLATION",
         "severity": "CRITICAL", "violations": ["POLICY_PRECHECK_VIOLATION"],
         "decision_hash": "d4", "source_hash": "jkl", "failed_fields": []},
    ]
    for t in triage.triage_stream(iter(flagged)):
        print(t.record_id, t.review_queue.value, t.priority, t.sla_deadline_utc)

Because the terminal category is already the highest-severity violation (the categorization gate resolves collisions before this layer sees the record), routing keys off a single field and cannot flatten severity: R3 carries both a missing field and low confidence in its violations, but its category is MISSING_MANDATORY_FIELD, so it routes once — to the AP exception queue at P2 — while the low-confidence finding rides along as evidence rather than spawning a second ticket. Folding decision_hash into ticket_key makes ticket creation idempotent, so a replayed batch reuses the existing ticket instead of duplicating it.

Step-by-Step Integration

  1. Consume the categorized stream, not raw OCR. Wire this layer strictly after the Receipt Error Categorization gate so every record already carries a terminal category, severity, violations, and decision_hash. Never re-derive the failure here.

  2. Pin the routing contract. Store _ROUTES and TRIAGE_VERSION in your config store and bump the version whenever a queue mapping or SLA changes, so audit reconstruction resolves to one routing table.

  3. Skip clean records. Confirm INFO/CLEAN_PASS records are filtered before triage — they advance to the Core Policy Architecture & Taxonomy Design engine, not a human queue.

  4. Attach the evidence bundle to the ticket, not a pointer to it. Resolve source_hash to a pre-signed image URL and inline failed_fields + violations so the reviewer decides without a second round trip.

  5. Enforce idempotency at the ticketing boundary. Upsert on ticket_key; a re-run of the batch must update the existing ticket rather than create a duplicate. Verify with:

    from manual_review_triage import CategorizedRecord, ManualReviewTriage
    raw = {"record_id": "R", "category": "MISSING_MANDATORY_FIELD",
           "severity": "HIGH", "violations": ["MISSING_MANDATORY_FIELD"],
           "decision_hash": "d1", "source_hash": "h", "failed_fields": ["merchant"]}
    t = ManualReviewTriage()
    rec = CategorizedRecord.model_validate(raw)
    assert t.triage(rec).ticket_key == t.triage(rec).ticket_key   # idempotent
    assert t.triage(rec).review_queue.value == "ap_exception"      # right queue
  6. Mirror the ticket to the audit ledger. Append every review_ticket_created JSON event to the same append-only ledger the categorization gate writes to, so the decision_hash links the auto-decision and the human decision into one chain of custody.

  7. Preserve reviewer overrides as new records. When a reviewer resolves a ticket, append the resolution, reviewer_id, and resolved_at as a separate compliance record keyed by record_id — never mutate the original OCR payload or the categorized record.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
Timezone-shifted transaction_date A UTC-normalized date crosses a submission-window boundary and re-flags on replay, spawning a second ticket Normalize to UTC upstream; key the ticket on decision_hash, never on wall-clock time
Split-tender receipts Line items reconcile to a partial total, so the same expense produces two low-total tickets in different queues Group by source_hash before triage; route the group to a single travel-policy audit ticket
Duplicate flagged across windows The same receipt resubmitted in two periods creates two review tickets Cross-check against the Duplicate Receipt Detection pipeline before opening a ticket
FX variance near tolerance A CURRENCY_CONVERSION_DRIFT oscillates in and out of tolerance on re-processing, churning treasury tickets Snapshot the reference rate on the record; align the drift band with Dynamic Threshold Tuning
Faded thermal / very low confidence Re-keying stalls because no field is legible enough to verify Route to MANUAL_VISION_REKEY with a resubmission fallback rather than looping the OCR retry
Unmapped category A new taxonomy code has no route and could silently drop Fail closed to COMPLIANCE_HOLD (the _FALLBACK), never to a soft queue
MCC ambiguity on precheck A borderline merchant category over-triggers COMPLIANCE_HOLD Resolve the code first via Merchant Category Code routing

FAQ

How is this different from the receipt error categorization gate?

Categorization is the deterministic classification step: it types every extraction result into one error category with a severity and a decision hash. This layer is the routing step that runs only on the flagged subset — it takes that category and decides which human queue owns the record, at what priority and SLA, and packages the evidence a reviewer needs. Classification answers “what is wrong”; triage answers “who fixes it and how urgently”.

Why route by category instead of re-evaluating the rules here?

Re-evaluating rules at the routing boundary re-introduces severity flattening: two components can disagree on which violation wins, and the record fractures across queues. Because the Receipt Error Categorization gate already resolves collisions to a single highest-severity terminal category, keying off that one field guarantees one owning queue and one ticket per record.

What stops the same receipt from creating duplicate review tickets?

The ticket_key is a SHA-256 of the upstream decision_hash plus the triage version, and the ticketing system upserts on it. Since categorization is idempotent — the same artifact under the same policy version yields the same decision_hash — a replayed batch reuses the existing ticket instead of opening a new one.

How should reviewer decisions be recorded for audit?

Append the resolution as a separate compliance record keyed by record_id, capturing reviewer_id, resolution, and resolved_at. Never mutate the original OCR payload or the categorized record; the decision_hash links the automated decision and the human decision into one immutable chain of custody suitable for Sarbanes-Oxley Act control testing.

Where do low-confidence records go when re-keying can’t recover the value?

They start in the MANUAL_VISION_REKEY queue against the raw image. If the field is illegible (faded thermal ink, cropped capture), the reviewer triggers a resubmission fallback rather than looping the OCR retry — align the confidence floor that produces these with your Tesseract OCR configuration so the queue volume stays predictable.