Coverage for src / quber / processors / table_inference.py: 66%

196 statements  

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

1""" 

2Table Inference with LLM 

3 

4Uses PydanticAI with various model providers to analyze tables and generate 

5meaningful titles and descriptions based on document context. 

6""" 

7 

8import asyncio 

9import json 

10import os 

11from datetime import datetime 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any, Dict, List, Optional 

14 

15import logfire 

16from loguru import logger 

17from pydantic import BaseModel, Field 

18 

19if TYPE_CHECKING: 

20 from docling_core.types.doc.document import DoclingDocument, NodeItem 

21 

22from quber.agents import AgentFactory 

23from quber.agents.factory import ModelProvider 

24from quber.core.consolidation import HeaderConsolidator 

25from quber.core.models import TableMetadata 

26from quber.core.parsers import parser_for_preset 

27from quber.prompts import load_prompt 

28from quber.settings import Settings, get_settings 

29from quber.utils import setup_logging 

30 

31 

32def provenance_page(item: "NodeItem") -> Optional[int]: 

33 """The 1-indexed page an item sits on, taken from its docling provenance. 

34 

35 docling records an item's location in `item.prov` (a list of provenance 

36 spans, each with a `page_no`); the page lives there, not on a `_page` 

37 attribute. An item may carry several spans when it straddles a page break; 

38 the first span's page is used. Structural group nodes have no provenance 

39 and return None. `prov` is read via getattr because the base node type does 

40 not declare it (only laid-out items do).""" 

41 prov = getattr(item, "prov", None) 

42 if not prov: 

43 return None 

44 return prov[0].page_no 

45 

46 

47class TableContext(BaseModel): 

48 """Input context for table analysis.""" 

49 

50 executive_summary: str = Field(description="3-4 sentence document-level summary") 

51 procedural_title: str = Field(description="Title generated by procedural rules") 

52 headers: List[str] = Field(description="Individual headers found") 

53 descriptive_text: Optional[str] = Field(default=None, description="Text that describes the table") 

54 table_preview: str = Field(description="First few rows of the table") 

55 preceding_text: Optional[str] = Field(default=None, description="Text before the table") 

56 page_context: Optional[str] = Field( 

57 default=None, description="Extended page-level context including headers" 

58 ) 

59 page_number: int = Field(description="Page where table appears") 

60 

61 

62class TableInfo(BaseModel): 

63 """LLM-inferred information about a table.""" 

64 

65 title: str = Field(description="Clear, concise title for the table") 

66 description: str = Field( 

67 description="Functional description of what the table contains and how it might be used" 

68 ) 

69 

70 

71class ExecutiveSummary(BaseModel): 

72 """Executive summary of a document.""" 

73 

74 summary: str = Field(description="3-4 sentence executive summary of the document") 

75 

76 

77class TableInferenceProcessor: 

78 """Process tables with LLM inference for enhanced metadata.""" 

79 

80 def __init__( 

81 self, 

82 provider: Optional[ModelProvider] = None, 

83 model: Optional[str] = None, 

84 settings: Optional[Settings] = None, 

85 preset: str = "tuned-financial", 

86 ): 

87 """ 

88 Initialize the processor with specified configuration. 

89 

90 Args: 

91 provider: Model provider to use (auto-detected if not specified) 

92 model: Specific model to use (uses default if not specified) 

93 settings: Settings object (uses the cached get_settings() if not 

94 provided). The optional-injection seam lets callers/tests pass 

95 an overridden Settings (e.g. CLI flags). 

96 preset: DoclingParser preset to use ("tuned-financial" canonical 

97 default, or "legacy" for the pre-QUE-218 configuration). 

98 """ 

99 self.settings = settings or get_settings() 

100 self.provider = provider 

101 self.model = model 

102 self.preset = preset 

103 

104 # Set up logging 

105 setup_logging( 

106 service_name="table-inference", 

107 enable_logfire=self.settings.obs.enable_logfire, 

108 log_level=self.settings.log_level, 

109 ) 

