| """CLI for corpus preparation, retrieval-only evaluation, reports, and trace replay.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
| from typing import Annotated |
|
|
| import httpx |
| import typer |
|
|
| from . import __version__ |
| from .config import AppConfig, load_config, load_yaml_mapping |
| from .data.combined import sha256_file, write_benchmark_manifests, write_combined_corpus |
| from .data.corpus import write_documents |
| from .data.hotpotqa import HotpotQAAdapter |
| from .data.musique import MuSiQueAdapter |
| from .data.schemas import BenchmarkExample |
| from .data.twowiki import TwoWikiAdapter |
| from .evaluation.aggregate import aggregate_records, latest_records |
| from .evaluation.report import write_report |
| from .evaluation.runner import BenchmarkRunner |
| from .exceptions import ConfigurationError |
| from .logging import configure_logging, get_logger, redact |
| from .retrieval import DenseRetriever, DocumentStore, SparseRetriever |
| from .tracing.replay import frozen_replay |
|
|
| app = typer.Typer( |
| name="agentic-search", |
| help="Retrieval-only benchmark: FIXED vs Agentic vs iterative Search-as-Code.", |
| invoke_without_command=True, |
| no_args_is_help=False, |
| ) |
| data_app = typer.Typer(help="Collect all benchmarks and build a shared offline corpus.", no_args_is_help=True) |
| index_app = typer.Typer(help="Build auditable sparse or dense index metadata.", no_args_is_help=True) |
| run_app = typer.Typer(help="Run one retrieval query and show retrieved context.", no_args_is_help=True) |
| benchmark_app = typer.Typer(help="Run a resumable retrieval-only benchmark.", no_args_is_help=True) |
| report_app = typer.Typer(help="Aggregate retrieval-only result records.", no_args_is_help=True) |
| replay_app = typer.Typer(help="Read a frozen trajectory without model or index calls.", no_args_is_help=True) |
| ui_app = typer.Typer(help="Terminal trace viewer for inspectable benchmark demos.", no_args_is_help=True) |
| config_app = typer.Typer(help="Inspect and validate project configuration.", no_args_is_help=True) |
|
|
| DEFAULT_HOTPOT_PARQUET_URL = ( |
| "https://huggingface.co/datasets/hotpotqa/hotpot_qa/resolve/" |
| "refs%2Fconvert%2Fparquet/distractor/validation/0000.parquet" |
| ) |
| DEFAULT_TWOWIKI_PARQUET_URL = ( |
| "https://huggingface.co/datasets/framolfese/2WikiMultihopQA/resolve/main/" |
| "data/validation-00000-of-00001.parquet" |
| ) |
| DEFAULT_MUSIQUE_JSON_URL = "https://huggingface.co/datasets/fladhak/musique/resolve/main/dev.json" |
|
|
|
|
| def _public_config(config: AppConfig) -> dict[str, object]: |
| output = config.model_dump(mode="json") |
| output["model"]["base_url_configured"] = bool(config.resolved_model_base_urls()) |
| output["model"]["replica_count"] = len(config.resolved_model_base_urls()) |
| output["model"]["api_key_configured"] = config.resolved_model_api_key() is not None |
| return redact(output) |
|
|
|
|
| def _examples(path: Path, max_examples: int) -> list[BenchmarkExample]: |
| if path.suffix == ".jsonl": |
| examples = [ |
| BenchmarkExample.model_validate_json(line) |
| for line in path.read_text(encoding="utf-8").splitlines() |
| if line.strip() |
| ] |
| else: |
| examples = list(HotpotQAAdapter(path).load_examples()) |
| return examples[:max_examples] if max_examples > 0 else examples |
|
|
|
|
| def _download_stream(url: str, output: Path) -> None: |
| output.parent.mkdir(parents=True, exist_ok=True) |
| temporary = output.with_suffix(output.suffix + ".tmp") |
| with httpx.stream("GET", url, timeout=300, follow_redirects=True) as response: |
| response.raise_for_status() |
| with temporary.open("wb") as destination: |
| for chunk in response.iter_bytes(): |
| destination.write(chunk) |
| temporary.replace(output) |
|
|
|
|
| @data_app.command("download-hotpot") |
| def download_hotpot( |
| output: Annotated[Path, typer.Option("--output", help="Destination HotpotQA JSON file.")] = Path("data/raw/hotpotqa/dev_distractor.json"), |
| parquet_url: Annotated[str, typer.Option("--parquet-url", help="Hugging Face validation parquet URL.")] = DEFAULT_HOTPOT_PARQUET_URL, |
| limit: Annotated[int, typer.Option("--limit", help="0 fetches the complete validation split.")] = 0, |
| ) -> None: |
| """Download the official HF HotpotQA distractor validation split as original-style JSON.""" |
| output = output.resolve() |
| output.parent.mkdir(parents=True, exist_ok=True) |
| parquet_path = output.with_suffix(".parquet.tmp") |
| with httpx.stream("GET", parquet_url, timeout=180, follow_redirects=True) as response: |
| response.raise_for_status() |
| with parquet_path.open("wb") as destination: |
| for chunk in response.iter_bytes(): |
| destination.write(chunk) |
| try: |
| import pyarrow.parquet as parquet |
| except ImportError as error: |
| raise RuntimeError("pyarrow is required to normalize the official HotpotQA parquet") from error |
| rows = parquet.read_table(parquet_path).to_pylist() |
| parquet_path.unlink(missing_ok=True) |
| if limit: |
| rows = rows[:limit] |
| records = [ |
| { |
| "_id": row["id"], |
| "question": row["question"], |
| "answer": row["answer"], |
| "type": row.get("type"), |
| "level": row.get("level"), |
| "supporting_facts": [ |
| (title, sent_id) |
| for title, sent_id in zip( |
| row["supporting_facts"]["title"], row["supporting_facts"]["sent_id"], strict=True |
| ) |
| ], |
| "context": [ |
| (title, sentences) |
| for title, sentences in zip( |
| row["context"]["title"], row["context"]["sentences"], strict=True |
| ) |
| ], |
| } |
| for row in rows |
| ] |
| temporary = output.with_suffix(output.suffix + ".tmp") |
| temporary.write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8") |
| temporary.replace(output) |
| typer.echo(json.dumps({"output": str(output), "examples": len(records), "parquet_url": parquet_url})) |
|
|
|
|
| @data_app.command("build-hotpot-corpus") |
| def build_hotpot_corpus( |
| source: Annotated[Path, typer.Option("--source", help="Raw HotpotQA JSON file.")], |
| output: Annotated[Path, typer.Option("--output", help="Corpus JSONL output.")] = Path("data/processed/hotpotqa_context_union.jsonl"), |
| ) -> None: |
| """Build a stable corpus from the union of offline HotpotQA contexts.""" |
| manifest = write_documents(HotpotQAAdapter(source).build_corpus(), output.resolve()) |
| manifest_path = output.resolve().with_suffix(".manifest.json") |
| manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") |
| typer.echo(json.dumps({**manifest, "manifest": str(manifest_path)}, ensure_ascii=False)) |
|
|
|
|
| @data_app.command("download-2wiki") |
| def download_twowiki( |
| output: Annotated[Path, typer.Option("--output", help="2Wiki validation parquet destination.")] = Path( |
| "data/raw/2wikimultihopqa/validation.parquet" |
| ), |
| parquet_url: Annotated[str, typer.Option("--parquet-url")] = DEFAULT_TWOWIKI_PARQUET_URL, |
| ) -> None: |
| """Download the HotpotQA-schema-compatible 2WikiMultiHopQA validation split.""" |
| output = output.resolve() |
| _download_stream(parquet_url, output) |
| typer.echo(json.dumps({"output": str(output), "sha256": sha256_file(output), "url": parquet_url})) |
|
|
|
|
| @data_app.command("download-musique") |
| def download_musique( |
| output: Annotated[Path, typer.Option("--output", help="MuSiQue dev JSON destination.")] = Path( |
| "data/raw/musique/dev.json" |
| ), |
| json_url: Annotated[str, typer.Option("--json-url")] = DEFAULT_MUSIQUE_JSON_URL, |
| ) -> None: |
| """Download the MuSiQue development set with paragraph-level supporting labels.""" |
| output = output.resolve() |
| _download_stream(json_url, output) |
| typer.echo(json.dumps({"output": str(output), "sha256": sha256_file(output), "url": json_url})) |
|
|
|
|
| @data_app.command("download-all") |
| def download_all() -> None: |
| """Collect every retrieval benchmark source used by the combined corpus.""" |
| hotpot = Path("data/raw/hotpotqa/dev_distractor.json") |
| if not hotpot.is_file(): |
| download_hotpot(output=hotpot) |
| twowiki = Path("data/raw/2wikimultihopqa/validation.parquet") |
| if not twowiki.is_file(): |
| download_twowiki(output=twowiki) |
| musique = Path("data/raw/musique/dev.json") |
| if not musique.is_file(): |
| download_musique(output=musique) |
| typer.echo( |
| json.dumps( |
| { |
| "hotpotqa": str(hotpot.resolve()), |
| "2wikimultihopqa": str(twowiki.resolve()), |
| "musique": str(musique.resolve()), |
| } |
| ) |
| ) |
|
|
|
|
| @data_app.command("build-combined-corpus") |
| def build_combined_corpus( |
| hotpot: Annotated[Path, typer.Option("--hotpot")]=Path("data/raw/hotpotqa/dev_distractor.json"), |
| twowiki: Annotated[Path, typer.Option("--twowiki")]=Path("data/raw/2wikimultihopqa/validation.parquet"), |
| musique: Annotated[Path, typer.Option("--musique")]=Path("data/raw/musique/dev.json"), |
| output: Annotated[Path, typer.Option("--output")]=Path("data/processed/multihop_context_union.jsonl"), |
| ) -> None: |
| """Build one global deduplicated corpus from all three benchmark context collections.""" |
| inputs = {"hotpotqa": hotpot.resolve(), "2wikimultihopqa": twowiki.resolve(), "musique": musique.resolve()} |
| missing = [str(path) for path in inputs.values() if not path.is_file()] |
| if missing: |
| raise typer.BadParameter(f"missing benchmark sources: {', '.join(missing)}") |
| manifest = write_combined_corpus( |
| [HotpotQAAdapter(inputs["hotpotqa"]), TwoWikiAdapter(inputs["2wikimultihopqa"]), MuSiQueAdapter(inputs["musique"])], |
| output.resolve(), |
| ) |
| manifest["source_checksums"] = {dataset: sha256_file(path) for dataset, path in inputs.items()} |
| manifest_path = output.resolve().with_suffix(".manifest.json") |
| manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") |
| typer.echo(json.dumps({**manifest, "manifest": str(manifest_path)}, ensure_ascii=False)) |
|
|
|
|
| @data_app.command("build-benchmark-manifests") |
| def build_benchmark_manifests( |
| hotpot: Annotated[Path, typer.Option("--hotpot")] = Path("data/raw/hotpotqa/dev_distractor.json"), |
| twowiki: Annotated[Path, typer.Option("--twowiki")] = Path("data/raw/2wikimultihopqa/validation.parquet"), |
| musique: Annotated[Path, typer.Option("--musique")] = Path("data/raw/musique/dev.json"), |
| output_dir: Annotated[Path, typer.Option("--output-dir")] = Path("data/processed/benchmarks"), |
| ) -> None: |
| """Export source-partitioned questions and gold IDs for later retrieval-only evaluation.""" |
| inputs = {"hotpotqa": hotpot.resolve(), "2wikimultihopqa": twowiki.resolve(), "musique": musique.resolve()} |
| missing = [str(path) for path in inputs.values() if not path.is_file()] |
| if missing: |
| raise typer.BadParameter(f"missing benchmark sources: {', '.join(missing)}") |
| manifest = write_benchmark_manifests( |
| [ |
| HotpotQAAdapter(inputs["hotpotqa"]), |
| TwoWikiAdapter(inputs["2wikimultihopqa"]), |
| MuSiQueAdapter(inputs["musique"]), |
| ], |
| output_dir.resolve(), |
| ) |
| manifest["source_checksums"] = {dataset: sha256_file(path) for dataset, path in inputs.items()} |
| manifest_path = output_dir.resolve() / "manifest.json" |
| manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") |
| typer.echo(json.dumps(manifest, ensure_ascii=False)) |
|
|
|
|
| @index_app.command("sparse") |
| def build_sparse_index( |
| corpus: Annotated[Path, typer.Option("--corpus", help="Corpus JSONL path.")], |
| output: Annotated[Path, typer.Option("--output", help="Sparse index metadata JSON.")] = Path("data/indices/sparse.metadata.json"), |
| ) -> None: |
| """Build and audit the portable BM25 index statistics.""" |
| retriever = SparseRetriever(DocumentStore.from_jsonl(corpus).all()) |
| retriever.save_metadata(output.resolve()) |
| typer.echo(str(output.resolve())) |
|
|
|
|
| @index_app.command("dense") |
| def build_dense_index( |
| corpus: Annotated[Path, typer.Option("--corpus", help="Corpus JSONL path.")], |
| index_dir: Annotated[Path | None, typer.Option("--index-dir", help="Persistent BGE/FAISS index directory.")] = None, |
| config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None, |
| ) -> None: |
| """Encode every corpus passage with BGE and persist FAISS, vectors, and doc-id mapping.""" |
| config = load_config(config_path) |
| documents = DocumentStore.from_jsonl(corpus).all() |
| if config.retrieval.dense.backend != "sentence_transformers": |
| raise typer.BadParameter("index dense requires dense.backend=sentence_transformers") |
| output = (index_dir or config.retrieval.dense.index_dir).resolve() |
| manifest = DenseRetriever.build_persisted_index( |
| documents, |
| model_name=config.retrieval.dense.model_name, |
| device=config.retrieval.dense.device, |
| batch_size=config.retrieval.dense.batch_size, |
| output_dir=output, |
| ) |
| typer.echo(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)) |
|
|
|
|
| @run_app.command("query") |
| def run_query( |
| question: Annotated[str, typer.Argument(help="Question to retrieve evidence for.")], |
| corpus: Annotated[Path, typer.Option("--corpus")], |
| system: Annotated[str, typer.Option("--system", help="fixed, agentic, sac, or a controller baseline.")] = "fixed", |
| config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None, |
| ) -> None: |
| """Run one system and print only its retrieved contexts plus cost/latency.""" |
| runner = BenchmarkRunner(load_config(config_path), corpus_path=corpus.resolve()) |
| try: |
| record = runner.run_one(BenchmarkExample(example_id="adhoc", question=question, answers=[]), system) |
| typer.echo(json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True)) |
| finally: |
| runner.close() |
|
|
|
|
| @benchmark_app.command("run") |
| def benchmark_run( |
| corpus: Annotated[Path, typer.Option("--corpus", help="Offline corpus JSONL.")], |
| dataset: Annotated[ |
| Path | None, |
| typer.Option("--dataset", help="Legacy raw HotpotQA JSON input; prefer --examples."), |
| ] = None, |
| examples: Annotated[ |
| Path | None, |
| typer.Option("--examples", help="Exported source-partitioned BenchmarkExample JSONL."), |
| ] = None, |
| experiment: Annotated[Path, typer.Option("--experiment", "-e", help="Experiment YAML.")] = Path("configs/experiments/smoke.yaml"), |
| config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None, |
| run_id: Annotated[str | None, typer.Option("--run-id", help="Resume this run ID when supplied.")] = None, |
| systems_override: Annotated[ |
| str | None, |
| typer.Option("--systems", help="Comma-separated systems; overrides the experiment matrix."), |
| ] = None, |
| max_examples: Annotated[int | None, typer.Option("--max-examples")] = None, |
| concurrency: Annotated[int | None, typer.Option("--concurrency")] = None, |
| warmup_examples: Annotated[ |
| int, |
| typer.Option("--warmup-examples", help="Untimed examples per system used only to warm models."), |
| ] = 0, |
| use_cache: Annotated[ |
| bool | None, |
| typer.Option("--cache/--no-cache", help="Override configured cache behavior for this run."), |
| ] = None, |
| force: Annotated[bool, typer.Option("--force", help="Re-run completed example/system pairs.")] = False, |
| ) -> None: |
| """Evaluate context coverage, retrieval evidence, latency, and token cost only.""" |
| if (dataset is None) == (examples is None): |
| raise typer.BadParameter("provide exactly one of --dataset or --examples") |
| config = load_config(config_path) |
| if use_cache is not None: |
| config = config.model_copy( |
| update={"cache": config.cache.model_copy(update={"enabled": use_cache})} |
| ) |
| experiment_values = load_yaml_mapping(experiment) |
| settings = experiment_values.get("experiment", {}) |
| systems = ( |
| [item.strip() for item in systems_override.split(",") if item.strip()] |
| if systems_override is not None |
| else [str(item) for item in settings.get("systems", ["fixed", "agentic", "sac"])] |
| ) |
| if not systems: |
| raise typer.BadParameter("--systems must name at least one system") |
| limit = max_examples if max_examples is not None else int(settings.get("max_examples", 20)) |
| workers = concurrency if concurrency is not None else int(settings.get("concurrency", config.model.request_concurrency)) |
| runner = BenchmarkRunner(config, corpus_path=corpus.resolve(), run_id=run_id) |
| try: |
| source = (examples or dataset).resolve() |
| examples_for_run = _examples(source, limit) |
| runner.warmup(examples_for_run, systems, count=warmup_examples) |
| records_path, records = runner.run(examples_for_run, systems, concurrency=workers, force=force) |
| report_rows = aggregate_records( |
| records, |
| bootstrap_samples=config.evaluation.bootstrap_samples, |
| seed=config.project.seed, |
| ) |
| report_dir = config.project.artifacts_dir / "reports" / runner.run_id |
| json_path, csv_path = write_report(report_rows, report_dir) |
| typer.echo(json.dumps({"run_id": runner.run_id, "records": str(records_path), "report_json": str(json_path), "report_csv": str(csv_path)}, indent=2)) |
| finally: |
| runner.close() |
|
|
|
|
| @report_app.command("build") |
| def build_report( |
| records: Annotated[Path, typer.Option("--records", help="results.jsonl path.")], |
| output_dir: Annotated[Path, typer.Option("--output-dir")], |
| config_path: Annotated[Path | None, typer.Option("--config", "-c")] = None, |
| ) -> None: |
| """Rebuild a report from recorded results without rerunning retrieval or model calls.""" |
| config = load_config(config_path) |
| rows = latest_records( |
| [json.loads(line) for line in records.read_text(encoding="utf-8").splitlines() if line] |
| ) |
| report = aggregate_records(rows, bootstrap_samples=config.evaluation.bootstrap_samples, seed=config.project.seed) |
| json_path, csv_path = write_report(report, output_dir) |
| typer.echo(json.dumps({"json": str(json_path), "csv": str(csv_path)})) |
|
|
|
|
| @replay_app.command("show") |
| def replay_show(trace: Annotated[Path, typer.Option("--trace", help="Leaf trace directory.")]) -> None: |
| """Display persisted events exactly as recorded, without live model calls.""" |
| typer.echo(json.dumps(frozen_replay(trace), ensure_ascii=False, indent=2)) |
|
|
|
|
| @ui_app.command("trace") |
| def ui_trace(trace: Annotated[Path, typer.Option("--trace", help="Leaf trace directory.")]) -> None: |
| """Render a concise terminal trace viewer for demos and failure inspection.""" |
| for event in frozen_replay(trace): |
| typer.echo( |
| f"{event['step_id']} {event['component']}:{event['event_type']} " |
| f"latency_ms={event['latency_ms']:.2f}" |
| ) |
|
|
|
|
| @config_app.command("show") |
| def show_config( |
| config_path: Annotated[Path | None, typer.Option("--config", "-c", help="YAML config path.")] = None, |
| ) -> None: |
| """Print resolved configuration without exposing secret values.""" |
| try: |
| typer.echo(json.dumps(_public_config(load_config(config_path)), ensure_ascii=False, indent=2, sort_keys=True)) |
| except ConfigurationError as error: |
| typer.echo(str(error), err=True) |
| raise typer.Exit(code=2) from error |
|
|
|
|
| @app.callback() |
| def main_callback( |
| context: typer.Context, |
| version: Annotated[bool, typer.Option("--version", help="Print package version.")] = False, |
| log_level: Annotated[str | None, typer.Option("--log-level", help="Logging severity.")] = None, |
| ) -> None: |
| configure_logging(log_level) |
| if version: |
| typer.echo(__version__) |
| raise typer.Exit() |
| if context.invoked_subcommand is None: |
| typer.echo(context.get_help()) |
|
|
|
|
| app.add_typer(data_app, name="data") |
| app.add_typer(index_app, name="index") |
| app.add_typer(run_app, name="run") |
| app.add_typer(benchmark_app, name="benchmark") |
| app.add_typer(report_app, name="report") |
| app.add_typer(replay_app, name="replay") |
| app.add_typer(ui_app, name="ui") |
| app.add_typer(config_app, name="config") |
|
|
|
|
| def main() -> None: |
| get_logger("cli").debug("starting CLI", extra={"fields": {"version": __version__}}) |
| app() |
|
|