Coverage for src / quber / core / extractors / camelot / llm / pipeline.py: 96%

164 statements  

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

1""" 

2The extraction pipeline as a pydantic-graph async graph. 

3 

4The pipeline is orchestrated as a pydantic-graph 

5[`GraphBuilder`](https://ai.pydantic.dev/graph/) async graph (the current 

6API; the ``Graph(nodes=[...])``/``BaseNode`` runner is deprecated). 

7Stage-to-step mapping: 

8 

9 start -> acquire page render ‖ Camelot flavors (concurrent), 

10 content-empty shells dropped 

11 -> map -> classify one step run per candidate 

12 -> join collect classifier decisions 

13 -> group_pages drop rejects, group survivors per page 

14 -> map -> unify_page one step run per page (single-candidate 

15 pages bypass the unifier) 

16 -> join flatten per-page rows 

17 -> log_unified log accepted -> unified table counts at 

18 the stage boundary 

19 -> map -> correct one step run per unified table 

20 -> join -> finalize deterministic (page, row) ordering 

21 -> end 

22 

23Per-stage fan-out happens through graph ``map`` edges (a task per item); 

24LLM concurrency stays bounded by per-stage semaphores carried in 

25`PipelineDeps`. Joins use ``preferred_parent_fork='closest'`` so each map 

26edge pairs with its own join. Keep that one-join-per-map shape: a join 

27fed by other joins across the broadcast fork fires before all branches 

28have finished, silently dropping results. 

29""" 

30 

31from __future__ import annotations 

32 

33import asyncio 

34from dataclasses import dataclass, field 

35from functools import cache 

36from pathlib import Path 

37from typing import List, Tuple 

38 

39from loguru import logger 

40from pydantic import BaseModel, ConfigDict 

41from pydantic_graph import GraphBuilder, StepContext, reduce_list_append, reduce_list_extend 

42from pydantic_graph.graph_builder import Graph 

43from pydantic_graph.id_types import JoinID, NodeID 

44 

45from quber.agents.classifier import ClassifierResult, TableClassifier 

46from quber.agents.llm_client import FootnoteDef, LLMClient 

47from quber.agents.unifier import CandidateInput, TableUnifier 

48from quber.core.extractors.base import ExtractedTable 

49from quber.core.extractors.camelot.acquire import ( 

50 CamelotCandidate, 

51 is_content_empty, 

52 render_pages, 

53 run_camelot_flavors_parallel, 

54) 

55 

56# --- Graph payload models (frozen: items flowing along graph edges) --------- 

57 

58 

59class SequencedCandidate(BaseModel): 

60 """A Camelot candidate plus its position in Camelot's output order. 

61 

62 `seq` exists because graph joins collect in completion order: without 

63 it, the unifier would see each page's candidates in a nondeterministic 

64 order, and the unifier LLM's output is sensitive to candidate order 

65 (observed on Visa Q2FY25 page 3: shuffled input produced digit-altered 

66 merges). Sorting on `seq` restores the sequential pipeline's exact 

67 LLM-input ordering. 

68 """ 

69 

70 model_config = ConfigDict(frozen=True) 

71 

72 seq: int 

73 candidate: CamelotCandidate 

74 

75 

76class ClassifiedCandidate(BaseModel): 

77 """A Camelot candidate paired with its classifier decision.""" 

78 

79 model_config = ConfigDict(frozen=True) 

80 

81 seq: int 

82 candidate: CamelotCandidate 

83 decision: ClassifierResult 

84 

85 

86class PageGroup(BaseModel): 

87 """All classifier-accepted candidates on one physical page.""" 

88 

89 model_config = ConfigDict(frozen=True) 

90 

91 page: int 

92 items: Tuple[ClassifiedCandidate, ...] 

93 

94 

95class UnifiedTableRow(BaseModel): 

96 """One canonical table emitted by the unify stage. 

97 

98 `page` + `row_idx` form the deterministic output ordering key — the 

99 graph's correction fan-out completes in arbitrary order, so the final 

100 step sorts on (page, row_idx) to reproduce the sequential pipeline's 

101 output order exactly. 

102 """ 

103 

104 model_config = ConfigDict(frozen=True) 

