Coverage for src / quber / core / extractors / camelot / acquire.py: 60%

122 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1""" 

2Camelot acquisition layer: get candidate cell grids out of a PDF. 

3 

4This module knows nothing about LLMs or the pipeline graph. It renders 

5page images, runs both Camelot flavors in spawn-context subprocesses, 

6and converts each Camelot DataFrame into a serializable 

7`CamelotCandidate`. 

8 

9Both Camelot flavors run in parallel: 

10 

11- `lattice` — visible grid lines. 

12- `stream` — whitespace-based; more permissive. 

13 

14There is deliberately no lattice-first / fall-back-to-stream gate: on 

15pages where tables are aligned by whitespace alone, lattice still 

16returns grids — empty shells — so "fall back only when lattice finds 

17nothing" let those shells through as false positives. `is_content_empty` 

18catches the shells instead. 

19""" 

20 

21from __future__ import annotations 

22 

23import multiprocessing as mp 

24from concurrent.futures import ProcessPoolExecutor 

25from concurrent.futures import TimeoutError as FutureTimeoutError 

26from pathlib import Path 

27from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple 

28 

29from loguru import logger 

30from pydantic import BaseModel, Field 

31 

32if TYPE_CHECKING: 

33 from pandas import DataFrame 

34 

35Flavor = Literal["lattice", "stream"] 

36 

37CAMELOT_FLAVOR_TIMEOUT_S = 60.0 

38 

39 

40def render_pages(source: Path, dpi: int, out_dir: Path) -> List[Path]: 

41 from pdf2image import convert_from_path 

42 

43 images = convert_from_path(str(source), dpi=dpi, fmt="png", output_folder=str(out_dir)) 

44 paths: List[Path] = [] 

45 for i, img in enumerate(images, start=1): 

46 path = out_dir / f"page-{i:04d}.png" 

47 img.save(path, "PNG") 

48 paths.append(path) 

49 return paths 

50 

51 

52class CamelotCandidate(BaseModel): 

53 """Serializable per-Camelot-table output. Crosses the spawn-process 

54 boundary; only picklable primitives, no Camelot internals. 

55 

56 `cells` is the raw grid (rows of cell strings) straight from Camelot's 

57 DataFrame; `markdown` is its rendering. The correspondence flow works 

58 on `cells` so it can assemble, audit and fill at the grid level and 

59 render markdown once at the end; the legacy classifier/unifier path 

60 consumes `markdown`. 

61 """ 

62 

63 candidate_id: str = Field(description="Stable id assigned by the parent; flavor-page-idx") 

64 flavor: Flavor 

65 page: int = Field(ge=1) 

66 bbox: Optional[Tuple[float, float, float, float]] = None 

67 accuracy: float = Field(default=0.0, ge=0.0, le=100.0) 

68 cells: List[List[str]] = Field( 

69 default_factory=list, description="Camelot's raw cell grid (rows of strings)" 

70 ) 

71 cell_boxes: List[List[Optional[Tuple[float, float, float, float]]]] = Field( 

72 default_factory=list, 

73 description=( 

74 "Per-cell geometry aligned 1:1 with `cells`: same row/column shape, each entry the " 

75 "Camelot cell box (x1, y1, x2, y2) in PDF points with a bottom-left page origin, or " 

76 "None where Camelot exposed no box for that cell. Empty for candidates not built " 

77 "directly from a Camelot grid (e.g. assembled or recovered candidates)." 

78 ), 

79 ) 

80 markdown: str = Field(description="Camelot's raw markdown for this table; rendering of `cells`") 

81 

82 

83def grid_to_markdown(cells: List[List[str]]) -> str: 

84 """Render a cell grid (rows of strings) to a markdown table. Row 0 is 

85 the header; rows are padded to the widest row so the column count is 

86 uniform. Single rendering point for the structured-grid flow. 

87 """ 

88 if not cells: 

89 return "" 

90 width = max(len(r) for r in cells) 

91 

92 def render(row: List[str]) -> str: 

93 padded = [str(c) for c in row] + [""] * (width - len(row)) 

