Coverage for src / quber / core / extractors / camelot / recapture.py: 90%
60 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"""Detect and repair Camelot capture drops before structure correction.
3Camelot occasionally drops printed values during cell assignment even though
4its text extraction captured them: a totals row typeset on a raised baseline
5splits into two row bands at the default grouping tolerance, and the numbers'
6vertical center lands exactly on the band boundary, failing the strict
7containment test on both sides — the row's label and dollar signs survive,
8its numbers vanish from the grid.
10The repair loop keeps authority deterministic and puts the model only where
11judgement is needed:
131. DETECT (deterministic): numeric tokens present in the table region's text
14 layer but absent from the grid. Same numeric normalization as the
15 correction-stage grounding guard.
162. RECOMMEND (agent): the capture advisor sees the cropped table image, the
17 extracted grid, and the dropped tokens with their printed lines, and
18 proposes one bounded retry adjustment.
193. RETRY + ACCEPT (deterministic): re-run the region pass with the advised
20 knobs. The retry replaces the original ONLY if it recovers every missing
21 numeric and loses none the original had. Anything else keeps the original
22 grid, so a wrong recommendation costs one extra Camelot pass and nothing
23 else.
25Every detection and every verdict is logged — a capture drop on a financial
26document must never be silent.
27"""
29from __future__ import annotations
31import asyncio
32from pathlib import Path
33from typing import List, Optional, Set
35from loguru import logger
37from quber.agents.capture_advisor import CaptureAdvisor
38from quber.agents.completeness import page_words
39from quber.core.extractors.camelot.acquire import CamelotCandidate, grid_to_markdown
40from quber.core.extractors.camelot.correspondence.correction import numeric_keys
41from quber.core.extractors.camelot.correspondence.geometry import (
42 WordBox,
43 bbox_to_top_left,
44 crop_region_png,
45 table_crop_box,
46)
47from quber.core.extractors.camelot.correspondence.recovery import camelot_targeted
49# How far outside the Camelot bbox (PDF points) a word may sit and still count
50# as inside the table for drop detection. Tight on purpose: a generous pad
51# would pull a neighbouring table's numbers into the missing set and flag
52# healthy captures.
53DETECT_PAD_PTS = 2.0
56def dropped_numeric_keys(cells: List[List[str]], region_words: List[WordBox]) -> Set[str]:
57 """Numeric tokens in the region's text layer that appear in no grid cell."""
58 layer = numeric_keys(" ".join(w[4] for w in region_words))
59 grid = numeric_keys(" ".join(c for row in cells for c in row))
60 return layer - grid
63def printed_lines(region_words: List[WordBox], missing: Set[str]) -> List[str]:
64 """For each missing token, the full printed line it sits on — the evidence
65 the advisor grounds its diagnosis in."""
66 lines: List[str] = []
67 for key in sorted(missing):
68 for w in region_words:
69 if numeric_keys(w[4]) == {key}:
70 line = " ".join(x[4] for x in region_words if abs(x[1] - w[1]) < 3)
71 lines.append(f"'{key}' on printed line: {line}")
72 break
73 else:
74 lines.append(f"'{key}' (word not isolated on a line)")
75 return lines
78async def repair_capture(
79 cand: CamelotCandidate,
80 source: str,
81 page: int,
82 page_image: Optional[Path],
83 dpi: int,
84 ordinal: int,
85 advisor: Optional[CaptureAdvisor],
86) -> CamelotCandidate:
87 """Return `cand`, or an advised retry that provably captures more.
89 No-op when the advisor is off, the candidate has no bbox, or no drop is
90 detected. The retry is scoped to the candidate's own bbox — the exact
91 frame the drop was measured in.
92 """
93 if advisor is None or cand.bbox is None or page_image is None:
94 return cand
95 _pw, page_h, words = await asyncio.to_thread(page_words, Path(source), page)
96 left, top, right, bottom = bbox_to_top_left(cand.bbox, page_h, pad=DETECT_PAD_PTS)
97 region_words = [w for w in words if w[0] >= left and w[2] <= right and w[1] >= top and w[3] <= bottom]
98 missing = dropped_numeric_keys(cand.cells, region_words)
99 if not missing:
100 return cand
101 logger.warning(
102 "page {}: capture drop detected — {} numeric token(s) in the table's text layer "
103 "missing from the grid: {}",
104 page,
105 len(missing),
106 sorted(missing),
107 )
109 crop = await asyncio.to_thread(crop_region_png, page_image, table_crop_box(cand.bbox, page_h), dpi)
110 advice = await advisor.recommend(
111 crop, grid_to_markdown(cand.cells), printed_lines(region_words, missing), page
112 )
113 if advice is None:
114 return cand
115 logger.info(
116 "page {}: capture advisor recommends flavor={} row_tol={} column_tol={}: {}",
117 page,
118 advice.flavor,
119 advice.row_tol,
120 advice.column_tol,
121 advice.diagnosis,
122 )
124 x1, y1, x2, y2 = cand.bbox
125 area = f"{min(x1, x2):.1f},{max(y1, y2):.1f},{max(x1, x2):.1f},{min(y1, y2):.1f}"
126 try:
127 retry = await asyncio.to_thread(
128 camelot_targeted,
129 source,
130 page,
131 area,
132 ordinal,
133 advice.flavor,
134 advice.row_tol,
135 advice.column_tol,
136 )
137 except Exception as exc:
138 logger.warning("page {}: capture-repair retry failed; keeping original grid: {}", page, exc)
139 return cand
140 if retry is None:
141 logger.warning("page {}: capture-repair retry found no grid; keeping original", page)
142 return cand
144 original = numeric_keys(" ".join(c for row in cand.cells for c in row))
145 retried = numeric_keys(" ".join(c for row in retry.cells for c in row))
146 recovered = missing & retried
147 lost = original - retried
148 if recovered == missing and not lost:
149 logger.info(
150 "page {}: capture repair ACCEPTED — recovered {} token(s), lost none",
151 page,
152 len(recovered),
153 )
154 return retry
155 logger.warning(
156 "page {}: capture repair REJECTED (recovered {}/{}, lost {}); keeping original grid",
157 page,
158 len(recovered),
159 len(missing),
160 len(lost),
161 )
162 return cand