105 

106 page: int 

107 row_idx: int 

108 candidate: CamelotCandidate 

109 decision: ClassifierResult 

110 markdown: str 

111 source_ids: Tuple[str, ...] 

112 

113 

114class CorrectedTableItem(BaseModel): 

115 """A finished ExtractedTable plus its ordering key.""" 

116 

117 model_config = ConfigDict(frozen=True) 

118 

119 page: int 

120 row_idx: int 

121 table: ExtractedTable 

122 

123 

124# --- Graph state and dependencies ------------------------------------------- 

125 

126 

127@dataclass 

128class PipelineState: 

129 """Mutable run-scoped state. `page_images` is written once by the 

130 acquire step (single writer); the counters feed stage-boundary logs. 

131 """ 

132 

133 page_images: Tuple[Path, ...] = () 

134 accepted_count: int = 0 

135 

136 

137@dataclass(frozen=True) 

138class PipelineDeps: 

139 """Run-scoped clients + config carried into every graph step. 

140 

141 The per-stage semaphores preserve the pre-graph concurrency contract: 

142 the graph fans out one task per item, but LLM calls stay bounded at 

143 `max_concurrent` per stage. 

144 """ 

145 

146 llm: LLMClient 

147 classifier: TableClassifier 

148 unifier: TableUnifier 

149 source: Path 

150 tmp_dir: Path 

151 dpi: int 

152 run_llm_correction: bool 

153 flavor_timeout_s: float 

154 classify_sem: asyncio.Semaphore = field(repr=False) 

155 unify_sem: asyncio.Semaphore = field(repr=False) 

156 correct_sem: asyncio.Semaphore = field(repr=False) 

157 

158 

159# --- Graph steps ------------------------------------------------------------- 

160 

161 

162async def acquire_candidates( 

163 ctx: StepContext[PipelineState, PipelineDeps, Path], 

164) -> List[SequencedCandidate]: 

165 """Render page images and run both Camelot flavors concurrently, then 

166 drop content-empty shells before they reach the classifier. 

167 

168 The render and the Camelot subprocesses are independent I/O-bound 

169 work; overlapping them takes the render off the critical path (the 

170 sequential pipeline ran them back to back). 

171 """ 

172 deps = ctx.deps 

173 page_images, candidates = await asyncio.gather( 

174 asyncio.to_thread(render_pages, deps.source, deps.dpi, deps.tmp_dir), 

175 asyncio.to_thread(run_camelot_flavors_parallel, deps.source, deps.flavor_timeout_s), 

176 ) 

177 ctx.state.page_images = tuple(page_images) 

178 logger.info( 

179 "camelot produced {} candidates ({} lattice, {} stream) from {}", 

180 len(candidates), 

181 sum(1 for c in candidates if c.flavor == "lattice"), 

182 sum(1 for c in candidates if c.flavor == "stream"), 

183 deps.source.name, 

184 ) 

185 

186 populated = [c for c in candidates if not is_content_empty(c.markdown)] 

187 dropped = len(candidates) - len(populated) 

188 if dropped: 

189 logger.info("dropped {} content-empty candidates pre-classifier", dropped) 

190 return [SequencedCandidate(seq=seq, candidate=c) for seq, c in enumerate(populated)] 

191 

192 

193async def classify_candidate( 

194 ctx: StepContext[PipelineState, PipelineDeps, SequencedCandidate], 

195) -> ClassifiedCandidate: 

196 item = ctx.inputs 

197 async with ctx.deps.classify_sem: 

198 decision = await ctx.deps.classifier.classify(item.candidate.markdown) 

199 return ClassifiedCandidate(seq=item.seq, candidate=item.candidate, decision=decision) 

200 

201 

202async def group_pages( 

203 ctx: StepContext[PipelineState, PipelineDeps, List[ClassifiedCandidate]], 

204) -> List[PageGroup]: 

205 """Drop classifier rejects and group survivors per page (ascending). 

206 

207 Items are re-sorted on `seq` inside each page: the classify join 

208 collects in completion order, and the unifier LLM's behavior is 

209 order-sensitive, so each page must present its candidates in 

210 Camelot's original output order — exactly what the sequential 

211 pipeline fed it. 

212 """ 

