ragaudit-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
ragaudit/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """ragaudit: audits RAG pipelines by checking whether retrieval did the work."""
File without changes
@@ -0,0 +1,109 @@
1
+ """The Adapter Protocol and loaders for user-supplied adapter files."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+ from typing import Callable
7
+
8
+ from ragaudit.models.adapter import Adapter, ConfigurableAdapter, RAGResponse
9
+
10
+ from .callable_adapter import CallableAdapter
11
+
12
+ __all__ = [
13
+ "Adapter",
14
+ "ConfigurableAdapter",
15
+ "RAGResponse",
16
+ "AdapterLoadError",
17
+ "AdapterFactory",
18
+ "load_adapter",
19
+ "load_adapter_factory",
20
+ ]
21
+
22
+ AdapterFactory = Callable[[dict], Adapter]
23
+ """config dict -> Adapter configured with it."""
24
+
25
+
26
+ class AdapterLoadError(Exception):
27
+ """Raised when an adapter file can't be loaded or doesn't expose an adapter."""
28
+
29
+
30
+ def load_adapter(path: str) -> Adapter:
31
+ """Load an Adapter from a Python file, for the `--adapter` CLI flag.
32
+
33
+ The file must define either a module-level `adapter` object satisfying
34
+ the Adapter Protocol, or a module-level `query(question: str) -> RAGResponse`
35
+ function, which is wrapped into an Adapter via CallableAdapter.
36
+ """
37
+ module = _import_adapter_module(path)
38
+
39
+ adapter = getattr(module, "adapter", None)
40
+ if adapter is not None:
41
+ return adapter
42
+
43
+ query_fn = getattr(module, "query", None)
44
+ if callable(query_fn):
45
+ return CallableAdapter(query_fn)
46
+
47
+ raise AdapterLoadError(
48
+ f"Adapter file '{path}' must define a module-level `adapter` object "
49
+ "or a `query(question: str) -> RAGResponse` function."
50
+ )
51
+
52
+
53
+ def load_adapter_factory(path: str) -> AdapterFactory:
54
+ """Load an adapter file as a factory that builds a pipeline from a config dict (v2 evaluate/tune).
55
+
56
+ Checked in order:
57
+
58
+ 1. a module-level `make_adapter(config: dict) -> Adapter` function;
59
+ 2. an `adapter` object (or `query` function) whose adapter has a
60
+ `configure(config: dict)` method, called before returning it;
61
+ 3. a plain v1 adapter, which only accepts an empty config.
62
+ """
63
+ module = _import_adapter_module(path)
64
+
65
+ make_adapter = getattr(module, "make_adapter", None)
66
+ if callable(make_adapter):
67
+ return lambda config: make_adapter(dict(config))
68
+
69
+ query_fn = getattr(module, "query", None)
70
+ if getattr(module, "adapter", None) is not None:
71
+ base = module.adapter
72
+ elif callable(query_fn):
73
+ base = CallableAdapter(query_fn)
74
+ else:
75
+ raise AdapterLoadError(
76
+ f"Adapter file '{path}' must define `make_adapter(config)`, a module-level `adapter` object, "
77
+ "or a `query(question: str) -> RAGResponse` function."
78
+ )
79
+
80
+ def factory(config: dict) -> Adapter:
81
+ configure = getattr(base, "configure", None)
82
+ if callable(configure):
83
+ configure(dict(config))
84
+ return base
85
+ if config:
86
+ raise AdapterLoadError(
87
+ f"Adapter file '{path}' doesn't accept a config: define `make_adapter(config)` or give the "
88
+ "adapter a `configure(config)` method to use --config or `ragaudit tune`."
89
+ )
90
+ return base
91
+
92
+ return factory
93
+
94
+
95
+ def _import_adapter_module(path: str) -> ModuleType:
96
+ file_path = Path(path)
97
+ if not file_path.is_file():
98
+ raise AdapterLoadError(f"Adapter file not found: {path}")
99
+
100
+ spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
101
+ if spec is None or spec.loader is None:
102
+ raise AdapterLoadError(f"Could not load adapter module from: {path}")
103
+
104
+ module = importlib.util.module_from_spec(spec)
105
+ try:
106
+ spec.loader.exec_module(module)
107
+ except Exception as exc:
108
+ raise AdapterLoadError(f"Adapter file raised an error on import: {path}") from exc
109
+ return module
@@ -0,0 +1,15 @@
1
+ """Wraps a plain callable into the Adapter Protocol."""
2
+
3
+ from typing import Callable
4
+
5
+ from ragaudit.models.adapter import RAGResponse
6
+
7
+
8
+ class CallableAdapter:
9
+ """Adapts a plain `query(question: str) -> RAGResponse` callable to Adapter."""
10
+
11
+ def __init__(self, fn: Callable[[str], RAGResponse]) -> None:
12
+ self._fn = fn
13
+
14
+ def query(self, question: str) -> RAGResponse:
15
+ return self._fn(question)
ragaudit/cli.py ADDED
@@ -0,0 +1,540 @@
1
+ """The ragaudit CLI: ingest -> generate -> run -> report.
2
+
3
+ This module only wires together already-implemented modules (loading,
4
+ chunking, generation, running, ablation, scoring aggregation, storage,
5
+ and reporting) behind a Typer app. It contains no scoring or business
6
+ logic of its own.
7
+
8
+ v2 adds `generate --corpus`, `evaluate` and `tune`; their bodies live in
9
+ cli_v2.py. Without --corpus, `generate` runs exactly the v1 code path.
10
+ """
11
+
12
+ import functools
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import typer
19
+
20
+ from ragaudit import cli_easy, cli_v2
21
+ from ragaudit.adapters.base import AdapterLoadError, load_adapter
22
+ from ragaudit.generate.pipeline import (
23
+ DEFAULT_LEXICAL_THRESHOLD,
24
+ DEFAULT_MAX_FACTS_PER_CHUNK,
25
+ DEFAULT_MULTI_HOP_PAIRS,
26
+ )
27
+ from ragaudit.generate.testset_generator import generate_test_set
28
+ from ragaudit.ingest import chunker
29
+ from ragaudit.ingest.loader import load_documents as load_documents_from_disk
30
+ from ragaudit.llm.client import LLMClient, LLMClientError
31
+ from ragaudit.models.run import Run, RunConfig
32
+ from ragaudit.models.scoring import Verdict
33
+ from ragaudit.report.generator import generate_report
34
+ from ragaudit.runner.ablation import run_ablation
35
+ from ragaudit.runner.evaluate import DEFAULT_K, DEFAULT_REGRESSION_SAMPLE
36
+ from ragaudit.runner.executor import run_test_set, score_by_id
37
+ from ragaudit.runner.summary import compute_run_summary
38
+ from ragaudit.scoring.correctness import judge_correctness
39
+ from ragaudit.scoring.groundedness import judge_groundedness
40
+ from ragaudit.scoring.retrieval import score_retrieval_by_text
41
+ from ragaudit.scoring.text_match import DEFAULT_MATCH_THRESHOLD, validate_match_by
42
+ from ragaudit.storage import db as db_module
43
+ from ragaudit.storage import repository
44
+ from ragaudit.env import load_dotenv
45
+ from ragaudit.scaffold import DEFAULT_ADAPTER_PATH
46
+ from ragaudit.storage.store import DEFAULT_STORE_PATH
47
+ from ragaudit.tune.loop import DEFAULT_MAX_TRIALS, DEFAULT_OVERFIT_THRESHOLD
48
+
49
+ app = typer.Typer(help="Audits RAG pipelines by checking whether retrieval actually did the work.")
50
+
51
+ DB_OPTION = typer.Option(Path("ragaudit.db"), "--db", help="SQLite database path.")
52
+ STORE_OPTION = typer.Option(DEFAULT_STORE_PATH, "--store", help="v2 store path.")
53
+ LLM_OPTION = typer.Option(
54
+ None, "--llm", help="v2 LLM provider: openai (default, RAGAUDIT_API_*) or mock (offline). Env: RAGAUDIT_LLM."
55
+ )
56
+
57
+ NO_CONTEXT_SYSTEM_PROMPT = (
58
+ "Answer the question using only your own internal knowledge. You have not "
59
+ "been given any retrieved context or documents to work from."
60
+ )
61
+
62
+
63
+ def _error_exit(message: str) -> None:
64
+ """Print a clean, colored error to stderr and exit with status 1."""
65
+ typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
66
+ raise typer.Exit(code=1)
67
+
68
+
69
+ def _cli_safe(func):
70
+ """Convert any exception that escapes a command into a clean error, never a stack trace."""
71
+
72
+ @functools.wraps(func)
73
+ def wrapper(*args, **kwargs):
74
+ try:
75
+ return func(*args, **kwargs)
76
+ except typer.Exit:
77
+ raise
78
+ except AdapterLoadError as exc:
79
+ _error_exit(str(exc))
80
+ except LLMClientError as exc:
81
+ _error_exit(f"LLM request failed: {exc}")
82
+ except KeyError as exc:
83
+ _error_exit(f"missing required environment variable: {exc}")
84
+ except Exception as exc: # last-resort safety net
85
+ _error_exit(f"unexpected error: {exc}")
86
+
87
+ return wrapper
88
+
89
+
90
+ @app.callback()
91
+ def _load_settings() -> None:
92
+ # RAGAUDIT_* settings can live in ./.env (see `ragaudit init`); exported variables win.
93
+ load_dotenv()
94
+
95
+
96
+ @app.command()
97
+ @_cli_safe
98
+ def init(
99
+ framework: str = typer.Option(
100
+ "plain", "--framework",
101
+ help="Adapter template: plain, langchain, llamaindex, or demo (a ready-to-run toy pipeline, no edits needed).",
102
+ ),
103
+ out: Path = typer.Option(DEFAULT_ADAPTER_PATH, "--out", help="Where to write the adapter template."),
104
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
105
+ docs: Path = typer.Option(
106
+ cli_easy.DEFAULT_DEMO_DOCS, "--docs", help="demo only: the docs folder the demo pipeline searches."
107
+ ),
108
+ ) -> None:
109
+ """Write a starter adapter (one TODO: plug in your pipeline) and a .env.example. --framework demo needs no edits."""
110
+ cli_easy.run_init(framework, out, force, docs)
111
+
112
+
113
+ @app.command()
114
+ @_cli_safe
115
+ def doctor(
116
+ adapter: Path = typer.Option(DEFAULT_ADAPTER_PATH, "--adapter", help="Adapter file to check."),
117
+ llm: Optional[str] = LLM_OPTION,
118
+ ) -> None:
119
+ """Check your setup: Python, RAGAUDIT_* settings, one LLM call, and the adapter. Prints a fix for each failure."""
120
+ cli_easy.run_doctor(adapter, llm)
121
+
122
+
123
+ @app.command()
124
+ @_cli_safe
125
+ def ingest(
126
+ path: Path = typer.Argument(..., help="Directory containing .txt/.md documents to ingest."),
127
+ db: Path = DB_OPTION,
128
+ chunk_size: int = typer.Option(chunker.DEFAULT_CHUNK_SIZE, "--chunk-size", help="Characters per chunk."),
129
+ overlap: int = typer.Option(chunker.DEFAULT_OVERLAP, "--overlap", help="Character overlap between chunks."),
130
+ ) -> None:
131
+ """Load documents from PATH, chunk them, and persist to the database."""
132
+ if not path.exists():
133
+ _error_exit(f"path not found: {path}")
134
+
135
+ documents = load_documents_from_disk(path)
136
+ if not documents:
137
+ _error_exit(f"no .txt/.md documents found under {path}")
138
+
139
+ all_chunks = []
140
+ with typer.progressbar(documents, label="Chunking documents") as progress:
141
+ for document in progress:
142
+ all_chunks.extend(chunker.chunk_document(document, chunk_size=chunk_size, overlap=overlap))
143
+
144
+ conn = db_module.get_connection(db)
145
+ try:
146
+ repository.save_documents(conn, documents)
147
+ repository.save_chunks(conn, all_chunks)
148
+ finally:
149
+ conn.close()
150
+
151
+ typer.echo(f"Ingested {len(documents)} document(s) into {len(all_chunks)} chunk(s). Saved to {db}")
152
+
153
+
154
+ @app.command()
155
+ @_cli_safe
156
+ def generate(
157
+ n: Optional[int] = typer.Option(
158
+ None,
159
+ "--n",
160
+ help="Number of questions to generate per chunk (default 1). With --corpus: max facts per chunk (default 3).",
161
+ ),
162
+ db: Path = DB_OPTION,
163
+ corpus: Optional[Path] = typer.Option(
164
+ None,
165
+ "--corpus",
166
+ help="v2: build a verified, de-worded, dev/test-split question set from this .jsonl file or directory.",
167
+ ),
168
+ store: Path = STORE_OPTION,
169
+ llm: Optional[str] = LLM_OPTION,
170
+ generator_model: Optional[str] = typer.Option(None, "--generator-model", help="v2: generator model."),
171
+ verifier_model: Optional[str] = typer.Option(
172
+ None, "--verifier-model", help="v2: verifier model; must differ from the generator."
173
+ ),
174
+ max_chunks: Optional[int] = typer.Option(None, "--max-chunks", help="v2: process at most this many chunks."),
175
+ max_llm_calls: Optional[int] = typer.Option(None, "--max-llm-calls", help="v2: stop after this many LLM calls."),
176
+ multi_hop: bool = typer.Option(False, "--multi-hop", help="v2: also write questions that need two chunks."),
177
+ multi_hop_pairs: int = typer.Option(
178
+ DEFAULT_MULTI_HOP_PAIRS, "--multi-hop-pairs", help="v2: max chunk pairs for multi-hop per run."
179
+ ),
180
+ lexical_threshold: float = typer.Option(
181
+ DEFAULT_LEXICAL_THRESHOLD,
182
+ "--lexical-threshold",
183
+ help="v2: max share of question content words found in the chunk before a rewrite is rejected.",
184
+ ),
185
+ retry_processed: bool = typer.Option(
186
+ False, "--retry-processed", help="v2: re-process chunks that previously yielded no active question."
187
+ ),
188
+ ) -> None:
189
+ """Generate a test set from ingested chunks and persist it."""
190
+ if corpus is not None:
191
+ cli_v2.run_generate(
192
+ corpus=corpus,
193
+ store_path=store,
194
+ max_facts_per_chunk=n if n is not None else DEFAULT_MAX_FACTS_PER_CHUNK,
195
+ llm=llm,
196
+ generator_model=generator_model,
197
+ verifier_model=verifier_model,
198
+ max_chunks=max_chunks,
199
+ max_llm_calls=max_llm_calls,
200
+ multi_hop=multi_hop,
201
+ multi_hop_pairs=multi_hop_pairs,
202
+ lexical_threshold=lexical_threshold,
203
+ retry_processed=retry_processed,
204
+ )
205
+ return
206
+
207
+ n = 1 if n is None else n
208
+ if not db.exists():
209
+ _error_exit(f"database not found at {db} — run `ragaudit ingest <path>` first.")
210
+
211
+ conn = db_module.get_connection(db)
212
+ try:
213
+ documents = repository.load_documents(conn)
214
+ chunks = repository.load_chunks(conn)
215
+ if not documents or not chunks:
216
+ _error_exit("no ingested documents found — run `ragaudit ingest <path>` first.")
217
+
218
+ client = LLMClient()
219
+ typer.echo(
220
+ f"Generating {n} question(s) per chunk for {len(chunks)} chunk(s) using {client.model} "
221
+ "(this may take a while)..."
222
+ )
223
+ test_set = generate_test_set(client, documents, chunks, n_per_chunk=n)
224
+ repository.save_test_set(conn, test_set)
225
+ finally:
226
+ conn.close()
227
+
228
+ typer.echo(
229
+ f"Generated {len(test_set.test_cases)} test case(s) from {len(chunks)} chunk(s). "
230
+ f"Saved test set '{test_set.testset_id}' to {db}"
231
+ )
232
+
233
+
234
+ MATCH_BY_OPTION = typer.Option(
235
+ "id",
236
+ "--match-by",
237
+ help="How a retrieval hit is decided: id (the adapter returns ragaudit's chunk ids; default) or text "
238
+ "(a returned context overlaps the gold chunk's text; works without re-indexing your pipeline).",
239
+ )
240
+ MATCH_THRESHOLD_OPTION = typer.Option(
241
+ DEFAULT_MATCH_THRESHOLD, "--match-threshold", help="--match-by text: minimum overlap (0-1) to count as a hit."
242
+ )
243
+
244
+
245
+ @app.command()
246
+ @_cli_safe
247
+ def evaluate(
248
+ adapter: Path = typer.Option(..., "--adapter", help="Adapter file: `make_adapter(config)`, `adapter`, or `query`."),
249
+ corpus: Optional[Path] = typer.Option(
250
+ None, "--corpus", help="Current corpus, to detect new/changed/deleted chunks (default: last recorded)."
251
+ ),
252
+ config: Optional[Path] = typer.Option(None, "--config", help="YAML pipeline config passed to the adapter."),
253
+ split: str = typer.Option("dev", "--split", help="dev (default) or test. Test goes through the locked path."),
254
+ k: int = typer.Option(DEFAULT_K, "--k", help="Score retrieval over the top k retrieved chunk ids."),
255
+ regression_sample: int = typer.Option(
256
+ DEFAULT_REGRESSION_SAMPLE,
257
+ "--regression-sample",
258
+ help="When only the corpus changed: old questions to re-run alongside all new ones.",
259
+ ),
260
+ seed: int = typer.Option(0, "--seed", help="Seed for the regression sample."),
261
+ full: bool = typer.Option(False, "--full", help="Ignore regression sampling and cover every active question."),
262
+ ablation: bool = typer.Option(False, "--ablation", help="Re-ask lucky passes with no context (v1 ablation)."),
263
+ store: Path = STORE_OPTION,
264
+ llm: Optional[str] = LLM_OPTION,
265
+ judge_model: Optional[str] = typer.Option(None, "--judge-model", help="Correctness judge model."),
266
+ match_by: str = MATCH_BY_OPTION,
267
+ match_threshold: float = MATCH_THRESHOLD_OPTION,
268
+ ) -> None:
269
+ """v2: evaluate a pipeline on generated questions, with caching and regression sampling."""
270
+ cli_v2.run_evaluate(
271
+ adapter_path=adapter,
272
+ corpus=corpus,
273
+ config_path=config,
274
+ split=split,
275
+ k=k,
276
+ regression_sample=regression_sample,
277
+ seed=seed,
278
+ store_path=store,
279
+ llm=llm,
280
+ judge_model=judge_model,
281
+ ablation=ablation,
282
+ full=full,
283
+ match_by=match_by,
284
+ match_threshold=match_threshold,
285
+ )
286
+
287
+
288
+ @app.command()
289
+ @_cli_safe
290
+ def tune(
291
+ adapter: Path = typer.Option(..., "--adapter", help="Adapter file exposing `make_adapter(config)`."),
292
+ search_space: Path = typer.Option(..., "--search-space", help="YAML file of knobs and their allowed values."),
293
+ corpus: Optional[Path] = typer.Option(None, "--corpus", help="Current corpus (default: last recorded)."),
294
+ strategy: str = typer.Option("grid", "--strategy", help="grid, random (seeded) or llm."),
295
+ max_trials: int = typer.Option(DEFAULT_MAX_TRIALS, "--max-trials", help="Max configs to evaluate, baseline included."),
296
+ max_llm_calls: Optional[int] = typer.Option(
297
+ None, "--max-llm-calls", help="Cap on judge + proposer calls; room for the final test evaluation is reserved."
298
+ ),
299
+ seed: int = typer.Option(0, "--seed", help="Seed for the random strategy and fallbacks."),
300
+ k: int = typer.Option(DEFAULT_K, "--k", help="Score retrieval over the top k retrieved chunk ids."),
301
+ overfit_threshold: float = typer.Option(
302
+ DEFAULT_OVERFIT_THRESHOLD, "--overfit-threshold", help="Warn when best dev minus best test exceeds this (points)."
303
+ ),
304
+ out: Path = typer.Option(Path("best_config.yaml"), "--out", help="Where to write the best config (YAML)."),
305
+ store: Path = STORE_OPTION,
306
+ llm: Optional[str] = LLM_OPTION,
307
+ judge_model: Optional[str] = typer.Option(None, "--judge-model", help="Correctness judge model."),
308
+ proposer_model: Optional[str] = typer.Option(None, "--proposer-model", help="Model for --strategy llm."),
309
+ ) -> None:
310
+ """v2: search pipeline configs on the dev split; report baseline vs best on the locked test split."""
311
+ cli_v2.run_tune_command(
312
+ adapter_path=adapter,
313
+ search_space_path=search_space,
314
+ corpus=corpus,
315
+ strategy=strategy,
316
+ max_trials=max_trials,
317
+ max_llm_calls=max_llm_calls,
318
+ seed=seed,
319
+ k=k,
320
+ overfit_threshold=overfit_threshold,
321
+ out=out,
322
+ store_path=store,
323
+ llm=llm,
324
+ judge_model=judge_model,
325
+ proposer_model=proposer_model,
326
+ )
327
+
328
+
329
+ def _text_retrieval_scorer(chunk_texts: dict[str, str], threshold: float):
330
+ def scorer(test_case, response):
331
+ gold_text = chunk_texts.get(test_case.source_chunk_id)
332
+ if gold_text is None:
333
+ raise ValueError(f"chunk {test_case.source_chunk_id} is not in the database; re-run `ragaudit ingest`.")
334
+ return score_retrieval_by_text(
335
+ test_case.source_chunk_id, gold_text, response.retrieved_chunk_ids, response.contexts, threshold
336
+ )
337
+
338
+ return scorer
339
+
340
+
341
+ def _execute_run(
342
+ conn,
343
+ test_set,
344
+ adapter_path: Path,
345
+ client,
346
+ *,
347
+ run_ablation_flag: bool,
348
+ match_by: str,
349
+ match_threshold: float,
350
+ ) -> Run:
351
+ """Run, score, ablate and save one Run. Shared by `run` and `check`."""
352
+ loaded_adapter = load_adapter(str(adapter_path))
353
+
354
+ def correctness_judge(question: str, expected_answer: str, candidate_answer: str):
355
+ return judge_correctness(client, question, expected_answer, candidate_answer)
356
+
357
+ def groundedness_judge(answer: str, contexts: list[str]):
358
+ return judge_groundedness(client, answer, contexts)
359
+
360
+ if match_by == "text":
361
+ chunk_texts = {chunk.chunk_id: chunk.text for chunk in repository.load_chunks(conn)}
362
+ retrieval_scorer = _text_retrieval_scorer(chunk_texts, match_threshold)
363
+ typer.echo(f"Matching retrieval by text (threshold {match_threshold:g}), using the contexts the adapter returns.")
364
+ else:
365
+ retrieval_scorer = score_by_id
366
+
367
+ typer.echo(f"Running {len(test_set.test_cases)} test case(s) through adapter {adapter_path}...")
368
+ scored_cases = run_test_set(test_set, loaded_adapter, correctness_judge, groundedness_judge, retrieval_scorer)
369
+
370
+ if match_by == "text" and scored_cases and not any(case.response.contexts for case in scored_cases):
371
+ typer.secho(
372
+ "Warning: --match-by text needs the retrieved passages, but the adapter returned no `contexts` for any "
373
+ "question, so every case counts as a retrieval miss. Return the retrieved texts in RAGResponse.contexts.",
374
+ fg=typer.colors.YELLOW,
375
+ )
376
+
377
+ if run_ablation_flag:
378
+ lucky_pass_count = sum(1 for case in scored_cases if case.verdict == Verdict.LUCKY_PASS)
379
+ if lucky_pass_count:
380
+ typer.echo(f"Ablation: enabled — running against {lucky_pass_count} lucky-pass candidate(s)...")
381
+
382
+ def no_context_query(question: str) -> str:
383
+ return client.complete(NO_CONTEXT_SYSTEM_PROMPT, question)
384
+
385
+ def check_correctness(question: str, expected_answer: str, candidate_answer: str) -> bool:
386
+ return judge_correctness(client, question, expected_answer, candidate_answer).is_correct
387
+
388
+ scored_cases = run_ablation(scored_cases, test_set.test_cases, no_context_query, check_correctness)
389
+ else:
390
+ typer.echo("Ablation: enabled, but no lucky-pass candidates were found — nothing to ablate.")
391
+ else:
392
+ typer.echo("Ablation: disabled (--no-ablation).")
393
+
394
+ summary = compute_run_summary(scored_cases)
395
+ run = Run(
396
+ config=RunConfig(
397
+ run_id=str(uuid.uuid4()),
398
+ testset_id=test_set.testset_id,
399
+ adapter_path=str(adapter_path),
400
+ correctness_judge_model=client.model,
401
+ groundedness_judge_model=client.model,
402
+ run_ablation=run_ablation_flag,
403
+ started_at=datetime.now(timezone.utc),
404
+ ),
405
+ finished_at=datetime.now(timezone.utc),
406
+ scored_cases=scored_cases,
407
+ summary=summary,
408
+ )
409
+ repository.save_run(conn, run)
410
+ return run
411
+
412
+
413
+ @app.command(name="run")
414
+ @_cli_safe
415
+ def run_cmd(
416
+ adapter: Path = typer.Option(..., "--adapter", help="Path to a Python file exposing `query` or `adapter`."),
417
+ db: Path = DB_OPTION,
418
+ testset_id: Optional[str] = typer.Option(
419
+ None, "--testset-id", help="Test set id to run (defaults to the most recently generated)."
420
+ ),
421
+ no_ablation: bool = typer.Option(False, "--no-ablation", help="Skip the retrieval-disabled ablation step."),
422
+ match_by: str = MATCH_BY_OPTION,
423
+ match_threshold: float = MATCH_THRESHOLD_OPTION,
424
+ ) -> None:
425
+ """Run the adapter over a test set, score it, ablate lucky passes, and persist the Run."""
426
+ try:
427
+ validate_match_by(match_by)
428
+ except ValueError as exc:
429
+ _error_exit(str(exc))
430
+ if not db.exists():
431
+ _error_exit(f"database not found at {db} — run `ragaudit ingest` and `ragaudit generate` first.")
432
+
433
+ conn = db_module.get_connection(db)
434
+ try:
435
+ resolved_testset_id = testset_id or repository.get_latest_testset_id(conn)
436
+ if resolved_testset_id is None:
437
+ _error_exit("no test set found — run `ragaudit generate` first.")
438
+
439
+ test_set = repository.load_test_set(conn, resolved_testset_id)
440
+ if test_set is None:
441
+ _error_exit(f"test set '{resolved_testset_id}' not found.")
442
+
443
+ try:
444
+ load_adapter(str(adapter))
445
+ except AdapterLoadError as exc:
446
+ _error_exit(str(exc))
447
+
448
+ run = _execute_run(
449
+ conn,
450
+ test_set,
451
+ adapter,
452
+ LLMClient(),
453
+ run_ablation_flag=not no_ablation,
454
+ match_by=match_by,
455
+ match_threshold=match_threshold,
456
+ )
457
+ finally:
458
+ conn.close()
459
+
460
+ typer.echo(
461
+ f"Run '{run.config.run_id}' complete: {run.summary.total_cases} case(s), "
462
+ f"lucky-pass rate {run.summary.lucky_pass_rate:.1%}. Saved to {db}"
463
+ )
464
+
465
+
466
+ @app.command()
467
+ @_cli_safe
468
+ def check(
469
+ docs: Path = typer.Option(..., "--docs", help="Folder of .txt/.md documents your pipeline answers from."),
470
+ adapter: Path = typer.Option(DEFAULT_ADAPTER_PATH, "--adapter", help="Adapter file (see `ragaudit init`)."),
471
+ questions: int = typer.Option(
472
+ cli_easy.DEFAULT_CHECK_QUESTIONS, "--questions", help="How many test questions to write in total."
473
+ ),
474
+ match_by: str = typer.Option(
475
+ "text", "--match-by", help="text (default: compare returned contexts with the source passage) or id."
476
+ ),
477
+ match_threshold: float = MATCH_THRESHOLD_OPTION,
478
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
479
+ no_open: bool = typer.Option(False, "--no-open", help="Don't open the report in the browser."),
480
+ max_llm_calls: Optional[int] = typer.Option(
481
+ None, "--max-llm-calls", help="Hard cap on LLM calls (default: the estimate's upper bound)."
482
+ ),
483
+ db: Path = typer.Option(cli_easy.DEFAULT_CHECK_DB, "--db", help="Working database, recreated on every check."),
484
+ out: Path = typer.Option(cli_easy.DEFAULT_CHECK_REPORT, "--out", help="Where to write the HTML report."),
485
+ llm: Optional[str] = LLM_OPTION,
486
+ ) -> None:
487
+ """Test your RAG end to end: write questions from your docs, run your adapter, score it, open the report."""
488
+ cli_easy.run_check(
489
+ docs, adapter, questions, match_by, match_threshold, yes, no_open, max_llm_calls, db, out, llm
490
+ )
491
+
492
+
493
+ @app.command()
494
+ @_cli_safe
495
+ def report(
496
+ out: Optional[Path] = typer.Option(
497
+ None, "--out", help="Output HTML file path (default report.html; generate-report.html / tune-report.html for v2 kinds)."
498
+ ),
499
+ db: Path = DB_OPTION,
500
+ run_id: Optional[str] = typer.Option(
501
+ None, "--run-id", help="Run id to report on (defaults to the most recently run). For v2 kinds, the generate run or tune id."
502
+ ),
503
+ kind: str = typer.Option(
504
+ "run", "--kind", help="run (v1 run report, the default), generate (v2 generate run) or tune (v2 tune run)."
505
+ ),
506
+ store: Path = STORE_OPTION,
507
+ ) -> None:
508
+ """Render the latest (or specified) Run into a static HTML report. v2: --kind generate|tune."""
509
+ if kind in ("generate", "tune"):
510
+ output_path = cli_v2.run_report(kind, store, out or Path(f"{kind}-report.html"), run_id)
511
+ typer.echo(f"Report written to {output_path}")
512
+ return
513
+ if kind != "run":
514
+ _error_exit(f"--kind must be run, generate or tune, got {kind!r}")
515
+ out = out or Path("report.html")
516
+ if not db.exists():
517
+ _error_exit(f"database not found at {db} — run `ragaudit ingest`, `generate`, and `run` first.")
518
+
519
+ conn = db_module.get_connection(db)
520
+ try:
521
+ resolved_run_id = run_id or repository.get_latest_run_id(conn)
522
+ if resolved_run_id is None:
523
+ _error_exit("no run found — run `ragaudit run --adapter <path>` first.")
524
+
525
+ run = repository.load_run(conn, resolved_run_id)
526
+ if run is None:
527
+ _error_exit(f"run '{resolved_run_id}' not found.")
528
+
529
+ test_set = repository.load_test_set(conn, run.config.testset_id)
530
+ if test_set is None:
531
+ _error_exit(f"test set '{run.config.testset_id}' referenced by run '{resolved_run_id}' not found.")
532
+ finally:
533
+ conn.close()
534
+
535
+ output_path = generate_report(run, test_set, out)
536
+ typer.echo(f"Report written to {output_path}")
537
+
538
+
539
+ if __name__ == "__main__":
540
+ app()