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
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""
2Command-line interface for Quber.
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.
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.
16`table` extracts the tables and writes `<base>.tables.json`, plus
17`<base>.flags.json` when any cell carries a flagged decision.
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.
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.
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"""
42import asyncio
43import json
44import sys
45import tempfile
46import time
47from importlib.metadata import version
48from pathlib import Path
49from typing import Optional
51import click
52from loguru import logger
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
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.
66@click.group()
67@click.version_option(version=version("quber"))
68def cli():
69 """Quber - Advanced table extraction and inference system."""
70 pass
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.
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)
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 )
117 # Convert provider string to enum if provided
118 provider_enum = ModelProvider(provider) if provider else None
120 # Create processor
121 from quber.processors import TableInferenceProcessor
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)
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)
138 # Process document
139 click.echo(f"\nAnalyzing: {pdf_path}")
141 async def run_analysis():
142 try:
143 results = await processor.process_document(doc)
145 # Create output directory
146 output_path = Path(output_dir)
147 output_path.mkdir(parents=True, exist_ok=True)
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"
154 # Save files
155 processor.save_json(results, str(json_file))
156 processor.save_summary(results, str(summary_file))
158 return results, json_file, summary_file
159 except Exception as e:
160 logger.error(f"Analysis failed: {e}")
161 raise
163 results, json_file, summary_file = asyncio.run(run_analysis())
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}")
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.
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})
204 # Find PDF files
205 dir_path = Path(directory)
206 pdf_files = list(dir_path.glob(pattern))
208 if not pdf_files:
209 click.echo(f"No files found matching pattern: {pattern}", err=True)
210 sys.exit(1)
212 click.echo(f"Found {len(pdf_files)} PDF files to process")
214 # Create output directory
215 output_path = Path(output_dir)
216 output_path.mkdir(parents=True, exist_ok=True)
218 # Convert provider string to enum if provided
219 provider_enum = ModelProvider(provider) if provider else None
221 # Create processor
222 from quber.processors import TableInferenceProcessor
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)
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))
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"
243 processor.save_json(result, str(json_path))
244 processor.save_summary(result, str(summary_path))
246 click.echo(f" ✓ {result['total_tables']} tables analyzed")
247 results.append(result)
249 except Exception as e:
250 click.echo(f" ✗ Failed: {e}", err=True)
251 logger.error(f"Failed to process {pdf_file}: {e}")
253 return results
255 results = asyncio.run(process_batch())
257 # Summary
258 total_documents = len(results)
259 total_tables = sum(r["total_tables"] for r in results)
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}/")
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
279 factory = AgentFactory(enable_logfire=False)
281 # Get available providers (with API keys)
282 available = factory.get_available_providers()
284 # Filter to specific provider if requested
285 if provider:
286 providers_to_show = [ModelProvider(provider)]
287 else:
288 providers_to_show = list(ModelProvider)
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")
295 for p in providers_to_show:
296 models = factory.list_available_models(p)
297 is_available = p.value in available
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)
303 model_list = models[p.value]
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
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}")
322 if remaining > 0:
323 click.echo(f" ... and {remaining} more models (use --all to see all)")
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})")
333@cli.group()
334def db():
335 """Database management commands."""
336 pass
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
346 log_level = "DEBUG" if verbose else "INFO"
347 logger.remove()
348 logger.add(sys.stderr, level=log_level)
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)
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
368 log_level = "DEBUG" if verbose else "INFO"
369 logger.remove()
370 logger.add(sys.stderr, level=log_level)
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)
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
390 log_level = "DEBUG" if verbose else "INFO"
391 logger.remove()
392 logger.add(sys.stderr, level=log_level)
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)
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
411 log_level = "DEBUG" if verbose else "INFO"
412 logger.remove()
413 logger.add(sys.stderr, level=log_level)
415 try:
416 stats_data = get_import_stats()
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']}")
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")
429 except Exception as e:
430 click.echo(f"✗ Failed to retrieve stats: {e}", err=True)
431 sys.exit(1)
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
445 log_level = "DEBUG" if verbose else "INFO"
446 logger.remove()
447 logger.add(sys.stderr, level=log_level)
449 try:
450 click.echo("Initializing embedding service...")
451 embedding_service = get_embedding_service(provider=provider, device=device)
453 click.echo("Loading tables from database...")
454 with get_session() as session:
455 tables = session.query(ExtractedTable).all()
456 total = len(tables)
458 if total == 0:
459 click.echo("No tables found in database")
460 return
462 click.echo(f"Found {total} tables to process")
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)
471 # Update both embedding fields
472 table.title_embedding = embedding.tolist()
473 table.description_embedding = embedding.tolist()
474 updated += 1
476 if i % batch_size == 0:
477 session.commit()
478 click.echo(f" Processed {i}/{total} tables...")
480 # Final commit
481 session.commit()
482 click.echo(f"✓ Generated embeddings for {updated}/{total} tables")
484 except Exception as e:
485 click.echo(f"✗ Failed to generate embeddings: {e}", err=True)
486 logger.exception(e)
487 sys.exit(1)
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.
535 Writes the document as markdown and/or JSON. No LLM inference.
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
546 from quber.core.parsers import parser_for_preset
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)
558 click.echo(f"Parsing ({preset}, device={device}): {pdf_path}")
559 result = parser.parse(doc)
560 document = result.document
562 base = doc.stem
563 image_ref_mode = ImageRefMode(image_mode)
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']}")
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}")
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}")
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.
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.
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
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}")
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.
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.
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.
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.
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
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
683 local = resolve_document(source)
684 base = local.stem
685 document, parse_name = load_document(parse, base)
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)
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
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}")
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 )
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].
813 Engines:
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.
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 )
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
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}")
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
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
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)}")
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
927 flags = cell_flags(tables)
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}")
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))")
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
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
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 )
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.
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.
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
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)
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")
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}")
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.
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.
1084 Two control flows over one fusion core:
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.
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
1098 llm = get_llm_client(llm_backend) # type: ignore[arg-type]
1099 source = resolve_document(pdf_path)
1100 base = source.stem
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 )
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
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
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
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)
1178 from collections import Counter
1180 from docling_core.types.doc.base import ImageRefMode
1182 image_ref_mode = ImageRefMode(image_mode)
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}")
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)
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 )
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 )
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}")
1232 from quber.core.extractors.base import cell_flags
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))")
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
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}")
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
1263 return parser_for_preset(preset)
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.
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
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
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 )
1304 click.echo(f"Extracting tables: {pdf_path}")
1305 tables = extractor.extract_tables_sync(Path(pdf_path))
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)
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)
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}")
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
1341 uvicorn.run("quber.playground.app:app", host=host, port=port, reload=reload)
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")
1353def main():
1354 """Main entry point for the CLI."""
1355 cli()
1358if __name__ == "__main__":
1359 main()