Coverage for src / quber / cli.py: 28%

570 statements  

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

1""" 

2Command-line interface for Quber. 

3 

4Four commands compose into the extraction pipeline: `document`, `table`, `fuse`, 

5`figure`. They hand work to each other through files rather than in memory, and 

6each names its output after the source document's stem, so one stage's 

7`--output-dir` is the next stage's input directory. A stage that runs to 

8completion also writes a `<base>.<stage>.complete.json` marker, which is how a 

9cloud job reports it finished. 

10 

11`document` parses the PDF and writes `<base>.docling.json` with 

12`<base>.confidence.json` and `<base>.cells.json` beside it. All three are always 

13written, because the later stages reload them together through 

14`ParseResult.load`. Markdown and split-page HTML are optional extras. 

15 

16`table` extracts the tables and writes `<base>.tables.json`, plus 

17`<base>.flags.json` when any cell carries a flagged decision. 

18 

19`fuse` merges the two into `<base>.unified.json` and `<base>.unified.md`, with 

20`<base>.cells.json` beside them — the parse's positioned page cells, which the 

21figure stage's value reconciliation requires next to the parse it is handed. By 

22default it runs both engines in-process. Given `--artifacts-dir` it reloads 

23`<base>.{docling,confidence,cells}.json` and `<base>.tables.json` from a prior 

24run and only fuses, which is how the cloud path splits the work across jobs. 

25 

26`figure` runs last, after fusion, so its enrichment lands in the document 

27everything downstream reads. It loads the parse back — `<base>.unified.json` 

28first, then `<base>.docling.json` — reads the pages holding charts, pictures, 

29and image-printed tables, and writes the enriched parse back under the filename 

30it read. Alongside it go `<base>.figures.json`, `<base>.scanned-tables.json`, 

31`<base>.figure-values.json` — each plotted value reconciled between the scan's 

32reading and a local read grounded in `<base>.cells.json`, with a status and a 

33page box per value — and one `<base>.p<N>.ade.json` per page, so a re-run 

34reuses a page already paid for instead of submitting it again. 

35 

36`ade`, `analyze`, `batch`, `dual`, and `validate` are standalone and not part of 

37that chain. `playground` serves the answering web app in `quber.playground`, 

38which runs `fuse`, `figure`, and its own ingest as subprocesses when a document 

39is uploaded. 

40""" 

41 

42import asyncio 

43import json 

44import sys 

45import tempfile 

46import time 

47from importlib.metadata import version 

48from pathlib import Path 

49from typing import Optional 

50 

51import click 

52from loguru import logger 

53 

54from quber.agents.factory import ModelConfig, ModelProvider 

55from quber.agents.llm_client import LLMClient 

56from quber.files.cache import resolve_document 

57from quber.files.output import output_sink, write_completion_marker 

58from quber.settings import get_settings 

59 

60# `TableInferenceProcessor` and the docling-backed parsers are imported lazily 

61# inside the commands that use them (`analyze`, `batch`, `document`). Importing 

62# them at module load would pull the GPU/ML stack into every `quber` invocation, 

63# including the CPU-only `table` cloud job that needs none of it. 

64 

65 

66@click.group() 

67@click.version_option(version=version("quber")) 

68def cli(): 

69 """Quber - Advanced table extraction and inference system.""" 

70 pass 

71 

72 

73@cli.command() 

74@click.argument("pdf_path") 

75@click.option("--output-dir", "-o", default="output", help="Output directory (default: output)") 

76@click.option( 

77 "--provider", 

78 "-p", 

79 type=click.Choice([p.value for p in ModelProvider]), 

80 help="Model provider to use (auto-detected if not specified)", 

81) 

82@click.option("--model", "-m", help="Specific model to use (uses provider default if not specified)") 

83@click.option("--max-concurrent", default=5, type=int, help="Maximum concurrent table analyses (default: 5)") 

84@click.option( 

85 "--preset", 

86 type=click.Choice(["tuned-financial", "legacy"]), 

87 default="tuned-financial", 

88 help="DoclingParser configuration preset (default: tuned-financial; 'legacy' = pre-QUE-218)", 

89) 

90@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") 

91def analyze( 

92 pdf_path: str, 

93 output_dir: str, 

94 provider: Optional[str], 

95 model: Optional[str], 

96 max_concurrent: int, 

97 preset: str, 

98 verbose: bool, 

99): 

100 """ 

101 Analyze tables in a PDF document with LLM inference. 

102 

103 Example: 

104 quber analyze document.pdf 

105 quber analyze document.pdf --provider anthropic --model claude-3-opus-20240229 

106 """ 

107 # Configure logging 

108 log_level = "DEBUG" if verbose else "INFO" 

109 logger.remove() 

110 logger.add(sys.stderr, level=log_level) 

111 

112 # Load configuration; override the runtime knobs from CLI flags. 

113 settings = get_settings().model_copy( 

114 update={"log_level": log_level, "max_concurrent_tables": max_concurrent} 

115 ) 

116 

117 # Convert provider string to enum if provided 

118 provider_enum = ModelProvider(provider) if provider else None 

119 

120 # Create processor 

121 from quber.processors import TableInferenceProcessor 

122 

123 try: 

124 processor = TableInferenceProcessor( 

125 provider=provider_enum, 

126 model=model, 

127 settings=settings, 

128 preset=preset, 

129 ) 

130 except ValueError as e: 

131 click.echo(f"Error: {e}", err=True) 

132 sys.exit(1) 

133 

134 # Resolve to a local path: local stays local; s3:// materializes through 

135 # the persistent cache (logs an INFO cache-hit line when already cached). 

136 doc = resolve_document(pdf_path) 

137 

138 # Process document 

139 click.echo(f"\nAnalyzing: {pdf_path}") 

140 

141 async def run_analysis(): 

142 try: 

143 results = await processor.process_document(doc) 

144 

145 # Create output directory 

146 output_path = Path(output_dir) 

147 output_path.mkdir(parents=True, exist_ok=True) 

148 

149 # Generate filenames from PDF basename 

150 base_name = doc.stem 

151 json_file = output_path / f"{base_name}_analysis.json" 

152 summary_file = output_path / f"{base_name}_summary.md" 

153 

154 # Save files 

155 processor.save_json(results, str(json_file)) 

156 processor.save_summary(results, str(summary_file)) 

157 

158 return results, json_file, summary_file 

159 except Exception as e: 

160 logger.error(f"Analysis failed: {e}") 

161 raise 

162 

163 results, json_file, summary_file = asyncio.run(run_analysis()) 

164 

165 # Display summary 

166 click.echo("\n✓ Analysis complete") 

167 click.echo(f" - Found {results['total_tables']} tables") 

168 click.echo(f" - JSON output: {json_file}") 

169 click.echo(f" - Summary: {summary_file}") 

170 

171 

172@cli.command() 

