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

1""" 

2Output-stage structure correction with a grounding guard. 

3 

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""" 

14 

15from __future__ import annotations 

16 

17import asyncio 

18import re 

19from dataclasses import dataclass, field 

20from pathlib import Path 

21from typing import List, Optional, Set, Tuple 

22 

23from loguru import logger 

24 

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 

40 

41__all__ = ["CAPTION_PAD_PTS", "StructureCorrection", "correct_structure", "numeric_keys", "printed_title"] 

42 

43 

44@dataclass(frozen=True) 

45class StructureCorrection: 

46 """Output-stage structure-correction result threaded onto make_extracted. 

47 

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 """ 

54 

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 = "" 

78 

79 

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. 

93 

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. 

98 

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. 

104 

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 

118 

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 

141 

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) 

158 

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 

168 

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 

179 

180 corrected = _blank_unsourced_placeholders(corrected, grid_md, region_text) 

181 

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]) 

190 

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 ) 

206 

207 

208_DASH_ONLY_RE = re.compile(r"^[—–‒―-]+$") 

209_SEPARATOR_CELL_RE = re.compile(r"^[-: ]+$") 

210 

211 

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" 

217 

218 

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)) 

223 

224 

225async def printed_title(title: str, source: str, page: int) -> str: 

226 """`title` when the page prints it, else empty. 

227 

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 "" 

241 

242 

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. 

246 

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) 

269 

270 

271_TAG_LEAK_RE = re.compile(r"\[[A-Z]+\d+\]") 

272 

273 

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 "")) 

278 

279 

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. 

282 

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)) 

288 

289 

290_NUMERIC_RE = re.compile(r"\d[\d,]*(?:\.\d+)?") 

291 

292 

293def numeric_keys(text: str) -> Set[str]: 

294 """Every number in `text`, normalized to bare digits (plus a decimal point) 

295 for presence grounding. 

296 

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)}