110 

111 # Load system prompt from TOML file 

112 system_prompt = load_prompt("table_inference", "system") 

113 

114 # Create agent factory 

115 self.agent_factory = AgentFactory(enable_logfire=self.settings.obs.enable_logfire) 

116 

117 # Create the agent 

118 self.agent = self.agent_factory.create_agent( 

119 output_type=TableInfo, 

120 system_prompt=system_prompt, 

121 provider=provider, 

122 model=model, 

123 ) 

124 

125 logger.debug( 

126 f"Initialized TableInferenceProcessor with provider: {provider or 'auto'}, model: {model or 'default'}" 

127 ) 

128 

129 async def analyze_table( 

130 self, 

131 metadata: TableMetadata, 

132 page_context: Optional[str] = None, 

133 executive_summary: Optional[str] = None, 

134 ) -> Dict[str, Any]: 

135 """ 

136 Analyze a single table with LLM inference. 

137 

138 Args: 

139 metadata: Table metadata from enhanced parser 

140 page_context: Extended context from the page 

141 executive_summary: Document-level executive summary 

142 

143 Returns: 

144 Dictionary with both procedural and LLM-inferred information 

145 """ 

146 # Prepare context for LLM 

147 context = TableContext( 

148 executive_summary=executive_summary or "No document summary available", 

149 procedural_title=metadata.consolidated_header or "Untitled Table", 

150 headers=metadata.headers or [], 

151 descriptive_text=metadata.descriptive_text, 

152 table_preview=metadata.table_markdown or "", # Send full table for accurate analysis 

153 preceding_text=metadata.preceding_text, 

154 page_context=page_context, 

155 page_number=metadata.page_number, 

156 ) 

157 

158 # Get LLM inference with telemetry 

159 try: 

160 prompt = self.format_prompt(context) 

161 

162 if self.settings.obs.enable_logfire: 

163 with logfire.span("analyze_table", table_id=metadata.table_index, page=metadata.page_number): 

164 result = await self.agent.run(prompt) 

165 llm_info = result.output 

166 else: 

167 result = await self.agent.run(prompt) 

168 llm_info = result.output 

169 

170 logger.debug(f"Successfully analyzed table {metadata.table_index}") 

171 

172 except Exception as e: 

173 logger.error(f"LLM inference failed for table {metadata.table_index}: {e}") 

174 llm_info = TableInfo( 

175 title=metadata.consolidated_header or f"Table {metadata.table_index + 1}", 

176 description="Unable to generate description", 

177 ) 

178 

179 # Combine all information 

180 return { 

181 "table_id": metadata.table_index, 

182 "page": metadata.page_number, 

183 "procedural_title": metadata.consolidated_header, 

184 "headers": metadata.headers, 

185 "descriptive_text": metadata.descriptive_text, 

186 "preceding_text": metadata.preceding_text, 

187 "llm_title": llm_info.title, 

188 "llm_description": llm_info.description, 

189 "table_markdown": metadata.table_markdown, 

190 "metadata": { 

191 "rows": metadata.rows, 

192 "cols": metadata.cols, 

193 "first_cell": metadata.first_cell_content, 

194 }, 

195 } 

196 

197 async def analyze_tables_batch( 

198 self, 

199 metadata_list: List[TableMetadata], 

200 page_contexts: Optional[Dict[int, str]] = None, 

201 executive_summary: Optional[str] = None, 

202 max_concurrent: Optional[int] = None, 

203 ) -> List[Dict[str, Any]]: 

204 """ 

205 Analyze multiple tables concurrently with rate limiting. 

206 

207 Args: 

208 metadata_list: List of table metadata to analyze 

209 page_contexts: Optional dict mapping table indices to page contexts 

210 executive_summary: Document-level executive summary 

211 max_concurrent: Maximum concurrent analyses (uses config default if None) 

212 

213 Returns: 

214 List of analysis results 

215 """ 