173@click.argument("directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) 

174@click.option("--output-dir", "-o", default="output", help="Output directory for results (default: output)") 

175@click.option( 

176 "--provider", "-p", type=click.Choice([p.value for p in ModelProvider]), help="Model provider to use" 

177) 

178@click.option("--model", "-m", help="Specific model to use") 

179@click.option( 

180 "--max-concurrent", 

181 default=5, 

182 type=int, 

183 help="Maximum concurrent table analyses per document (default: 5)", 

184) 

185@click.option("--pattern", default="*.pdf", help="File pattern to match (default: *.pdf)") 

186def batch( 

187 directory: str, 

188 output_dir: str, 

189 provider: Optional[str], 

190 model: Optional[str], 

191 max_concurrent: int, 

192 pattern: str, 

193): 

194 """ 

195 Process multiple PDF documents in a directory. 

196 

197 Example: 

198 quber batch documents/ 

199 quber batch documents/ --pattern "*.pdf" --output-dir results/ 

200 """ 

201 # Load configuration; override max concurrency from the CLI flag. 

202 settings = get_settings().model_copy(update={"max_concurrent_tables": max_concurrent}) 

203 

204 # Find PDF files 

205 dir_path = Path(directory) 

206 pdf_files = list(dir_path.glob(pattern)) 

207 

208 if not pdf_files: 

209 click.echo(f"No files found matching pattern: {pattern}", err=True) 

210 sys.exit(1) 

211 

212 click.echo(f"Found {len(pdf_files)} PDF files to process") 

213 

214 # Create output directory 

215 output_path = Path(output_dir) 

216 output_path.mkdir(parents=True, exist_ok=True) 

217 

218 # Convert provider string to enum if provided 

219 provider_enum = ModelProvider(provider) if provider else None 

220 

221 # Create processor 

222 from quber.processors import TableInferenceProcessor 

223 

224 try: 

225 processor = TableInferenceProcessor(provider=provider_enum, model=model, settings=settings) 

226 except ValueError as e: 

227 click.echo(f"Error: {e}", err=True) 

228 sys.exit(1) 

229 

230 # Process each file 

231 async def process_batch(): 

232 results = [] 

233 for pdf_file in pdf_files: 

234 click.echo(f"\nProcessing: {pdf_file.name}") 

235 try: 

236 result = await processor.process_document(str(pdf_file)) 

237 

238 # Save outputs 

239 base_name = pdf_file.stem 

240 json_path = output_path / f"{base_name}_analysis.json" 

241 summary_path = output_path / f"{base_name}_summary.md" 

242 

243 processor.save_json(result, str(json_path)) 

244 processor.save_summary(result, str(summary_path)) 

245 

246 click.echo(f" ✓ {result['total_tables']} tables analyzed") 

247 results.append(result) 

248 

249 except Exception as e: 

250 click.echo(f" ✗ Failed: {e}", err=True) 

251 logger.error(f"Failed to process {pdf_file}: {e}") 

252 

253 return results 

254 

255 results = asyncio.run(process_batch()) 

256 

257 # Summary 

258 total_documents = len(results) 

259 total_tables = sum(r["total_tables"] for r in results) 

260 

261 click.echo("\n✓ Batch processing complete!") 

262 click.echo(f" - Processed {total_documents}/{len(pdf_files)} documents") 

263 click.echo(f" - Analyzed {total_tables} total tables") 

264 click.echo(f" - Results saved to: {output_dir}/") 

265 

266 

267@cli.command() 

268@click.option( 

269 "--provider", 

270 "-p", 

271 type=click.Choice([p.value for p in ModelProvider]), 

272 help="Show models for specific provider only", 

273) 

274@click.option("--all", "-a", is_flag=True, help="Show all models (can be very long)") 

275def models(provider: Optional[str], all: bool): 

276 """List available models for each provider.""" 

277 from quber.agents import AgentFactory 

278 

279 factory = AgentFactory(enable_logfire=False) 

280 

281 # Get available providers (with API keys) 

282 available = factory.get_available_providers() 

283 

284 # Filter to specific provider if requested 

285 if provider: 

286 providers_to_show = [ModelProvider(provider)] 

287 else: 

288 providers_to_show = list(ModelProvider) 

289 

290 click.echo("Model Providers and Available Models:") 

291 click.echo("=" * 50) 

292 click.echo("\nNote: You can use any model name directly, even if not listed.") 

293 click.echo("The list below shows models recognized by the installed pydantic-ai.\n") 

294 

295 for p in providers_to_show: 

296 models = factory.list_available_models(p) 

297 is_available = p.value in available 

298 

299 status = "✓ (API key found)" if is_available else "✗ (no API key)" 

300 click.echo(f"\n{p.value.upper()} {status}") 

301 click.echo("-" * 40) 

302 

303 model_list = models[p.value] 

304 

305 # Show first 10 models by default, all if --all flag is used 

306 if not all and len(model_list) > 10: 

307 display_models = model_list[:10] 

308 remaining = len(model_list) - 10 

309 else: 

310 display_models = model_list 

311 remaining = 0 

312 

313 for model in display_models: 

314 default = " (default)" if model == ModelConfig.DEFAULT_MODELS.get(p) else "" 

315 # Truncate very long model names 

316 if len(model) > 60: 

317 model_display = model[:57] + "..." 

318 else: 

319 model_display = model 

320 click.echo(f" • {model_display}{default}") 

321 

322 if remaining > 0: 

323 click.echo(f" ... and {remaining} more models (use --all to see all)") 

324 

325 if not available: 

326 click.echo("\n⚠ No API keys found. Please set environment variables:") 

327 for p in ModelProvider: 

328 key_var = ModelConfig.API_KEY_VARS.get(p) 

329 if key_var: 

330 click.echo(f" • {key_var} (for {p.value})") 

331 

332 

333@cli.group() 

334def db(): 

335 """Database management commands.""" 

336 pass 

337 

338 

339@db.command() 

340@click.option("--drop", is_flag=True, help="Drop existing tables before creating (WARNING: data loss)") 

341@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") 

342def init(drop: bool, verbose: bool): 

343 """Initialize the database schema.""" 

344 from quber.db.connection import get_engine, init_db 

345 

346 log_level = "DEBUG" if verbose else "INFO" 

347 logger.remove() 

348 logger.add(sys.stderr, level=log_level) 

349 

350 click.echo("Initializing database...") 

351 try: 

352 engine = get_engine() 

353 init_db(engine, drop_all=drop) 

354 click.echo("✓ Database initialized successfully") 

355 except Exception as e: 

356 click.echo(f"✗ Failed to initialize database: {e}", err=True) 

357 sys.exit(1) 

358 

359 

360@db.command() 

361@click.argument("json_path", type=click.Path(exists=True)) 

362@click.option("--generate-embeddings", is_flag=True, help="Generate embeddings during import") 

363@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") 

364def import_json(json_path: str, generate_embeddings: bool, verbose: bool): 

365 """Import a single JSON analysis file into the database.""" 

366 from quber.db.importer import import_json_file 

367 

368 log_level = "DEBUG" if verbose else "INFO" 

369 logger.remove() 

370 logger.add(sys.stderr, level=log_level) 

371 

372 try: 

373 doc, table_count = import_json_file(json_path, generate_embeddings=generate_embeddings) 

374 emb_msg = " with embeddings" if generate_embeddings else "" 

375 click.echo(f"✓ Imported {doc.filename}: {table_count} tables{emb_msg}") 

376 except Exception as e: 

377 click.echo(f"✗ Import failed: {e}", err=True) 

378 sys.exit(1) 

379 

380 

381@db.command() 

382@click.argument("directory", type=click.Path(exists=True, file_okay=False, dir_okay=True)) 

383@click.option("--pattern", default="*_analysis.json", help="File pattern to match (default: *_analysis.json)") 

384@click.option("--generate-embeddings", is_flag=True, help="Generate embeddings during import") 

385@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") 

386def import_dir(directory: str, pattern: str, generate_embeddings: bool, verbose: bool): 

387 """Import all JSON analysis files from a directory.""" 

388 from quber.db.importer import import_directory 

389 

390 log_level = "DEBUG" if verbose else "INFO" 

391 logger.remove() 

392 logger.add(sys.stderr, level=log_level) 

393 

394 try: 

395 docs, total_tables = import_directory( 

396 directory, pattern=pattern, generate_embeddings=generate_embeddings 

397 ) 

398 emb_msg = " with embeddings" if generate_embeddings else "" 

399 click.echo(f"✓ Imported {len(docs)} documents with {total_tables} total tables{emb_msg}") 

400 except Exception as e: 

401 click.echo(f"✗ Import failed: {e}", err=True) 

402 sys.exit(1) 

403 

404 

405@db.command() 

406@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") 

407def stats(verbose: bool): 

408 """Show database statistics.""" 

409 from quber.db.importer import get_import_stats 

410 

411 log_level = "DEBUG" if verbose else "INFO" 

412 logger.remove() 

413 logger.add(sys.stderr, level=log_level) 

414 

415 try: 

416 stats_data = get_import_stats() 

417 

418 click.echo("\nDatabase Statistics") 

419 click.echo("=" * 50) 

420 click.echo(f"Total documents: {stats_data['total_documents']}") 

421 click.echo(f"Total tables: {stats_data['total_tables']}") 

422 click.echo(f"Avg tables/doc: {stats_data['avg_tables_per_doc']}") 

423 

424 if stats_data["documents"]: 

425 click.echo("\nDocuments:") 

426 for doc in stats_data["documents"]: 

427 click.echo(f" • {doc['filename']}: {doc['tables']} tables, {doc['pages']} pages") 

428 

429 except Exception as e: 

430 click.echo(f"✗ Failed to retrieve stats: {e}", err=True) 

431 sys.exit(1) 

432 

433 

434@db.command() 

435@click.option( 

436 "--provider", type=click.Choice(["local", "openai"]), help="Embedding provider (default: from env)" 

437) 

438@click.option("--device", type=click.Choice(["cuda", "cpu"]), help="Device for local model (default: cuda)") 

439@click.option("--batch-size", default=32, type=int, help="Batch size for processing (default: 32)") 

440@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging") 

441def generate_embeddings(provider: Optional[str], device: Optional[str], batch_size: int, verbose: bool): 

442 """Generate embeddings for all tables in the database.""" 

443 from quber.db import ExtractedTable, get_embedding_service, get_session 

444 

445 log_level = "DEBUG" if verbose else "INFO" 

446 logger.remove() 

447 logger.add(sys.stderr, level=log_level) 

448 

449 try: 

450 click.echo("Initializing embedding service...") 

451 embedding_service = get_embedding_service(provider=provider, device=device) 

452 

453 click.echo("Loading tables from database...") 

454 with get_session() as session: 

455 tables = session.query(ExtractedTable).all() 

456 total = len(tables) 

457 

458 if total == 0: 

459 click.echo("No tables found in database") 

460 return 

461 

462 click.echo(f"Found {total} tables to process") 

463 

464 # Process in batches 

465 updated = 0 

466 for i, table in enumerate(tables, 1): 

467 if table.llm_title and table.llm_description: 

468 # Generate embedding 

469 embedding = embedding_service.embed_table_metadata(table.llm_title, table.llm_description) 

470 

471 # Update both embedding fields 

472 table.title_embedding = embedding.tolist() 

473 table.description_embedding = embedding.tolist() 

474 updated += 1 

475 

476 if i % batch_size == 0: 

477 session.commit() 

478 click.echo(f" Processed {i}/{total} tables...") 

479 

480 # Final commit 

481 session.commit() 

482 click.echo(f"✓ Generated embeddings for {updated}/{total} tables") 

483 

484 except Exception as e: 

485 click.echo(f"✗ Failed to generate embeddings: {e}", err=True) 

486 logger.exception(e) 

487 sys.exit(1) 

488 

489 

490@cli.command(name="document") 

491@click.argument("pdf_path") 

492@click.option("--output-dir", "-o", default="output", help="Output directory (local path or s3:// prefix)") 

493@click.option( 

494 "--format", 

495 "fmt", 

496 type=click.Choice(["markdown", "json", "both"]), 

497 default="both", 

498 help="Output format(s) for the parsed DoclingDocument", 

499) 

500@click.option( 

501 "--html", 

502 is_flag=True, 

503 help="Also export html_split_page alongside markdown/json (matches docling CLI --to html_split_page)", 

504) 

505@click.option( 

506 "--image-mode", 

507 type=click.Choice(["embedded", "referenced", "placeholder"]), 

508 default="referenced", 

509 help="How images are written in the markdown export (default: referenced, matching tuned-financial canonical CLI)", 

510) 

511@click.option( 

512 "--preset", 

513 type=click.Choice(["tuned-financial", "legacy"]), 

514 default="tuned-financial", 

515 help="DoclingParser configuration preset (default: tuned-financial; 'legacy' = pre-QUE-218)", 

516) 

517@click.option( 

518 "--device", 

519 type=click.Choice(["auto", "cuda", "cpu", "mps"]), 

520 default="auto", 

521 help="Accelerator device. Default 'auto' resolves to cuda when available, otherwise cpu (so an Intel MacBook 'just works', slower).", 

522) 

523def parse( 

524 pdf_path: str, 

525 output_dir: str, 

526 fmt: str, 

527 html: bool, 

528 image_mode: str, 

529 preset: str, 

530 device: str, 

531): 

532 """ 

533 Run the `Parser` path: source PDF -> DoclingDocument. 

534 

535 Writes the document as markdown and/or JSON. No LLM inference. 

536 

537 By default uses the canonical `tuned-financial` configuration adopted in 

538 QUE-218 (TableFormerMode.ACCURATE, DoclingParse backend, OCR enabled, 

539 picture classification, page_batch_size=32) with `--device auto` resolving 

540 to CUDA when available (QUE-219). Pass `--preset legacy` to revert to 

541 the pre-QUE-218 behaviour, or `--device cpu` to force CPU on a GPU host. 

542 """ 

543 from docling.datamodel.accelerator_options import AcceleratorDevice 

544 from docling_core.types.doc.base import ImageRefMode 

545 

546 from quber.core.parsers import parser_for_preset 

547 

548 parser = parser_for_preset( 

549 preset, 

550 accelerator_device=AcceleratorDevice(device), 

551 ) 

552 # Resolve to a local path before docling: local stays local; s3:// 

553 # materializes through the persistent cache (so docling's str(source) 

554 # sees the cached local file, not the bare s3:// URI). Logs an INFO 

555 # cache-hit line when the document is already cached. 

556 doc = resolve_document(pdf_path) 

557 

558 click.echo(f"Parsing ({preset}, device={device}): {pdf_path}") 

559 result = parser.parse(doc) 

560 document = result.document 

561 

562 base = doc.stem 

563 image_ref_mode = ImageRefMode(image_mode) 

564 

565 # `output_sink` writes locally when `output_dir` is a path, and uploads the 

566 # artifacts (including any referenced-image sidecar folder) to the bucket 

567 # when it is an `s3://` prefix. 

568 with output_sink(output_dir) as out: 

569 # The document step's artifact contract: docling.json + confidence.json 

570 # (per-page scores + per-table OCR/native provenance) + cells.json. Always 

571 # written so the fusion step (and a later cloud job) can reload via 

572 # ParseResult.load. Markdown and split-page HTML are optional extras. 

573 artifacts = result.save(out, base) 

574 click.echo(f" - JSON: {artifacts['document']}") 

575 click.echo(f" - Confidence/provenance: {artifacts['confidence']}") 

576 click.echo(f" - Parsed cells: {artifacts['cells']}") 

577 

578 if fmt in ("markdown", "both"): 

579 md_path = out / f"{base}.docling.md" 

580 document.save_as_markdown(md_path, image_mode=image_ref_mode) 

581 click.echo(f" - Markdown: {md_path}") 

582 if html: 

583 html_path = out / f"{base}.docling.html" 

584 document.save_as_html(html_path, image_mode=image_ref_mode, split_page_view=True) 

585 click.echo(f" - HTML (split-page): {html_path}") 

586 

587 click.echo(f" - Pages: {len(document.pages)}") 

588 click.echo(f" - Tables: {len(document.tables)}") 

589 ocr_tables = sum(1 for p in result.table_provenance if p.verdict == "ocr") 

590 click.echo(f" - Tables read by OCR (rendered as image): {ocr_tables}") 

591 

592 

593@cli.command(name="ade") 

594@click.argument("source") 

595@click.option("--output-dir", "-o", default="output", help="Output directory (local path or s3:// prefix)") 

596@click.option( 

597 "--model", 

598 default=None, 

599 help="ADE parse model (default: the generally-available dpt-2; dpt-3 preview names route to the v2 API)", 

600) 

601@click.option("--page", type=int, default=None, help="Submit only this 1-based page of a PDF") 

602def ade(source: str, output_dir: str, model: Optional[str], page: Optional[int]): 

603 """ 

604 Parse a PDF or image through Landing.AI ADE. 

605 

606 A standalone extraction path for what docling handles poorly: charts 

607 (the parse reads a chart's plotted values off the page) and rasterized 

608 pages with no native text layer. Writes the raw parse response and the 

609 parse markdown, then a completion marker. 

610 

611 To read the charts in a document into its parse, use `quber figure`, which 

612 scans only the pages that hold one. 

613 """ 

614 from quber.providers.landing import run_parse 

615 from quber.providers.landing.client import DEFAULT_MODEL 

616 

617 summary = run_parse(source, output_dir=output_dir, model=model or DEFAULT_MODEL, page=page) 

618 click.echo(f"ADE parse ({summary.model_version}): {source}" + (f" page {page}" if page else "")) 

619 for name in summary.artifacts: 

620 click.echo(f" - {name}") 

621 click.echo(f" - Pages: {summary.pages}") 

622 click.echo(f" - Credits consumed: {summary.credits}") 

623 

624 

625@cli.command(name="figure") 

626@click.argument("source") 

627@click.option( 

628 "--parse", 

629 required=True, 

630 help="The parse to enrich: a DoclingDocument JSON file, or a directory holding " 

631 "one, searched for <base>.unified.json then <base>.docling.json. The enriched " 

632 "parse is written back under the same filename, so pointing --output-dir at the " 

633 "directory it came from replaces it", 

634) 

635@click.option("--output-dir", "-o", default="output", help="Output directory (local path or s3:// prefix)") 

636@click.option( 

637 "--model", 

638 default=None, 

639 help="ADE scan model. Defaults to the pinned dpt-3 version; 'dpt-3' selects that " 

640 "same pin, 'dpt-2' reverts to the previous line, and any exact ADE model name " 

641 "passes through as given", 

642) 

643@click.option( 

644 "--pages", 

645 type=click.Choice(["nominated", "all"]), 

646 default="nominated", 

647 help="Page breadth: 'nominated' scans the pages the parse points at; 'all' scans " 

648 "every page, for documents whose content the parse cannot be trusted to point at", 

649) 

650def figure(source: str, parse: str, output_dir: str, model: Optional[str], pages: str): 

651 """ 

652 Read the imagery in a document into its parse. 

653 

654 Three things a page prints are read by nothing else in the pipeline: the 

655 values plotted in a chart, the content of any other picture, and a table 

656 printed as an image, whose cells the parse read off the page itself with 

657 nothing having checked it since. This reads all three through Landing.AI ADE. 

658 

659 The parse nominates the pages holding them, and every nominated page is 

660 scanned; --pages all widens the run to every page of the document. Each page 

661 is submitted whole, so a chart's title above it and the note at the foot 

662 of the page come back with it, and a table comes back as a grid with a box on 

663 every cell, which replaces the parse's reading of it. 

664 

665 Independent of the rest of the pipeline: in, the parse and the source 

666 document; out, the same parse enriched, and the page scans. Writes the raw 

667 response per page scanned, the figure records, the enriched parse under the 

668 filename it was read from, then a completion marker. A page already scanned 

669 is read from its stored response instead of being paid for again. 

670 

671 Run it after `quber fuse` so the enrichment lands in the document the rest 

672 of the pipeline reads. 

673 """ 

674 from quber.agents.figure_values import get_figure_value_reader, get_scan_value_parser 

675 from quber.core.figures.orchestrator import load_document, run_figures_sync, write_marker 

676 from quber.core.figures.scan import SCAN_DEFAULT_MODEL 

677 

678 # 'dpt-3' is the CLI spelling of the pinned scan default; the API itself 

679 # rejects the bare name, so it never passes through by accident. 

680 if model is None or model.lower() == "dpt-3": 

681 model = SCAN_DEFAULT_MODEL 

682 

683 local = resolve_document(source) 

684 base = local.stem 

685 document, parse_name = load_document(parse, base) 

686 

687 # The parse's positioned cells are the fragment source for figure-value 

688 # reconciliation. They sit beside the parse under <base>.cells.json; a 

689 # parse directory without them runs the workflow with the values step off. 

690 parse_dir = Path(parse) if Path(parse).is_dir() else Path(parse).parent 

691 cells_path = parse_dir / f"{base}.cells.json" 

692 cells_pages = None 

693 if cells_path.exists(): 

694 cells_pages = {p["page_no"]: p for p in json.loads(cells_path.read_text(encoding="utf-8"))} 

695 else: 

696 logger.warning("No {} beside the parse; figure values skipped", cells_path.name) 

697 

698 started = time.time() 

699 try: 

700 result = run_figures_sync( 

701 local, 

702 document, 

703 base, 

704 parse_name, 

705 output_dir=output_dir, 

706 model=model, 

707 pages=pages, 

708 cells_pages=cells_pages, 

709 value_parser=get_scan_value_parser(), 

710 value_reader=get_figure_value_reader(), 

711 ) 

712 except Exception as exc: 

713 write_marker( 

714 output_dir, 

715 base, 

716 { 

717 "status": "failed", 

718 "document": base, 

719 "stage": "figure", 

720 "model": model, 

721 "error": f"{type(exc).__name__}: {exc}", 

722 "total_seconds": round(time.time() - started, 1), 

723 }, 

724 ) 

725 raise 

726 

727 run = result.run 

728 click.echo(f"Figure scan: {source}") 

729 for name in result.artifacts: 

730 click.echo(f" - {name}") 

731 click.echo(f" - Pages nominated: {run.nominated}") 

732 click.echo(f" - Pages scanned: {run.scanned} ({run.submitted} submitted this run)") 

733 click.echo(f" - Pages returning a figure: {run.with_figures}") 

734 click.echo(f" - Figure records: {len(run.figures)}") 

735 click.echo(f" - Tables read off a page image: {len(result.tables)}") 

736 if result.values is not None: 

737 click.echo( 

738 f" - Figure values: {len(result.values.values)} " 

739 f"({result.values.reconciled} reconciled, {result.values.flagged} flagged)" 

740 ) 

741 click.echo(f" - Credits consumed: {run.credits}") 

742 for err in run.errors: 

743 click.echo(f" - ERROR: {err}") 

744 

745 write_marker( 

746 output_dir, 

747 base, 

748 { 

749 "status": "complete", 

750 "document": base, 

751 "artifacts": result.artifacts, 

752 # Which model produced the run, off the per-page scan records — 

753 # two generations share the library, so a completed run says so. 

754 "model": model, 

755 "model_version": next((s.version for s in run.scans if s.version), None), 

756 "pages_nominated": run.nominated, 

757 "pages_scanned": run.scanned, 

758 "pages_submitted": run.submitted, 

759 "pages_returning_a_figure": run.with_figures, 

760 "figure_records": len(run.figures), 

761 "scanned_tables": len(result.tables), 

762 "credit_usage": run.credits, 

763 "errors": run.errors, 

764 "total_seconds": round(time.time() - started, 1), 

765 }, 

766 ) 

767 

768 

769@cli.command(name="table") 

770@click.argument("pdf_path") 

771@click.option("--output-dir", "-o", default="output", help="Output directory (local path or s3:// prefix)") 

772@click.option( 

773 "--llm-backend", 

774 type=click.Choice(["cli", "api", "mock"]), 

775 default=None, 

776 help="LLM client backend (default: $QUBER_LLM_BACKEND or api)", 

777) 

778@click.option( 

779 "--no-llm", 

780 is_flag=True, 

781 help="Skip the LLM structure-correction step (Camelot-only output)", 

782) 

783@click.option("--dpi", default=200, type=int, help="Page rasterization DPI") 

784@click.option( 

785 "--engine", 

786 type=click.Choice(["set-of-mark", "camelot-llm", "correspondence"]), 

787 default="set-of-mark", 

788 help="Extraction engine (default: set-of-mark). set-of-mark: vision locates each " 

789 "table and its region, Camelot fills it in-region, grounded correction " 

790 "cleans structure. camelot-llm and correspondence are DEPRECATED/dormant.", 

791) 

792@click.option( 

793 "--review", 

794 is_flag=True, 

795 help="Also write human-reviewable artifacts beside the JSON: a before/after " 

796 "HTML (source region + bounding box | corrected markdown), an annotated " 

797 "PDF (Set-of-Mark boxes drawn on the source pages), and — when any table " 

798 "carries flagged decisions — the flags HTML review queue. Renders " 

799 "from the tables already extracted, so it adds no extraction or LLM cost.", 

800) 

801def extract( 

802 pdf_path: str, 

803 output_dir: str, 

804 llm_backend: Optional[str], 

805 no_llm: bool, 

806 dpi: int, 

807 engine: str, 

808 review: bool, 

809): 

810 """ 

811 Run the `TableExtractor` path: source PDF -> list[ExtractedTable]. 

812 

813 Engines: 

814 

815 - set-of-mark (default): the page image is the arbiter. The grid locator names 

816 every table and its region; Camelot extracts each one constrained to its 

817 box (so it can neither shatter one table into many nor merge many into 

818 one); the grounded structure-correction LLM cleans headers/spans/merged 

819 cells without altering a value. One ExtractedTable per visual table. 

820 - camelot-llm (DEPRECATED): both Camelot flavors in parallel; per-candidate 

821 is_table classification; LLM unify per page; structure-correction. 

822 - correspondence (DEPRECATED): detector lists tables, Camelot chunks matched 

823 by bbox overlap, completeness-audited, with recovery escalation. 

824 

825 Output is a JSON file of `ExtractedTable` Pydantic models with 

826 provenance fields. 

827 """ 

828 from quber.agents.classifier import MockClassifier 

829 from quber.agents.llm_client import Backend, MockLLMClient, get_llm_client 

830 from quber.agents.unifier import MockUnifier 

831 from quber.core.extractors import ( 

832 CamelotCorrespondenceExtractor, 

833 CamelotLLMTableExtractor, 

834 TableExtractor, 

835 ) 

836 

837 backend_lit: Optional[Backend] = llm_backend # type: ignore[assignment] 

838 extractor: TableExtractor 

839 if engine == "set-of-mark": 

840 if no_llm: 

841 raise click.UsageError( 

842 "--no-llm is not supported with --engine set-of-mark; the grounded " 

843 "correction step is part of the set-of-mark capture." 

844 ) 

845 from quber.core.extractors.set_of_mark import SetOfMarkExtractor 

846 

847 extractor = SetOfMarkExtractor(dpi=dpi, llm=get_llm_client(backend_lit)) 

848 logger.info(f"Extracting (SoM): {pdf_path}") 

849 elif engine == "correspondence": 

850 if no_llm: 

851 raise click.UsageError( 

852 "--no-llm is not supported with --engine correspondence; the " 

853 "detector/correspondence/completeness flow is LLM-driven end to end." 

854 ) 

855 extractor = CamelotCorrespondenceExtractor(dpi=dpi, llm=get_llm_client(backend_lit)) 

856 logger.info(f"Extracting (correspondence): {pdf_path}") 

857 elif no_llm: 

858 # --no-llm sidelines every LLM stage: classifier default-accepts, 

859 # unifier pass-through, correction skipped. Output is raw Camelot 

860 # candidates from both flavors with provenance fields populated. 

861 extractor = CamelotLLMTableExtractor( 

862 llm_client=MockLLMClient(), 

863 classifier=MockClassifier(), 

864 unifier=MockUnifier(), 

865 run_llm_correction=False, 

866 dpi=dpi, 

867 ) 

868 logger.info(f"Extracting: {pdf_path}") 

869 else: 

870 extractor = CamelotLLMTableExtractor( 

871 llm_client=get_llm_client(backend_lit), 

872 run_llm_correction=True, 

873 dpi=dpi, 

874 ) 

875 logger.info(f"Extracting: {pdf_path}") 

876 

877 # Resolve to a local path before extraction: local stays local; s3:// 

878 # materializes through the persistent cache. Logs an INFO cache-hit line 

879 # when the document is already cached. 

880 source = resolve_document(pdf_path) 

881 base = source.stem 

882 

883 # Every run ends with a `<base>.tables.complete.json` marker — the same 

884 # contract the parse worker follows. Written strictly after the artifacts 

885 # (its own upload pass), status `complete` or `failed`, so a downstream 

886 # join can treat marker existence as "these artifacts are whole" and a 

887 # failure rides the same channel as a success. 

888 started = time.time() 

889 stage = "extract" 

890 try: 

891 tables = extractor.extract_tables_sync(source) 

892 except Exception as exc: 

893 write_completion_marker( 

894 output_dir, 

895 f"{base}.tables.complete.json", 

896 { 

897 "status": "failed", 

898 "document": base, 

899 "stage": stage, 

900 "error": f"{type(exc).__name__}: {exc}", 

901 "total_seconds": round(time.time() - started, 1), 

902 }, 

903 ) 

904 raise 

905 

906 if engine == "correspondence": 

907 detected_not_extracted = sum( 

908 1 

909 for t in tables 

910 if t.extraction_record and t.extraction_record.status == "detected_not_extracted" 

911 ) 

912 incomplete = sum( 

913 1 for t in tables if t.extraction_record and t.extraction_record.status == "incomplete" 

914 ) 

915 logger.info( 

916 f" - Tables: {len(tables)} " 

917 f"(detected-not-extracted: {detected_not_extracted}, incomplete: {incomplete})" 

918 ) 

919 else: 

920 logger.info(f" - Tables: {len(tables)}") 

921 

922 # The run's review queue: every cell whose status is registered for 

923 # inspection, with full document identity — no reverse attribution 

924 # from page numbers ever again. 

925 from quber.core.extractors.base import cell_flags 

926 

927 flags = cell_flags(tables) 

928 

929 # `output_sink` writes locally when `output_dir` is a path, and uploads the 

930 # artifacts to the bucket when it is an `s3://` prefix (cloud job output). 

931 stage = "write_artifacts" 

932 try: 

933 with output_sink(output_dir) as out: 

934 json_path = out / f"{base}.tables.json" 

935 json_path.write_text( 

936 json.dumps([t.model_dump() for t in tables], indent=2, default=str), encoding="utf-8" 

937 ) 

938 logger.info(f" - JSON: {json_path}") 

939 

940 if flags: 

941 flags_path = out / f"{base}.flags.json" 

942 flags_path.write_text(json.dumps([f.model_dump() for f in flags], indent=2), encoding="utf-8") 

943 logger.info(f" - Flags: {flags_path} ({len(flags)} review item(s))") 

944 

945 if review: 

946 # Render straight from the tables already extracted -- no second pass. 

947 from quber.review import render_annotated_pdf, render_flags_html, render_review_html 

948 

949 html_path = render_review_html([(source, tables)], out / f"{base}.review.html") 

950 boxed_path = render_annotated_pdf(tables, source, out / f"{base}.boxed.pdf") 

951 logger.info(f" - Review HTML: {html_path}") 

952 logger.info(f" - Annotated PDF: {boxed_path}") 

953 flags_html = render_flags_html([(source, tables)], out / f"{base}.flags.html") 

954 if flags_html: 

955 logger.info(f" - Flags HTML (review queue): {flags_html}") 

956 except Exception as exc: 

957 write_completion_marker( 

958 output_dir, 

959 f"{base}.tables.complete.json", 

960 { 

961 "status": "failed", 

962 "document": base, 

963 "stage": stage, 

964 "error": f"{type(exc).__name__}: {exc}", 

965 "total_seconds": round(time.time() - started, 1), 

966 }, 

967 ) 

968 raise 

969 

970 write_completion_marker( 

971 output_dir, 

972 f"{base}.tables.complete.json", 

973 { 

974 "status": "complete", 

975 "document": base, 

976 "tables": len(tables), 

977 "flags": len(flags), 

978 "total_seconds": round(time.time() - started, 1), 

979 }, 

980 ) 

981 

982 

983@cli.command() 

984@click.argument("pdf_path") 

985@click.option("--output-dir", "-o", default="output", help="Output directory") 

986@click.option("--dpi", default=200, type=int, help="Page rasterization DPI") 

987@click.option( 

988 "--no-classify", 

989 is_flag=True, 

990 help="Skip the is_table tagging on Camelot grids (grids still returned, untagged)", 

991) 

992def dual(pdf_path: str, output_dir: str, dpi: int, no_classify: bool): 

993 """ 

994 Run the two first-class flows side by side: source PDF -> DualResult. 

995 

996 The visual flow looks at each rendered page and lets the grid locator 

997 own the table count and identity; the Camelot flow pulls cell grids 

998 straight from the PDF and tags each with an is_table verdict (metadata 

999 only — it never drops a grid). The two run concurrently and neither is 

1000 matched to the other: this is the un-reconciled input for a later 

1001 cross-reference step. No detector, box repair, recovery, or escalation. 

1002 

1003 PDF_PATH may be a local path or an s3:// URI; s3 inputs materialize 

1004 through the persistent cache before extraction. 

1005 """ 

1006 from quber.core.extractors.dual import DualFlowExtractor 

1007 

1008 source = resolve_document(pdf_path) 

1009 extractor = DualFlowExtractor(dpi=dpi, classify_camelot=not no_classify) 

1010 click.echo(f"Dual extraction: {pdf_path}") 

1011 result = extractor.run_sync(source) 

1012 

1013 out = Path(output_dir) 

1014 out.mkdir(parents=True, exist_ok=True) 

1015 base = source.stem 

1016 json_path = out / f"{base}.dual.json" 

1017 json_path.write_text(json.dumps(result.model_dump(), indent=2, default=str), encoding="utf-8") 

1018 

1019 visual_by_page = result.visual_count_by_page() 

1020 camelot_by_page = result.camelot_count_by_page() 

1021 pages = sorted(set(visual_by_page) | set(camelot_by_page)) 

1022 click.echo(f" - Visual tables: {len(result.visual)} Camelot grids: {len(result.camelot)}") 

1023 click.echo(" - Per page (visual | camelot):") 

1024 for p in pages: 

1025 click.echo(f" p{p}: {visual_by_page.get(p, 0)} | {camelot_by_page.get(p, 0)}") 

1026 click.echo(f" - JSON: {json_path}") 

1027 

1028 

1029@cli.command() 

1030@click.argument("pdf_path") 

1031@click.option("--output-dir", "-o", default="output", help="Output directory (local path or s3:// prefix)") 

1032@click.option( 

1033 "--artifacts-dir", 

1034 default=None, 

1035 help="Cloud-style standalone fusion: load the document artifacts " 

1036 "(<base>.{docling,confidence,cells}.json) and the table artifact " 

1037 "(<base>.tables.json) from this directory and fuse only, skipping both " 

1038 "engines. Without it, the document and table extractions run in-process.", 

1039) 

1040@click.option( 

1041 "--llm-backend", 

1042 type=click.Choice(["cli", "api", "mock"]), 

1043 default=None, 

1044 help="LLM client backend (default: $QUBER_LLM_BACKEND or api)", 

1045) 

1046@click.option("--dpi", default=200, type=int, help="Page rasterization DPI") 

1047@click.option("--preset", type=click.Choice(["tuned-financial", "legacy"]), default="tuned-financial") 

1048@click.option( 

1049 "--image-mode", 

1050 type=click.Choice(["embedded", "referenced", "placeholder"]), 

1051 default="embedded", 

1052 help="How pictures are written in the unified markdown/HTML. embedded " 

1053 "(default) inlines them base64 so the files are self-contained; referenced " 

1054 "writes a sidecar image folder and links it; placeholder drops them to " 

1055 "'<!-- image -->'.", 

1056) 

1057@click.option( 

1058 "--review", 

1059 is_flag=True, 

1060 help="Also write the human-reviewable control artifacts from both workflows: " 

1061 "the table-side before/after HTML, annotated PDF (Set-of-Mark boxes on the " 

1062 "fused tables), and — when any table carries flagged decisions — " 

1063 "the flags HTML review queue, plus the document-side split-page HTML of the " 

1064 "unified DoclingDocument. Rendered from the result already produced, no " 

1065 "second pass.", 

1066) 

1067def fuse( 

1068 pdf_path: str, 

1069 output_dir: str, 

1070 artifacts_dir: Optional[str], 

1071 llm_backend: Optional[str], 

1072 dpi: int, 

1073 preset: str, 

1074 image_mode: str, 

1075 review: bool, 

1076): 

1077 """ 

1078 Fuse the document (docling) and table (Set-of-Mark/Camelot) extractions. 

1079 

1080 Two views of the same page enrich each other: the Camelot bodies move into 

1081 the docling spine, regions SoM merged are split, charts and image tables are 

1082 annotated, and tables docling found that SoM missed are surfaced explicitly. 

1083 

1084 Two control flows over one fusion core: 

1085 

1086 - Local (default): the document and table extractions run in-process, then 

1087 fuse. 

1088 - Standalone (--artifacts-dir): load the two extractions' artifacts from a 

1089 directory (as a cloud job would from S3) and fuse only, no engines. 

1090 

1091 Writes the unified DoclingDocument (markdown + JSON), the corrected 

1092 Set-of-Mark/Camelot tables (JSON), and a fusion report (per-region match 

1093 kinds and any surfaced errors). With --review, also writes the table-side 

1094 before/after HTML and annotated PDF and the document-side split-page HTML. 

1095 """ 

1096 from quber.agents.llm_client import get_llm_client 

1097 

1098 llm = get_llm_client(llm_backend) # type: ignore[arg-type] 

1099 source = resolve_document(pdf_path) 

1100 base = source.stem 

1101 

1102 # Like the extraction jobs, fusion ends with its own completion marker 

1103 # (`<base>.fuse.complete.json`), written strictly after the artifacts. 

1104 # The join step ignores fuse markers, so fusing never re-triggers a join. 

1105 fuse_started = time.time() 

1106 try: 

1107 run_fusion(artifacts_dir, base, source, llm, pdf_path, preset, dpi, image_mode, review, output_dir) 

1108 except Exception as exc: 

1109 write_completion_marker( 

1110 output_dir, 

1111 f"{base}.fuse.complete.json", 

1112 { 

1113 "status": "failed", 

1114 "document": base, 

1115 "stage": "fuse", 

1116 "error": f"{type(exc).__name__}: {exc}", 

1117 "total_seconds": round(time.time() - fuse_started, 1), 

1118 }, 

1119 ) 

1120 raise 

1121 write_completion_marker( 

1122 output_dir, 

1123 f"{base}.fuse.complete.json", 

1124 { 

1125 "status": "complete", 

1126 "document": base, 

1127 "total_seconds": round(time.time() - fuse_started, 1), 

1128 }, 

1129 ) 

1130 

1131 

1132def run_fusion( 

1133 artifacts_dir: Optional[str], 

1134 base: str, 

1135 source: Path, 

1136 llm: LLMClient, 

1137 pdf_path: str, 

1138 preset: str, 

1139 dpi: int, 

1140 image_mode: str, 

1141 review: bool, 

1142 output_dir: str, 

1143): 

1144 """One fusion run, artifacts in to artifacts out. The command wrapper owns the marker.""" 

1145 from quber.core.fusion import DocumentFusion, fuse_artifacts 

1146 

1147 if artifacts_dir: 

1148 from quber.core.extractors.base import ExtractedTable 

1149 from quber.core.parsers import ParseResult 

1150 from quber.files.cache import resolve_artifacts 

1151 

1152 # Local directories pass through; an s3:// prefix materializes the four 

1153 # named artifact files through the persistent cache and reads them there. 

1154 adir = resolve_artifacts( 

1155 artifacts_dir, 

1156 [ 

1157 f"{base}.docling.json", 

1158 f"{base}.confidence.json", 

1159 f"{base}.cells.json", 

1160 f"{base}.tables.json", 

1161 ], 

1162 ) 

1163 logger.info(f"Fusing from artifacts ({adir}): {pdf_path}") 

1164 parse = ParseResult.load(adir, base) 

1165 som_tables = [ExtractedTable(**t) for t in json.loads((adir / f"{base}.tables.json").read_text())] 

1166 result = asyncio.run(fuse_artifacts(parse, som_tables, source, llm)) 

1167 else: 

1168 from quber.core.extractors.set_of_mark import SetOfMarkExtractor 

1169 

1170 logger.info(f"Fusing ({preset}): {pdf_path}") 

1171 fusion = DocumentFusion( 

1172 parser=parser_for_preset_lazy(preset), 

1173 extractor=SetOfMarkExtractor(dpi=dpi, llm=llm), 

1174 llm=llm, 

1175 ) 

1176 result = fusion.fuse_sync(source) 

1177 

1178 from collections import Counter 

1179 

1180 from docling_core.types.doc.base import ImageRefMode 

1181 

1182 image_ref_mode = ImageRefMode(image_mode) 

1183 

1184 kinds = Counter(m.kind for m in result.matches) 

1185 logger.info(f" - Unified document tables: {len(result.document.tables)}") 

1186 logger.info(f" - Set-of-Mark/Camelot tables: {len(result.tables)}") 

1187 logger.info(f" - Match kinds: {dict(kinds)}") 

1188 for err in result.errors: 

1189 logger.error(f" - {err}") 

1190 

1191 with output_sink(output_dir) as out: 

1192 unified_json = out / f"{base}.unified.json" 

1193 unified_json.write_text(json.dumps(result.document.export_to_dict(), indent=2), encoding="utf-8") 

1194 unified_md = out / f"{base}.unified.md" 

1195 result.document.save_as_markdown(unified_md, image_mode=image_ref_mode) 

1196 

1197 # The parse's positioned page cells, beside the unified parse. The 

1198 # figure stage reconciles chart values only when this file sits next 

1199 # to the parse it is handed; without it the value step silently turns 

1200 # off, and a document ingested through fuse arrives holding no figure 

1201 # values at all. 

1202 if result.parse is not None: 

1203 cells_json = out / f"{base}.cells.json" 

1204 cells_json.write_text( 

1205 json.dumps([p.model_dump() for p in result.parse.pages], indent=2, default=str), 

1206 encoding="utf-8", 

1207 ) 

1208 

1209 tables_json = out / f"{base}.tables.json" 

1210 tables_json.write_text( 

1211 json.dumps([t.model_dump() for t in result.tables], indent=2, default=str), 

1212 encoding="utf-8", 

1213 ) 

1214 

1215 report_json = out / f"{base}.fusion.json" 

1216 report_json.write_text( 

1217 json.dumps( 

1218 { 

1219 "match_kinds": dict(kinds), 

1220 "matches": [m.model_dump() for m in result.matches], 

1221 "errors": result.errors, 

1222 }, 

1223 indent=2, 

1224 default=str, 

1225 ), 

1226 encoding="utf-8", 

1227 ) 

1228 logger.info(f" - Unified: {unified_json} / {unified_md}") 

1229 logger.info(f" - Tables: {tables_json}") 

1230 logger.info(f" - Report: {report_json}") 

1231 

1232 from quber.core.extractors.base import cell_flags 

1233 

1234 flags = cell_flags(result.tables) + result.heading_flags 

1235 if flags: 

1236 flags_path = out / f"{base}.flags.json" 

1237 flags_path.write_text(json.dumps([f.model_dump() for f in flags], indent=2), encoding="utf-8") 

1238 logger.info(f" - Flags: {flags_path} ({len(flags)} review item(s))") 

1239 

1240 if review: 

1241 # Control artifacts, rendered from the result already produced (no 

1242 # second pass): the table workflow's before/after HTML + annotated 

1243 # PDF over the fused tables, and the document workflow's split-page 

1244 # HTML of the unified document. 

1245 from quber.review import render_annotated_pdf, render_flags_html, render_review_html 

1246 

1247 review_html = render_review_html([(source, result.tables)], out / f"{base}.review.html") 

1248 boxed_pdf = render_annotated_pdf(result.tables, source, out / f"{base}.boxed.pdf") 

1249 unified_html = out / f"{base}.unified.html" 

1250 result.document.save_as_html(unified_html, image_mode=image_ref_mode, split_page_view=True) 

1251 logger.info(f" - Review HTML (tables): {review_html}") 

1252 logger.info(f" - Annotated PDF: {boxed_pdf}") 

1253 logger.info(f" - Unified HTML (split-page): {unified_html}") 

1254 flags_html = render_flags_html([(source, result.tables)], out / f"{base}.flags.html") 

1255 if flags_html: 

1256 logger.info(f" - Flags HTML (review queue): {flags_html}") 

1257 

1258 

1259def parser_for_preset_lazy(preset: str): 

1260 """Build a parser, importing the GPU/ML stack only when fusion runs.""" 

1261 from quber.core.parsers import parser_for_preset 

1262 

1263 return parser_for_preset(preset) 

1264 

1265 

1266@cli.command() 

1267@click.argument("pdf_path", type=click.Path(exists=True)) 

1268@click.option( 

1269 "--llm-backend", 

1270 type=click.Choice(["cli", "api", "mock"]), 

1271 default=None, 

1272 help="LLM client backend (default: $QUBER_LLM_BACKEND or api)", 

1273) 

1274@click.option("--dpi", default=200, type=int, help="Page rasterization DPI") 

1275def validate(pdf_path: str, llm_backend: Optional[str], dpi: int): 

1276 """ 

1277 Run the Camelot-vs-LLM table-count validation hook. 

1278 

1279 Surfaces page-level mismatches between Camelot's detected count and 

1280 the LLM's image-based count. Per plan §7, mismatches are surfaced 

1281 explicitly; we do not silently judge which is correct. 

1282 """ 

1283 from pdf2image import convert_from_path 

1284 

1285 from quber.agents.classifier import MockClassifier 

1286 from quber.agents.llm_client import Backend, get_llm_client 

1287 from quber.agents.unifier import MockUnifier 

1288 from quber.core.extractors import CamelotLLMTableExtractor, TableExtractor 

1289 from quber.core.validate import camelot_vs_llm_count_sync 

1290 

1291 backend_lit: Optional[Backend] = llm_backend # type: ignore[assignment] 

1292 llm = get_llm_client(backend_lit) 

1293 # Validate compares raw Camelot counts vs LLM image counts; the 

1294 # classifier and unifier would distort the Camelot side, so we 

1295 # sideline them with mocks. 

1296 extractor: TableExtractor = CamelotLLMTableExtractor( 

1297 llm_client=llm, 

1298 classifier=MockClassifier(), 

1299 unifier=MockUnifier(), 

1300 run_llm_correction=False, 

1301 dpi=dpi, 

1302 ) 

1303 

1304 click.echo(f"Extracting tables: {pdf_path}") 

1305 tables = extractor.extract_tables_sync(Path(pdf_path)) 

1306 

1307 with tempfile.TemporaryDirectory(prefix="quber-validate-") as tmp: 

1308 tmp_path = Path(tmp) 

1309 images = convert_from_path(pdf_path, dpi=dpi, fmt="png", output_folder=str(tmp_path)) 

1310 page_images: list[Path] = [] 

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

1312 p = tmp_path / f"page-{i:04d}.png" 

1313 img.save(p, "PNG") 

1314 page_images.append(p) 

1315 

1316 click.echo(f"Running validation across {len(page_images)} pages") 

1317 report = camelot_vs_llm_count_sync(Path(pdf_path), tables, page_images, llm) 

1318 

1319 if report.has_mismatches: 

1320 click.echo(f"\nMISMATCHES ({len(report.mismatches)}):") 

1321 for m in report.mismatches: 

1322 click.echo(f" page {m.page}: camelot={m.camelot_count} llm={m.llm_count} (delta {m.delta:+d})") 

1323 else: 

1324 click.echo("\nNo mismatches detected.") 

1325 if report.errors: 

1326 click.echo(f"\nERRORS ({len(report.errors)}):") 

1327 for e in report.errors: 

1328 click.echo(f" {e}") 

1329 

1330 

1331@cli.command() 

1332@click.option("--host", default="127.0.0.1", help="Interface to bind (default: 127.0.0.1)") 

1333@click.option("--port", default=8101, type=int, help="Port to serve on (default: 8101)") 

1334@click.option("--reload", is_flag=True, help="Restart on source changes (development)") 

1335def playground(host: str, port: int, reload: bool): 

1336 """Serve the answering playground web app.""" 

1337 # The app module (FastAPI, retrieval, the answer agents) loads in the 

1338 # server process uvicorn starts, not here, so `quber --help` stays light. 

1339 import uvicorn 

1340 

1341 uvicorn.run("quber.playground.app:app", host=host, port=port, reload=reload) 

1342 

1343 

1344# Backwards-compatible aliases. The canonical names say what each command 

1345# produces -- `document` parses a PDF into a DoclingDocument, `table` extracts 

1346# the list of tables. The original `parse`/`extract` names predate that and are 

1347# still referenced in docs and existing scripts, so keep them resolving to the 

1348# same callbacks. 

1349cli.add_command(parse, name="parse") 

1350cli.add_command(extract, name="extract") 

1351 

1352 

1353def main(): 

1354 """Main entry point for the CLI.""" 

1355 cli() 

1356 

1357 

1358if __name__ == "__main__": 

1359 main()