94 return "| " + " | ".join(padded) + " |" 

95 

96 lines = [render(cells[0]), "| " + " | ".join("---" for _ in range(width)) + " |"] 

97 lines.extend(render(row) for row in cells[1:]) 

98 return "\n".join(lines) 

99 

100 

101def column_letter(index: int) -> str: 

102 """0-based column index to a spreadsheet letter: 0->A, 25->Z, 26->AA.""" 

103 out = "" 

104 index += 1 

105 while index: 

106 index, rem = divmod(index - 1, 26) 

107 out = chr(ord("A") + rem) + out 

108 return out 

109 

110 

111def grid_to_addressed_markdown(cells: List[List[str]]) -> str: 

112 """Render the grid with each non-empty cell's address printed inside it. 

113 

114 Every cell with text carries an inline tag — `[B3] 1,637` means data 

115 column B (A=0), grid row 3 (1-based) — so the agent READS an address off 

116 the label sitting next to the text; it never counts rows, columns, or 

117 pipes. Blank cells stay blank. The tags are reference only — the agent's 

118 corrected output must never contain them. 

119 """ 

120 if not cells: 

121 return "" 

122 width = max(len(r) for r in cells) 

123 

124 def tag(r: int, c: int, text: str) -> str: 

125 return f"[{column_letter(c)}{r}] {text}" if str(text).strip() else "" 

126 

127 def render_addressed(r: int, row: List[str]) -> str: 

128 padded = [str(c) for c in row] + [""] * (width - len(row)) 

129 return "| " + " | ".join(tag(r, c, v) for c, v in enumerate(padded)) + " |" 

130 

131 lines = [render_addressed(1, cells[0]), "| " + " | ".join("---" for _ in range(width)) + " |"] 

132 lines.extend(render_addressed(i, row) for i, row in enumerate(cells[1:], start=2)) 

133 return "\n".join(lines) 

134 

135 

136def df_to_cells(df: DataFrame) -> List[List[str]]: 

137 """Camelot DataFrame -> raw cell grid (rows of strings).""" 

138 return [[str(c) for c in row] for row in df.values.tolist()] 

139 

140 

141def cells_to_boxes( 

142 raw_cells: Any, grid: List[List[str]] 

143) -> List[List[Optional[Tuple[float, float, float, float]]]]: 

144 """Per-cell boxes aligned to `grid`'s shape from Camelot's Cell objects. 

145 

146 `raw_cells` is Camelot's `table.cells` — rows of `Cell` objects, each with 

147 x1/y1/x2/y2 in PDF points (bottom-left origin). `table.df` (hence `grid`) is 

148 built from that same grid, so index i,j lines up; still, we clamp to `grid`'s 

149 shape and fill None for any position Camelot did not cover, so `cell_boxes` 

150 is always exactly the same shape as `cells`. 

151 """ 

152 rows = list(raw_cells) if raw_cells is not None else [] 

153 boxes: List[List[Optional[Tuple[float, float, float, float]]]] = [] 

154 for i, grid_row in enumerate(grid): 

155 raw_row = list(rows[i]) if i < len(rows) else [] 

156 out_row: List[Optional[Tuple[float, float, float, float]]] = [] 

157 for j in range(len(grid_row)): 

158 cell = raw_row[j] if j < len(raw_row) else None 

159 if cell is not None and all(hasattr(cell, a) for a in ("x1", "y1", "x2", "y2")): 

160 out_row.append((float(cell.x1), float(cell.y1), float(cell.x2), float(cell.y2))) 

161 else: 

162 out_row.append(None) 

163 boxes.append(out_row) 

164 return boxes 

165 

166 

167def df_to_markdown(df: DataFrame) -> str: 

168 return grid_to_markdown(df_to_cells(df)) 

169 

170 

171def camelot_worker(source_str: str, flavor: Flavor) -> List[CamelotCandidate]: 

172 """Top-level entrypoint for the spawned process. Imports camelot 

173 fresh in the child so OpenCV initialization stays inside the worker 

174 and never crosses a fork boundary. 

175 """ 

176 import camelot 

177 

