Coverage for src / quber / core / extractors / camelot / correspondence / correction.py: 93%
104 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"""
2Output-stage structure correction with a grounding guard.
4The cropped table image is the arbiter of STRUCTURE (column/header
5layout); Camelot's cells and the page text layer are the only sources of
6VALUES. The LLM fixes Camelot's structural errors — a spurious empty
7column from stream over-segmentation, a header split across rows, a
8header misaligned from its values — and recovers any row Camelot
9truncated from the text layer. A grounding guard then rejects the
10correction if it introduced any value absent from both Camelot's grid
11and the page text layer — never emit an invented figure; fall back to
12the deterministic grid markdown.
13"""
15from __future__ import annotations
17import asyncio
18import re
19from dataclasses import dataclass, field
20from pathlib import Path
21from typing import List, Optional, Set, Tuple
23from loguru import logger
25from quber.agents.completeness import page_blocks, page_words
26from quber.agents.llm_client import CellMerge, FootnoteDef, FootnoteMark, LLMClient
27from quber.core.extractors.camelot.acquire import (
28 grid_to_addressed_markdown,
29 grid_to_markdown,
30 is_content_empty,
31)
32from quber.core.extractors.camelot.correspondence.geometry import (
33 CAPTION_PAD_PTS,
34 bbox_to_top_left,
35 crop_region_png,
36 region_text_in_bbox,
37 table_crop_box,
38)
39from quber.core.printed_text import is_printed, page_lines
41__all__ = ["CAPTION_PAD_PTS", "StructureCorrection", "correct_structure", "numeric_keys", "printed_title"]
44@dataclass(frozen=True)
45class StructureCorrection:
46 """Output-stage structure-correction result threaded onto make_extracted.
48 `markdown` is the presentation render to emit -- the LLM's corrected
49 markdown when accepted, else the deterministic grid render -- and
50 `llm_corrected` records whether it differs from that grid render. The
51 structured grid cells remain the value source of record; only the
52 rendered presentation and the heading metadata change.
53 """
55 title: str
56 # The sentence that introduces the table, copied off the page; empty when none.
57 caption: str
58 footnotes: List[FootnoteDef]
59 markdown: str
60 llm_corrected: bool
61 # Leading column-header rows of the corrected markdown, counted off the
62 # table image by the correction agent.
63 header_rows: int
64 units: str = ""
65 footnote_refs: List[str] = field(default_factory=list)
66 # Where each footnote marker sits, as the agent reported it: corrected
67 # row/col plus the carrying cell's printed address. Validated into
68 # LocatedMarkers at the grounding stage, not here.
69 footnote_marks: List[FootnoteMark] = field(default_factory=list)
70 # Cells the agent combined into one (split-symbol rejoin, header flatten),
71 # each with the source values merged. Used to union source cell geometry
72 # onto the merged cell.
73 cell_merges: List[CellMerge] = field(default_factory=list)
74 # The table region's text layer, already computed for the grounding guard.
75 # Carried so the grounding stage can tell a printed-but-unmatched cell
76 # from a label the correction added by convention.
77 region_text: str = ""
80async def correct_structure(
81 cells: List[List[str]],
82 page_image: Optional[Path],
83 bbox: Optional[Tuple[float, float, float, float]],
84 source: str,
85 page: int,
86 llm: LLMClient,
87 correct_sem: asyncio.Semaphore,
88 dpi: int,
89 ground_values: bool = True,
90) -> Optional[StructureCorrection]:
91 """Vet a table's structure against its cropped image and ground the
92 result in the page text layer.
94 Everything is scoped to this table's bbox (crop + region text) so an
95 adjacent table can never bleed in. Skipped when the page image or bbox
96 is missing or the grid render is empty. Bounded by `correct_sem`; any
97 failure or rejection returns None so callers render the grid as before.
99 `ground_values` checks every number in the corrected table against the grid
100 and the page text layer, discarding the whole correction over one number
101 neither carries. It is on for a table extracted from a text layer, where
102 that layer is a source independent of the grid, so a number in neither has
103 no origin.
105 A caller whose table was read off a page image turns it off, because there
106 the check has nothing independent to compare against — only the grid it
107 asked to have corrected — so every repair fails it by construction. On a
108 reviewed balance sheet every repair did: the correction restored the printed
109 19,543,903 from a reading of 19.543.903 and was rejected for it, so the
110 corrupted figure stood. Where the page prints no text, the agent reading the
111 image is the only component that has seen the table.
112 """
113 if page_image is None or bbox is None:
114 return None
115 grid_md = grid_to_markdown(cells)
116 if is_content_empty(grid_md):
117 return None
119 _pw, page_h_pts, words = await asyncio.to_thread(page_words, Path(source), page)
120 region = bbox_to_top_left(bbox, page_h_pts)
121 region_text = region_text_in_bbox(words, region)
122 # The whole page as its text blocks, the units the layout keeps together:
123 # a paragraph, a title printed over two lines, one panel of a slide. The
124 # caption and title are copied from here, because the region's text layer
125 # cuts a line in half where two tables sit side by side. What is copied is
126 # then checked against the blocks and against the page's printed lines,
127 # since either reading can hold a phrase the other splits.
128 blocks = await asyncio.to_thread(page_blocks, Path(source), page)
129 page_text = "\n\n".join(blocks)
130 printed = _printed_text(blocks, words)
131 # The image crop reaches a bit higher than the data box to include the
132 # table's title/caption, which sits just above the grid. Values come
133 # only from the tight `region_text` plus the grounding guard, so a
134 # taller image cannot leak a neighbouring table's numbers.
135 crop_box = table_crop_box(bbox, page_h_pts)
136 try:
137 crop_png = await asyncio.to_thread(crop_region_png, page_image, crop_box, dpi)
138 except Exception as exc:
139 logger.error("page {}: table region crop failed: {}", page, exc)
140 return None
142 async with correct_sem:
143 try:
144 # The agent sees the grid inside a printed coordinate frame so its
145 # merge reports can NAME source cells by address (read, not
146 # counted). The guard and the llm_corrected comparison below use
147 # the unlabeled render — the frame is reference only.
148 correction = await llm.vet_structure(
149 crop_png, grid_to_addressed_markdown(cells), region_text, page_text
150 )
151 except Exception as exc:
152 logger.error("page {}: LLM structure vetting failed: {}", page, exc)
153 return None
154 if correction is None or not (correction.markdown and correction.markdown.strip()):
155 return None
156 for opening in correction.body_text:
157 logger.info("page {}: running text below the table left out of it: {!r}", page, opening)
159 # The coordinate frame must never leak into the corrected table. A leaked
160 # frame would corrupt values downstream, so it rejects the correction.
161 if _leaks_coordinate_frame(correction.markdown):
162 logger.warning(
163 "page {}: structure correction REJECTED (coordinate frame leaked into output); "
164 "keeping grid markdown",
165 page,
166 )
167 return None
169 corrected = correction.markdown
170 if ground_values:
171 ungrounded = ungrounded_values(corrected, grid_md, " ".join(w[4] for w in words))
172 if ungrounded:
173 logger.warning(
174 "page {}: structure correction REJECTED (ungrounded values {}); keeping grid markdown",
175 page,
176 sorted(ungrounded),
177 )
178 return None
180 corrected = _blank_unsourced_placeholders(corrected, grid_md, region_text)
182 # The caption and the title are copies of printed text or nothing. A value
183 # the page does not print was composed, and a composed header line would
184 # steer retrieval toward words the document never says.
185 title = correction.title if is_printed(correction.title, printed) else ""
186 caption = correction.caption if is_printed(correction.caption, printed) else ""
187 for name, given, kept in (("title", correction.title, title), ("caption", correction.caption, caption)):
188 if given and not kept:
189 logger.info("page {}: {} is not printed on the page; dropped: {!r}", page, name, given[:80])
191 return StructureCorrection(
192 title=title,
193 caption=caption,
194 footnotes=list(correction.footnotes),
195 markdown=corrected,
196 llm_corrected=corrected != grid_md,
197 units=correction.units,
198 header_rows=correction.header_rows,
199 footnote_refs=list(correction.footnote_refs),
200 footnote_marks=list(correction.footnote_marks),
201 # A merge whose joined cells were all blank names no printed mark at
202 # all (CellMerge already drops blank pieces): not a provenance record.
203 cell_merges=[m for m in correction.cell_merges if m.sources],
204 region_text=region_text,
205 )
208_DASH_ONLY_RE = re.compile(r"^[—–‒―-]+$")
209_SEPARATOR_CELL_RE = re.compile(r"^[-: ]+$")
212#: Placed between blocks and between lines before a printed-text check. The
213#: check ignores whitespace so a phrase split over two lines of one block still
214#: matches; this character, which is not whitespace, keeps two neighbouring
215#: blocks from reading as one.
216_BOUNDARY = "\u00b6"
219def _printed_text(blocks: List[str], words: List[Tuple[float, float, float, float, str]]) -> str:
220 """Everything the page prints, read twice: as text blocks and as lines,
221 each unit closed off so a phrase cannot match across two of them."""
222 return _BOUNDARY.join(blocks) + _BOUNDARY + _BOUNDARY.join(page_lines(words))
225async def printed_title(title: str, source: str, page: int) -> str:
226 """`title` when the page prints it, else empty.
228 For a table whose structure correction did not run, the only title on offer
229 is the one the grid locator read off the page image. That reading is not
230 checked anywhere else, and a composed name would steer retrieval toward
231 words the document never says, so it passes the same check a copied title
232 does."""
233 if not title or not title.strip():
234 return ""
235 _pw, _ph, words = await asyncio.to_thread(page_words, Path(source), page)
236 blocks = await asyncio.to_thread(page_blocks, Path(source), page)
237 if is_printed(title, _printed_text(blocks, words)):
238 return title
239 logger.info("page {}: located title is not printed on the page; dropped: {!r}", page, title[:80])
240 return ""
243def _blank_unsourced_placeholders(markdown: str, grid_md: str, region_text: str) -> str:
244 """Blank every dash-only cell whose dash appears nowhere in Camelot's grid
245 or the table's text layer.
247 The vet prompt forbids writing a placeholder into a printed-blank cell,
248 but compliance is not exact: the agent still occasionally emits an em-dash
249 for a cell the page shows empty. Such a dash names nothing on the page. A
250 genuinely printed nil dash is present in the grid or the text layer and is
251 always kept. Normalizing here makes the output deterministic either way —
252 the same boundary pattern as `CellMerge.drop_blank_sources`. Separator
253 lines are structural, never touched.
254 """
255 source = grid_md + "\n" + region_text
256 lines: List[str] = []
257 for line in (markdown or "").splitlines():
258 stripped = line.strip()
259 if stripped.startswith("|"):
260 cells = stripped.strip("|").split("|")
261 if not all(_SEPARATOR_CELL_RE.fullmatch(c) for c in cells):
262 cells = [
263 " " if _DASH_ONLY_RE.fullmatch(c.strip()) and c.strip() not in source else c
264 for c in cells
265 ]
266 line = "|" + "|".join(cells) + "|"
267 lines.append(line)
268 return "\n".join(lines)
271_TAG_LEAK_RE = re.compile(r"\[[A-Z]+\d+\]")
274def _leaks_coordinate_frame(markdown: str) -> bool:
275 """True when the corrected table still carries an inline address tag
276 (e.g. '[B3]'), which would corrupt cell text downstream."""
277 return bool(_TAG_LEAK_RE.search(markdown or ""))
280def ungrounded_values(corrected: str, grid_md: str, page_text: str) -> Set[str]:
281 """Numbers in the corrected table that neither the grid nor the page carries.
283 Every number in a corrected table must be present in the extracted grid or
284 in the page's text layer. A number in neither was read off nothing, and the
285 caller discards the whole correction rather than carry it.
286 """
287 return numeric_keys(corrected) - (numeric_keys(grid_md) | numeric_keys(page_text))
290_NUMERIC_RE = re.compile(r"\d[\d,]*(?:\.\d+)?")
293def numeric_keys(text: str) -> Set[str]:
294 """Every number in `text`, normalized to bare digits (plus a decimal point)
295 for presence grounding.
297 Currency, percent, parentheses, commas and spacing are dropped, so the same
298 figure matches however it was rendered: '$ 8,273.04', '8273.04' and
299 '8,273.04%' all key to '8273.04'. Bare integers count too — so a percentage
300 column written as plain '21' in the source still grounds an output cell
301 rendered as '21%'. This is presence grounding (is the figure in the source),
302 not literal-drift detection, so a value is never rejected merely because a
303 symbol moved into or out of its cell during correction.
304 """
305 return {m.group(0).replace(",", "") for m in _NUMERIC_RE.finditer(text)}