216 max_concurrent = max_concurrent or self.settings.max_concurrent_tables 

217 page_contexts = page_contexts or {} 

218 

219 # Create semaphore for rate limiting 

220 semaphore = asyncio.Semaphore(max_concurrent) 

221 

222 async def analyze_with_limit(metadata: TableMetadata): 

223 async with semaphore: 

224 page_context = page_contexts.get(metadata.table_index) 

225 return await self.analyze_table(metadata, page_context, executive_summary) 

226 

227 # Process all tables concurrently with rate limiting 

228 tasks = [analyze_with_limit(metadata) for metadata in metadata_list] 

229 

230 with logfire.span( 

231 "analyze_tables_batch", total_tables=len(metadata_list), max_concurrent=max_concurrent 

232 ): 

233 results = await asyncio.gather(*tasks) 

234 

235 return results 

236 

237 def format_prompt(self, context: TableContext) -> str: 

238 """Format the context as a prompt for the agent.""" 

239 return f""" 

240Analyze this table from page {context.page_number}: 

241 

242=== DOCUMENT EXECUTIVE SUMMARY === 

243{context.executive_summary} 

244 

245=== EXTENDED PAGE CONTEXT === 

246{context.page_context or "No extended page context available"} 

247 

248=== IMMEDIATE TABLE CONTEXT === 

249Procedural Title: {context.procedural_title} 

250Headers Found: {", ".join(context.headers) if context.headers else "None"} 

251 

252Preceding Text (chronological order, furthest to closest): 

253{context.preceding_text or "None"} 

254 

255Note: The LAST element in preceding text may be a footnote from the previous table. 

256[Table] markers indicate preceding tables. 

257 

258=== TABLE DATA (may be truncated at page boundary) === 

259{context.table_preview} 

260 

261Based on ALL context levels (document summary, page context, procedural headers, and table data), 

262extract the most appropriate title and provide a functional description for what this COMPLETE table represents. 

263""" 

264 

265 def get_table_preview(self, markdown: Optional[str], max_rows: int = 10) -> str: 

266 """Get preview of table (first N rows).""" 

267 if not markdown: 

268 return "No table content available" 

269 

270 lines = markdown.strip().split("\n") 

271 preview_lines = lines[: min(max_rows + 2, len(lines))] # +2 for header and separator 

272 return "\n".join(preview_lines) 

273 

274 def extract_page_context( 

275 self, document: "DoclingDocument", page_number: int, char_limit: int = 2000 

276 ) -> str: 

277 """ 

278 Extract extended context from the page containing the table. 

279 

280 Args: 

281 document: The Docling document object 

282 page_number: The page number (1-indexed) where the table appears 

283 char_limit: Maximum characters to extract for context 

284 

285 Returns: 

286 String containing page-level context including headers and surrounding text 

287 """ 

288 context_parts: List[str] = [] 

289 char_count = 0 

290 

291 # Text on the table's own page. iterate_items() yields (item, level) 

292 # tuples; the page of each item comes from its provenance, and headers 

293 # carry a level. Text and level are read with getattr because only some 

294 # item types (text, headers) have them. 

295 current_page_text: List[str] = [] 

296 for item, _level in document.iterate_items(): 

297 if provenance_page(item) != page_number: 

298 continue 

299 text = (getattr(item, "text", "") or "").strip() 

300 if not text: 

301 continue 

302 level = getattr(item, "level", None) 

303 if level is not None: 

304 current_page_text.append(f"[Header L{level}] {text}") 

305 else: 

306 current_page_text.append(text) 

307 char_count += len(text) 

308 if char_count >= char_limit: 

309 break 

310 

311 # If we have room and the table is not on the first page, add a tail of 

312 # the previous page for lead-in context. 

313 if char_count < char_limit * 0.7 and page_number > 1: # Use 70% to leave room 

314 prev_page = page_number - 1 

315 prev_page_text: List[str] = [] 

316 for item, _level in document.iterate_items(): 

317 if provenance_page(item) != prev_page: 