178 from quber.agents.completeness import page_words 

179 from quber.core.extractors.camelot.tighten import tighten_cell_boxes 

180 

181 out: List[CamelotCandidate] = [] 

182 tables = camelot.read_pdf(source_str, pages="all", flavor=flavor) # pyright: ignore[reportPrivateImportUsage] 

183 # Word rectangles per page, read once and shared by every table on it. 

184 words_cache: dict[int, Tuple[float, List[Any]]] = {} 

185 for idx, table in enumerate(tables): 

186 raw_page = getattr(table, "page", 1) 

187 page = int(raw_page) if raw_page is not None else 1 

188 # camelot exposes the table box only as the private `_bbox`; read it 

189 # via getattr so the access is not flagged as private use. 

190 raw_bbox = getattr(table, "_bbox", None) 

191 bbox = tuple(raw_bbox) if raw_bbox else None 

192 report = getattr(table, "parsing_report", {}) or {} 

193 accuracy = float(report.get("accuracy", 0.0) or 0.0) 

194 cells = df_to_cells(table.df) 

195 cell_boxes = cells_to_boxes(getattr(table, "cells", None), cells) 

196 if page not in words_cache: 

197 _, page_h, words = page_words(Path(source_str), page) 

198 words_cache[page] = (page_h, words) 

199 page_h, words = words_cache[page] 

200 cell_boxes = tighten_cell_boxes(cells, cell_boxes, words, page_h) 

201 out.append( 

202 CamelotCandidate( 

203 candidate_id=f"{flavor}-p{page}-{idx}", 

204 flavor=flavor, 

205 page=page, 

206 bbox=bbox, 

207 accuracy=accuracy, 

208 cells=cells, 

209 cell_boxes=cell_boxes, 

210 markdown=grid_to_markdown(cells), 

211 ) 

212 ) 

213 return out 

214 

215 

216def run_camelot_flavors_parallel( 

217 source: Path, 

218 timeout_s: float = CAMELOT_FLAVOR_TIMEOUT_S, 

219) -> List[CamelotCandidate]: 

220 """Run lattice + stream concurrently in spawn-context subprocesses. 

221 

222 A flavor that times out or raises is logged ERROR and contributes 

223 zero candidates; the other flavor's output is still returned. If 

224 BOTH fail, returns an empty list — the caller decides what to do. 

225 """ 

226 ctx = mp.get_context("spawn") 

227 candidates: List[CamelotCandidate] = [] 

228 flavors: List[Flavor] = ["lattice", "stream"] 

229 

230 with ProcessPoolExecutor(max_workers=2, mp_context=ctx) as pool: 

231 futures = {flavor: pool.submit(camelot_worker, str(source), flavor) for flavor in flavors} 

232 for flavor, future in futures.items(): 

233 try: 

234 candidates.extend(future.result(timeout=timeout_s)) 

235 except FutureTimeoutError: 

236 logger.error( 

237 "camelot {flavor} timed out after {timeout}s on {source}", 

238 flavor=flavor, 

239 timeout=timeout_s, 

240 source=source.name, 

241 ) 

242 future.cancel() 

243 except Exception as exc: 

244 logger.error( 

245 "camelot {flavor} raised on {source}: {exc}", 

246 flavor=flavor, 

247 source=source.name, 

248 exc=exc, 

249 ) 

250 

251 if not candidates: 

252 logger.error("camelot produced 0 candidates from BOTH flavors on {}", source.name) 

253 return candidates 

254 

255 

256def is_content_empty(markdown: str) -> bool: 

257 """A Camelot output is content-empty when every non-delimiter cell 

258 is whitespace. Lattice's false-positive shells on whitespace-aligned 

259 PDFs look like a 1-row grid of empty pipes; this catches them 

260 before they reach the classifier or correction stage. 

261 """ 

262 if not markdown.strip(): 

263 return True 

264 for line in markdown.splitlines(): 

265 cells = [c.strip() for c in line.strip().strip("|").split("|")] 

266 if any(c and not set(c) <= {"-", " "} for c in cells): 

267 return False 

268 return True