Coverage for src / quber / core / extractors / set_of_mark / inspection.py: 92%
48 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Verify every gap cell's proposed status against the table image.
3The classifier assigns each unboxed corrected cell a condition from text
4evidence alone — a hypothesis. This stage has the status inspector LOOK at
5the table crop and give a three-way verdict per cell, and holds authority
6deterministically: a condition the inspector positively confirms keeps its
7status with the evidence recorded on the cell; a condition the image
8positively CONTRADICTS (the page shows something else there) becomes
9`defect`; anything the image cannot settle — including unmentioned cells
10and unreachable inspections (failed call, missing crop) — becomes
11`unverified`. The inspector is never forced to pick the nearest option when
12none fits. There is no upgrade path anywhere: inspection can move a cell
13into the review or defect queues, never out of them.
14"""
16from __future__ import annotations
18import asyncio
19from pathlib import Path
20from typing import Any, Dict, List, Optional, Sequence, Tuple
22from loguru import logger
24from quber.agents.completeness import page_words
25from quber.agents.status_inspector import StatusInspector
26from quber.core.extractors.base import GroundedCell
27from quber.core.extractors.camelot.correspondence.geometry import crop_region_png, table_crop_box
29# The statuses that are hypotheses needing visual confirmation. `reconciled`
30# has its box; `single_character` is definitional (text length); everything
31# else claims something only the image can show.
32INSPECTED_STATUSES = frozenset(
33 {
34 "header_printed_unlocated",
35 "label_printed_unlocated",
36 "total_label_added",
37 "header_label_added",
38 "unverified",
39 }
40)
42# The header/label split is a diagnostic convenience — both are pass-tier —
43# so both carry the SAME position-agnostic condition: the claim to verify is
44# that the text is printed on the page, not where. A position-specific
45# framing let a row-kind misclassification turn a correctly printed label
46# into a false defect.
47# The condition states only what the IMAGE can verify. Naming the internal
48# mechanism ("could not be located in the text grid") invited the model to
49# dispute that unverifiable clause and contradict text it could see printed.
50_PRINTED_CONDITION = (
51 "this text is printed on the page — as a header, a band, or a row label; "
52 "it may wrap across printed lines, combine fragments printed apart, or "
53 "share a line with neighboring text"
54)
55_CONDITION_TEXT = {
56 "header_printed_unlocated": _PRINTED_CONDITION,
57 "label_printed_unlocated": _PRINTED_CONDITION,
58 "total_label_added": (
59 "the extraction ADDED this conventional label to a row printed without "
60 "one; the page prints nothing at this position and the row visibly "
61 "totals or summarizes its section"
62 ),
63 "header_label_added": (
64 "the extraction ADDED this conventional column heading; the page prints "
65 "the table without a header over this column, so nothing is printed at "
66 "this position"
67 ),
68 "unverified": (
69 "the extraction wrote this text without a known printed source. This "
70 "condition holds when the page prints NOTHING at the position (or the "
71 "text is nowhere to be seen); it is contradicted ONLY when the page "
72 "prints a DIFFERENT word at the position that the extraction should "
73 "have used instead"
74 ),
75}
78async def inspect_gap_cells(
79 corrected_grid: Sequence[Sequence[GroundedCell]],
80 markdown: str,
81 source: str,
82 page: int,
83 page_image: Optional[Path],
84 bbox: Optional[Tuple[float, float, float, float]],
85 dpi: int,
86 inspector: Optional[StatusInspector],
87) -> None:
88 """Run one inspection over the table's flagged cells and gate the results."""
89 flagged: List[Tuple[int, int, GroundedCell]] = [
90 (r, c, cell)
91 for r, row in enumerate(corrected_grid)
92 for c, cell in enumerate(row)
93 if cell.status in INSPECTED_STATUSES
94 ]
95 if not flagged or inspector is None:
96 return
97 if page_image is None or bbox is None:
98 _downgrade_all(flagged, "no table image available for inspection", page)
99 return
101 try:
102 _pw, page_h, _words = await asyncio.to_thread(page_words, Path(source), page)
103 crop_png = await asyncio.to_thread(crop_region_png, page_image, table_crop_box(bbox, page_h), dpi)
104 except Exception as exc:
105 logger.warning(
106 "page {}: could not prepare the table image for inspection; its flagged cells "
107 "are recorded as unverified in the review flags: {}",
108 page,
109 exc,
110 )
111 _downgrade_all(flagged, "inspection crop failed", page)
112 return
114 payload: List[Dict[str, Any]] = [
115 {"row": r, "col": c, "text": cell.text, "condition": _CONDITION_TEXT[cell.status or ""]}
116 for r, c, cell in flagged
117 ]
118 report = await inspector.inspect(crop_png, markdown, payload, page)
119 findings = {(f.row, f.col): f for f in report.findings} if report else {}
121 for r, c, cell in flagged:
122 f = findings.get((r, c))
123 if f is None:
124 _reassign(cell, "unverified", "not confirmed by inspection", page, r, c)
125 elif f.verdict == "holds":
126 cell.note = f.evidence
127 elif f.verdict == "contradicted":
128 _reassign(cell, "defect", f.evidence, page, r, c)
129 else:
130 _reassign(cell, "unverified", f.evidence, page, r, c)
133def _reassign(cell: GroundedCell, status: str, evidence: str, page: int, r: int, c: int) -> None:
134 # A confirmed defect is the one outcome a person must act on, so it alone
135 # warns; downgrades to unverified reach the reviewer through the flags
136 # record and leave only a debugging trace here.
137 if cell.status != status:
138 if status == "defect":
139 logger.warning(
140 "page {}: the page contradicts the output at row {}, col {} — the extraction "
141 "wrote {!r}, but the inspector saw: {} (recorded as a defect in the review flags)",
142 page,
143 r,
144 c,
145 cell.text[:40],
146 evidence,
147 )
148 else:
149 logger.debug(
150 "page {}: cell (row {}, col {}) {!r} could not be confirmed against the page ({} -> {}): {}",
151 page,
152 r,
153 c,
154 cell.text[:40],
155 cell.status,
156 status,
157 evidence,
158 )
159 cell.status = status
160 cell.note = evidence
163def _downgrade_all(flagged: Sequence[Tuple[int, int, GroundedCell]], reason: str, page: int) -> None:
164 for r, c, cell in flagged:
165 _reassign(cell, "unverified", reason, page, r, c)