318 continue 

319 text = (getattr(item, "text", "") or "").strip() 

320 if text: 

321 prev_page_text.append(text) 

322 

323 # Take last ~500 chars from previous page 

324 if prev_page_text: 

325 prev_text = "\n".join(prev_page_text[-5:]) # Last 5 text elements 

326 if len(prev_text) > 500: 

327 prev_text = "..." + prev_text[-500:] 

328 context_parts.append("=== Previous Page Context ===\n" + prev_text) 

329 

330 # Add current page context 

331 if current_page_text: 

332 context_parts.append( 

333 "=== Current Page Content ===\n" + "\n".join(current_page_text[:20]) 

334 ) # First 20 elements 

335 

336 return "\n\n".join(context_parts) if context_parts else "No page context available" 

337 

338 async def generate_executive_summary(self, document: "DoclingDocument") -> str: 

339 """ 

340 Generate a 3-4 sentence executive summary of the entire document. 

341 

342 Args: 

343 document: The Docling document object 

344 

345 Returns: 

346 Executive summary string 

347 """ 

348 # Export full document to markdown 

349 document_markdown = document.export_to_markdown() 

350 

351 # Create a specialized agent for executive summary generation 

352 summary_agent = self.agent_factory.create_agent( 

353 output_type=ExecutiveSummary, 

354 system_prompt="You are analyzing business documents to create executive summaries.", 

355 provider=self.provider, 

356 model=self.model, 

357 ) 

358 

359 # Create prompt for executive summary 

360 prompt = f""" 

361Analyze this complete document and provide a 3-4 sentence executive summary. 

362 

363The summary should describe: 

3641. What this document is (type of document, purpose) 

3652. Who it is about (company, organization, or subject) 

3663. What are the major points being made (key metrics, themes, or findings) 

367 

368=== DOCUMENT === 

369{document_markdown} 

370 

371Provide a concise executive summary that captures the essential nature and content of this document. 

372""" 

373 

374 try: 

375 if self.settings.obs.enable_logfire: 

376 with logfire.span("generate_executive_summary"): 

377 result = await summary_agent.run(prompt) 

378 return result.output.summary 

379 else: 

380 result = await summary_agent.run(prompt) 

381 return result.output.summary 

382 

383 except Exception as e: 

384 logger.error(f"Failed to generate executive summary: {e}") 

385 return "Unable to generate document summary" 

386 

387 async def process_document(self, pdf_path: "str | os.PathLike[str]") -> Dict[str, Any]: 

388 """ 

389 Process all tables in a document. 

390 

391 Args: 

392 pdf_path: Path to PDF document. Accepts a local path string or any 

393 os.PathLike (e.g. a cloudpathlib S3Path); Path(pdf_path) 

394 materializes the latter to a local cached file before parsing. 

395 

396 Returns: 

397 Dictionary with document and table information 

398 """ 

399 # str() keeps the label cheap for S3Path (its str is the s3:// URI, not 

400 # a download); os.path.basename(S3Path) would force materialization. 

401 doc_name = os.path.basename(str(pdf_path)) 

402 

403 # Log start of document processing 

404 logger.info(f"Starting document processing: {doc_name}") 

405 

406 # Parse into the canonical DoclingDocument, then consolidate headers 

407 # as a separate post-processing step (plan §4). 

408 parser = parser_for_preset(self.preset) 

409 consolidator = HeaderConsolidator(separator=" ^ ", enable_consolidation=True, debug=False) 

410 

411 with logfire.span("parse_document", document=str(pdf_path)): 

412 document = parser.parse(Path(pdf_path)).document 

413 table_metadata = consolidator.consolidate(document) 

414 

415 # Log completion of resource-intensive document parsing 

416 logger.info(f"Document parsed successfully: {doc_name}") 

417 

418 # Generate executive summary of the document 

419 logger.info(f"Generating executive summary for {doc_name}") 

420 executive_summary = await self.generate_executive_summary(document) 