213 accepted = sorted((item for item in ctx.inputs if item.decision.is_table), key=lambda item: item.seq) 

214 rejected = len(ctx.inputs) - len(accepted) 

215 logger.info("classifier accepted {} / rejected {}", len(accepted), rejected) 

216 ctx.state.accepted_count = len(accepted) 

217 

218 by_page: dict[int, List[ClassifiedCandidate]] = {} 

219 for item in accepted: 

220 by_page.setdefault(item.candidate.page, []).append(item) 

221 return [PageGroup(page=page, items=tuple(items)) for page, items in sorted(by_page.items())] 

222 

223 

224async def unify_page( 

225 ctx: StepContext[PipelineState, PipelineDeps, PageGroup], 

226) -> List[UnifiedTableRow]: 

227 """Unify the flavor outputs on one page into canonical tables. 

228 

229 Single-candidate pages bypass the unifier (nothing to combine); 

230 out-of-range pages are dropped with a warning, exactly like the 

231 sequential pipeline. 

232 """ 

233 group = ctx.inputs 

234 page_images = ctx.state.page_images 

235 if group.page < 1 or group.page > len(page_images): 

236 logger.warning("unify: page {} out of range; skipping", group.page) 

237 return [] 

238 

239 if len(group.items) == 1: 

240 item = group.items[0] 

241 return [ 

242 UnifiedTableRow( 

243 page=group.page, 

244 row_idx=0, 

245 candidate=item.candidate, 

246 decision=item.decision, 

247 markdown=item.candidate.markdown, 

248 source_ids=(item.candidate.candidate_id,), 

249 ) 

250 ] 

251 

252 page_image = page_images[group.page - 1] 

253 unifier_input = [ 

254 CandidateInput( 

255 candidate_id=item.candidate.candidate_id, 

256 markdown=item.candidate.markdown, 

257 bbox=item.candidate.bbox, 

258 ) 

259 for item in group.items 

260 ] 

261 async with ctx.deps.unify_sem: 

262 result = await ctx.deps.unifier.unify(page_image, unifier_input) 

263 

264 item_by_id = {item.candidate.candidate_id: item for item in group.items} 

265 rows: List[UnifiedTableRow] = [] 

266 for row_idx, table in enumerate(result.tables): 

267 primary_id = table.source_candidate_ids[0] if table.source_candidate_ids else None 

268 primary = item_by_id.get(primary_id) if primary_id else None 

269 if primary is None: 

270 primary = group.items[0] 

271 rows.append( 

272 UnifiedTableRow( 

273 page=group.page, 

274 row_idx=row_idx, 

275 candidate=primary.candidate, 

276 decision=primary.decision, 

277 markdown=table.markdown, 

278 source_ids=tuple(table.source_candidate_ids), 

279 ) 

280 ) 

281 return rows 

282 

283 

284async def log_unified( 

285 ctx: StepContext[PipelineState, PipelineDeps, List[UnifiedTableRow]], 

286) -> List[UnifiedTableRow]: 

287 """Stage boundary: all pages unified, corrections not yet started.""" 

288 logger.info( 

289 "unifier combined {} accepted -> {} unified tables", 

290 ctx.state.accepted_count, 

291 len(ctx.inputs), 

292 ) 

293 return ctx.inputs 

294 

295 

296async def correct_table( 

297 ctx: StepContext[PipelineState, PipelineDeps, UnifiedTableRow], 

298) -> CorrectedTableItem: 

299 """Per-table LLM structure correction. The unifier's markdown is the 

300 input; the page image is reference. Camelot-empty input -> no 

301 correction (strict source-of-truth, no image-reading rescue). 

302 """ 

303 row = ctx.inputs 

304 deps = ctx.deps 

305 page_images = ctx.state.page_images 

306 if row.page < 1 or row.page > len(page_images): 

307 page_image = None 

308 else: 

309 page_image = page_images[row.page - 1] 

310 

311 title = "" 

312 subtitle = "" 

313 footnotes: List[FootnoteDef] = [] 

314 corrected_md = row.markdown 

315 llm_corrected = False 

316 

317 if deps.run_llm_correction and page_image is not None and not is_content_empty(row.markdown): 

318 async with deps.correct_sem: 

319 try: 

320 correction = await deps.llm.correct_structure(page_image, row.markdown) 

321 except Exception as exc: 

322 logger.error( 

323 "LLM correction failed page={} candidate={}: {}", 

324 row.page, 

325 row.candidate.candidate_id, 

326 exc, 

327 ) 

328 correction = None 

329 if correction is not None: 

330 title = correction.title 

331 subtitle = "" 

332 footnotes = list(correction.footnotes) 

333 if correction.markdown and correction.markdown.strip(): 

334 corrected_md = correction.markdown 

335 llm_corrected = corrected_md != row.markdown 

336 

337 table = ExtractedTable( 

338 title=title, 

339 subtitle=subtitle, 

340 markdown=corrected_md, 

341 footnotes=footnotes, 

342 page=row.page, 

343 bbox=row.candidate.bbox, 

344 flavor=row.candidate.flavor, 

345 source=str(deps.source), 

346 camelot_accuracy=row.candidate.accuracy, 

347 classifier_decision=row.decision, 

348 llm_corrected=llm_corrected, 

349 ) 

350 return CorrectedTableItem(page=row.page, row_idx=row.row_idx, table=table) 

351 

352 

353async def finalize( 

354 ctx: StepContext[PipelineState, PipelineDeps, List[CorrectedTableItem]], 

355) -> List[ExtractedTable]: 

356 """Restore deterministic output order: pages ascending, unifier row 

357 order within a page (the corrections join collects in completion 

358 order). 

359 """ 

360 ordered = sorted(ctx.inputs, key=lambda item: (item.page, item.row_idx)) 

361 return [item.table for item in ordered] 

362 

363 

364@cache 

365def build_pipeline_graph() -> Graph[PipelineState, PipelineDeps, Path, List[ExtractedTable]]: 

366 """Build the extraction pipeline graph (cached; the graph is immutable 

367 and steps receive everything run-scoped via state/deps). 

368 

369 Topology notes: every map edge pairs with its own closest-fork join — 

370 see the module docstring for why this exact shape is load-bearing. 

371 """ 

372 g = GraphBuilder( 

373 name="camelot.llm_pipeline", 

374 state_type=PipelineState, 

375 deps_type=PipelineDeps, 

376 input_type=Path, 

377 output_type=List[ExtractedTable], 

378 ) 

379 

380 acquire_step = g.step(acquire_candidates) 

381 classify_step = g.step(classify_candidate) 

382 group_step = g.step(group_pages) 

383 unify_step = g.step(unify_page) 

384 log_unified_step = g.step(log_unified) 

385 correct_step = g.step(correct_table) 

386 finalize_step = g.step(finalize) 

387 

388 j_classified = g.join( 

389 reduce_list_append, 

390 initial_factory=list, 

391 node_id="j_classified", 

392 preferred_parent_fork="closest", 

393 ) 

394 j_rows = g.join( 

395 reduce_list_extend, 

396 initial_factory=list, 

397 node_id="j_rows", 

398 preferred_parent_fork="closest", 

399 ) 

400 j_corrected = g.join( 

401 reduce_list_append, 

402 initial_factory=list, 

403 node_id="j_corrected", 

404 preferred_parent_fork="closest", 

405 ) 

406 

407 g.add_edge(g.start_node, acquire_step) 

408 g.add_mapping_edge(acquire_step, classify_step, downstream_join_id=JoinID(NodeID("j_classified"))) 

409 g.add_edge(classify_step, j_classified) 

410 g.add_edge(j_classified, group_step) 

411 g.add_mapping_edge(group_step, unify_step, downstream_join_id=JoinID(NodeID("j_rows"))) 

412 g.add_edge(unify_step, j_rows) 

413 g.add_edge(j_rows, log_unified_step) 

414 g.add_mapping_edge(log_unified_step, correct_step, downstream_join_id=JoinID(NodeID("j_corrected"))) 

415 g.add_edge(correct_step, j_corrected) 

416 g.add_edge(j_corrected, finalize_step) 

417 g.add_edge(finalize_step, g.end_node) 

418 

419 return g.build()