421 logger.debug(f"Executive summary: {executive_summary[:100]}...") 

422 

423 # Log table analysis details 

424 if len(table_metadata) > 0: 

425 model_info = self.model or "default" 

426 if self.provider: 

427 model_info = f"{self.provider.value}:{model_info}" 

428 logger.info(f"Analyzing {len(table_metadata)} tables using {model_info}") 

429 

430 # Extract page contexts for all tables 

431 page_contexts = {} 

432 for metadata in table_metadata: 

433 page_contexts[metadata.table_index] = self.extract_page_context(document, metadata.page_number) 

434 

435 # Process all tables concurrently 

436 tables = await self.analyze_tables_batch(table_metadata, page_contexts, executive_summary) 

437 

438 if len(tables) > 0: 

439 logger.info(f"Analysis complete: {len(tables)} tables processed") 

440 

441 # Build final output 

442 return { 

443 "document": doc_name, 

444 "extraction_date": datetime.now().isoformat(), 

445 "total_pages": len(document.pages), 

446 "total_tables": len(tables), 

447 "model_provider": self.provider.value if self.provider else "auto-detected", 

448 "model": self.model or "default", 

449 "tables": tables, 

450 } 

451 

452 def save_json(self, data: Dict[str, Any], output_path: str): 

453 """Save results as JSON.""" 

454 with open(output_path, "w", encoding="utf-8") as f: 

455 json.dump(data, f, indent=2, ensure_ascii=False) 

456 logger.debug(f"JSON results saved to: {output_path}") 

457 

458 def save_summary(self, data: Dict[str, Any], output_path: str): 

459 """Save human-readable summary.""" 

460 lines = [] 

461 lines.append("# Table Analysis Report") 

462 lines.append("") 

463 lines.append(f"**Document:** {data['document']}") 

464 lines.append("") 

465 lines.append(f"**Date:** {data['extraction_date']}") 

466 lines.append("") 

467 lines.append(f"**Model:** {data.get('model_provider', 'unknown')}/{data.get('model', 'unknown')}") 

468 lines.append("") 

469 lines.append(f"**Total Tables:** {data['total_tables']}") 

470 lines.append("") 

471 

472 for table in data["tables"]: 

473 lines.append(f"\n## Table {table['table_id'] + 1} (Page {table['page']})") 

474 

475 # Show procedural information 

476 lines.append("\n### Procedural Extraction:") 

477 lines.append(f"- **Consolidated Headers:** {table['procedural_title'] or 'None'}") 

478 if table.get("headers"): 

479 lines.append(f"- **Individual Headers:** {', '.join(table['headers'])}") 

480 if table.get("descriptive_text"): 

481 lines.append(f"- **Descriptive Text:** {table['descriptive_text'][:200]}...") 

482 

483 # Show LLM analysis 

484 lines.append("\n### LLM Analysis:") 

485 lines.append(f"- **Title:** {table['llm_title']}") 

486 lines.append(f"- **Description:** {table['llm_description']}") 

487 

488 # Show metadata 

489 lines.append("\n### Metadata:") 

490 lines.append( 

491 f"- **Size:** {table['metadata']['rows']} rows × {table['metadata']['cols']} columns" 

492 ) 

493 if table["metadata"].get("first_cell"): 

494 lines.append(f"- **First Cell:** {table['metadata']['first_cell'][:50]}...") 

495 

496 # Show table preview (first few rows) 

497 if table.get("table_markdown"): 

498 lines.append("\n### Table Preview:") 

499 preview_lines = table["table_markdown"].split("\n")[:7] # First 7 lines 

500 for line in preview_lines: 

501 lines.append(line) 

502 if len(table["table_markdown"].split("\n")) > 7: 

503 lines.append("| ... | ... | (additional rows omitted) |") 

504 

505 lines.append("\n---") 

506 

507 with open(output_path, "w", encoding="utf-8") as f: 

508 f.write("\n".join(lines)) 

509 logger.debug(f"Summary saved to: {output_path}")