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/cli_v2.py ADDED
@@ -0,0 +1,507 @@
1
+ """Command bodies for the v2 CLI (generate --corpus, evaluate, tune).
2
+
3
+ cli.py owns the Typer app and option declarations; this module holds the
4
+ v2 logic behind them so the v1 commands in cli.py stay small and
5
+ unchanged. Like cli.py, it contains no scoring or generation logic of its
6
+ own: it only wires modules together and prints results.
7
+ """
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import typer
13
+
14
+ from ragaudit import yaml_io
15
+ from ragaudit.adapters.base import AdapterLoadError, load_adapter_factory
16
+ from ragaudit.generate.pipeline import GenerateOptions, GenerateSummary, generate_questions, source_key
17
+ from ragaudit.ingest.corpus import CorpusError, load_corpus
18
+ from ragaudit.ingest.versioning import CorpusDiff, sync_corpus
19
+ from ragaudit.llm.factory import PROVIDER_ENV, CallBudget, make_client, resolve_provider
20
+ from ragaudit.models.evaluation import EvalSummary
21
+ from ragaudit.models.question import Split
22
+ from ragaudit.models.tune import Trial, TuneResult
23
+ from ragaudit.runner.evaluate import compute_config_hash, file_sha256, run_evaluation
24
+ from ragaudit.runner.locked import evaluate_test_split
25
+ from ragaudit.scoring.correctness import judge_correctness
26
+ from ragaudit.scoring.text_match import DEFAULT_MATCH_THRESHOLD, validate_match_by
27
+ from ragaudit.storage.store import Store, StoreSchemaError
28
+ from ragaudit.tune.loop import TuneError, run_tune
29
+ from ragaudit.tune.search_space import SearchSpaceError, load_search_space
30
+
31
+ VERIFIER_MODEL_ENV = "RAGAUDIT_VERIFIER_MODEL"
32
+
33
+
34
+ def _fail(message: str) -> None:
35
+ from ragaudit.cli import _error_exit
36
+
37
+ _error_exit(message)
38
+
39
+
40
+ def _open_store(path: Path) -> Store:
41
+ try:
42
+ return Store(path)
43
+ except StoreSchemaError as exc:
44
+ _fail(str(exc))
45
+
46
+
47
+ def _load_corpus_or_fail(path: Path):
48
+ try:
49
+ chunks = load_corpus(path)
50
+ except CorpusError as exc:
51
+ _fail(str(exc))
52
+ if not chunks:
53
+ _fail(f"no chunks found in corpus {path}")
54
+ return chunks
55
+
56
+
57
+ def _resolve_provider(llm: str | None) -> str:
58
+ """Resolve --llm, and export it so the adapter under test uses the same provider for its own LLM calls."""
59
+ try:
60
+ provider = resolve_provider(llm)
61
+ except ValueError as exc:
62
+ _fail(str(exc))
63
+ if llm is not None:
64
+ os.environ[PROVIDER_ENV] = provider
65
+ return provider
66
+
67
+
68
+ def _generation_clients(llm: str | None, generator_model: str | None, verifier_model: str | None):
69
+ """Generator and verifier clients. The verifier must be a different model from the generator."""
70
+ provider = _resolve_provider(llm)
71
+ if provider == "mock":
72
+ generator = make_client("mock", generator_model, mock_model="mock-generator")
73
+ verifier = make_client("mock", verifier_model, mock_model="mock-verifier")
74
+ else:
75
+ verifier_model = verifier_model or os.environ.get(VERIFIER_MODEL_ENV)
76
+ if not verifier_model:
77
+ _fail(
78
+ f"a verifier model is required: pass --verifier-model or set {VERIFIER_MODEL_ENV}. "
79
+ "It must be a different model from the generator, so it can catch the generator's mistakes."
80
+ )
81
+ generator = make_client("openai", generator_model)
82
+ verifier = make_client("openai", verifier_model)
83
+
84
+ if generator.model == verifier.model:
85
+ _fail(
86
+ f"the verifier model must differ from the generator model (both are {generator.model!r}); "
87
+ "a model checking its own output misses its own mistakes."
88
+ )
89
+ return generator, verifier
90
+
91
+
92
+ def format_generate_summary(summary: GenerateSummary, totals: dict) -> list[str]:
93
+ lines = []
94
+ if summary.corpus_diff:
95
+ diff = summary.corpus_diff
96
+ lines.append(
97
+ f"Corpus: {summary.corpus_chunks} chunk(s), index {summary.index_version[:12]}: "
98
+ f"{diff.get('new', 0)} new, {diff.get('changed', 0)} changed, {diff.get('deleted', 0)} deleted, "
99
+ f"{diff.get('unchanged', 0)} unchanged; {diff.get('marked_stale', 0)} question(s) marked stale"
100
+ + (f", {diff['revived']} revived" if diff.get("revived") else "")
101
+ + "."
102
+ )
103
+ lines.append(
104
+ f"Chunks: {summary.chunks_processed} processed, {summary.chunks_skipped} skipped "
105
+ f"(already have questions at this content hash), {summary.chunks_remaining} remaining."
106
+ )
107
+ if summary.chunks_unparseable:
108
+ lines.append(f" {summary.chunks_unparseable} chunk(s) returned unparseable facts and will be retried next run.")
109
+ if summary.pairs_processed or summary.pairs_remaining:
110
+ lines.append(f"Multi-hop pairs: {summary.pairs_processed} processed, {summary.pairs_remaining} remaining.")
111
+
112
+ lines.append(
113
+ f"Questions: {summary.generated} generated (active), {summary.rejected} rejected"
114
+ + (f", {summary.duplicates} already in the store" if summary.duplicates else "")
115
+ + (f"; {summary.multi_hop_generated} of the generated are multi-hop" if summary.multi_hop_generated else "")
116
+ + "."
117
+ )
118
+ if summary.reject_reasons:
119
+ reasons = ", ".join(f"{reason} {count}" for reason, count in sorted(summary.reject_reasons.items()))
120
+ lines.append(f" Rejected by reason: {reasons}.")
121
+ lines.append(f" Split of new active questions: dev {summary.split_counts['dev']}, test {summary.split_counts['test']}.")
122
+ lines.append(f"LLM calls: {summary.llm_calls}.")
123
+
124
+ by_split = totals["by_split_status"]
125
+ lines.append(
126
+ "Store totals: "
127
+ f"dev {by_split['dev']['active']} active / test {by_split['test']['active']} active, "
128
+ f"{by_split['dev']['stale'] + by_split['test']['stale']} stale, "
129
+ f"{by_split['dev']['rejected'] + by_split['test']['rejected'] + by_split['none']['rejected']} rejected."
130
+ )
131
+
132
+ if summary.stopped_reason == "max_chunks":
133
+ lines.append(f"Stopped at --max-chunks; {summary.chunks_remaining} chunk(s) still need questions. Re-run to continue.")
134
+ elif summary.stopped_reason == "max_llm_calls":
135
+ remaining = f"{summary.chunks_remaining} chunk(s)"
136
+ if summary.pairs_remaining:
137
+ remaining += f" and {summary.pairs_remaining} multi-hop pair(s)"
138
+ lines.append(f"Stopped at --max-llm-calls; {remaining} still need questions. Progress is saved; re-run to continue.")
139
+ return lines
140
+
141
+
142
+ def run_generate(
143
+ corpus: Path,
144
+ store_path: Path,
145
+ max_facts_per_chunk: int,
146
+ llm: str | None,
147
+ generator_model: str | None,
148
+ verifier_model: str | None,
149
+ max_chunks: int | None,
150
+ max_llm_calls: int | None,
151
+ multi_hop: bool,
152
+ multi_hop_pairs: int,
153
+ lexical_threshold: float,
154
+ retry_processed: bool,
155
+ ) -> GenerateSummary:
156
+ chunks = _load_corpus_or_fail(corpus)
157
+ generator, verifier = _generation_clients(llm, generator_model, verifier_model)
158
+ options = GenerateOptions(
159
+ max_facts_per_chunk=max_facts_per_chunk,
160
+ lexical_threshold=lexical_threshold,
161
+ multi_hop=multi_hop,
162
+ multi_hop_pairs=multi_hop_pairs,
163
+ max_chunks=max_chunks,
164
+ retry_processed=retry_processed,
165
+ )
166
+
167
+ store = _open_store(store_path)
168
+ try:
169
+ typer.echo(
170
+ f"Generating questions for {len(chunks)} chunk(s) with {generator.model}, "
171
+ f"verifying with {verifier.model}..."
172
+ )
173
+ summary = generate_questions(store, chunks, generator, verifier, options, CallBudget(max_llm_calls))
174
+ totals = store.question_counts()
175
+ finally:
176
+ store.close()
177
+
178
+ for line in format_generate_summary(summary, totals):
179
+ typer.echo(line)
180
+ typer.echo(f"Saved to {store_path} (generate run {summary.run_id}).")
181
+ return summary
182
+
183
+
184
+ # --- evaluate ----------------------------------------------------------------
185
+
186
+
187
+ def load_config_file(path: Path | None) -> dict:
188
+ """A pipeline config: a YAML mapping of knob -> value (a tune best-config file works as-is)."""
189
+ if path is None:
190
+ return {}
191
+ if not path.is_file():
192
+ _fail(f"config file not found: {path}")
193
+ try:
194
+ data = yaml_io.load(path)
195
+ except yaml_io.YAMLError as exc:
196
+ _fail(f"could not parse config file {path}: {exc}")
197
+ if data is None:
198
+ return {}
199
+ if not isinstance(data, dict):
200
+ _fail(f"config file {path} must contain a mapping of knob: value")
201
+ return data
202
+
203
+
204
+ def _format_diff(diff: CorpusDiff, n_chunks: int) -> str:
205
+ counts = diff.counts()
206
+ text = (
207
+ f"Corpus: {n_chunks} chunk(s), index {diff.index_version[:12]}: {counts['new']} new, "
208
+ f"{counts['changed']} changed, {counts['deleted']} deleted, {counts['unchanged']} unchanged; "
209
+ f"{counts['marked_stale']} question(s) marked stale"
210
+ )
211
+ if counts["revived"]:
212
+ text += f", {counts['revived']} revived"
213
+ return text + "."
214
+
215
+
216
+ def sync_or_load_index(store: Store, corpus: Path | None) -> str:
217
+ """Sync the corpus (printing the diff) and return the current index_version."""
218
+ if corpus is None:
219
+ latest = store.latest_corpus_version()
220
+ if latest is None:
221
+ _fail("no corpus version recorded in the store; pass --corpus (or run `ragaudit generate --corpus` first).")
222
+ typer.echo(f"Corpus: using the last recorded version, index {latest[0][:12]} (pass --corpus to check for changes).")
223
+ return latest[0]
224
+
225
+ chunks = _load_corpus_or_fail(corpus)
226
+ diff = sync_corpus(store, chunks)
227
+ typer.echo(_format_diff(diff, len(chunks)))
228
+ done = store.active_source_keys() | store.attempted_source_keys()
229
+ needing = sum(1 for chunk in chunks if source_key([chunk]) not in done)
230
+ if needing:
231
+ typer.echo(f" {needing} chunk(s) have no questions yet; run `ragaudit generate --corpus {corpus}` to cover them.")
232
+ return diff.index_version
233
+
234
+
235
+ def format_eval_summary(summary: EvalSummary) -> list[str]:
236
+ mode = {
237
+ "full": "full (config not evaluated before)",
238
+ "regression": f"regression ({summary.n_new} new + {summary.n_sampled_old} sampled old question(s))",
239
+ "cached": "cached (corpus and config unchanged)",
240
+ }[summary.mode]
241
+ failures = summary.failure_counts
242
+ verdicts = summary.verdict_counts
243
+ lines = [
244
+ f"Evaluated {summary.n_questions} of {summary.n_active_in_split} active {summary.split} question(s); "
245
+ f"mode {mode}; {summary.n_cached} from cache, {summary.n_run} run.",
246
+ f"Score (pass rate): {summary.score:.1f} | answer accuracy {summary.answer_accuracy:.1%} | "
247
+ f"recall@{summary.k} {summary.recall_at_k:.1%} | MRR {summary.mrr:.3f}",
248
+ f"Failure classes: pass {failures['pass']}, retrieval_miss {failures['retrieval_miss']}, "
249
+ f"generation_miss {failures['generation_miss']}.",
250
+ f"v1 verdicts: true_pass {verdicts['true_pass']}, lucky_pass {verdicts['lucky_pass']}, "
251
+ f"generation_failure {verdicts['generation_failure']}, retrieval_failure {verdicts['retrieval_failure']}.",
252
+ ]
253
+ if summary.lucky_pass_confirmed is not None:
254
+ lines.append(f"Ablation: {summary.lucky_pass_confirmed} lucky pass(es) stayed correct with retrieval off.")
255
+ if summary.judge_failures or summary.adapter_errors:
256
+ lines.append(
257
+ f"Warnings: {summary.judge_failures} judge parse failure(s), {summary.adapter_errors} adapter error(s); "
258
+ "both are scored as failures."
259
+ )
260
+ if summary.mode == "regression":
261
+ lines.append(
262
+ "Note: in regression mode the score covers new questions plus a sample of old ones, not the whole split."
263
+ )
264
+ return lines
265
+
266
+
267
+ def build_adapter(adapter_path: Path, config: dict):
268
+ try:
269
+ return load_adapter_factory(str(adapter_path))(config)
270
+ except AdapterLoadError as exc:
271
+ _fail(str(exc))
272
+ except ValueError as exc:
273
+ _fail(f"adapter rejected the config: {exc}")
274
+
275
+
276
+ def make_judge(provider: str, judge_model: str | None):
277
+ client = make_client(provider, judge_model, mock_model="mock-judge")
278
+
279
+ def judge(question: str, expected_answer: str, candidate_answer: str):
280
+ return judge_correctness(client, question, expected_answer, candidate_answer)
281
+
282
+ return client, judge
283
+
284
+
285
+ def run_evaluate(
286
+ adapter_path: Path,
287
+ corpus: Path | None,
288
+ config_path: Path | None,
289
+ split: str,
290
+ k: int,
291
+ regression_sample: int,
292
+ seed: int,
293
+ store_path: Path,
294
+ llm: str | None,
295
+ judge_model: str | None,
296
+ ablation: bool,
297
+ full: bool,
298
+ match_by: str = "id",
299
+ match_threshold: float = DEFAULT_MATCH_THRESHOLD,
300
+ ) -> EvalSummary:
301
+ from ragaudit.cli import NO_CONTEXT_SYSTEM_PROMPT
302
+
303
+ try:
304
+ validate_match_by(match_by)
305
+ except ValueError as exc:
306
+ _fail(str(exc))
307
+ if match_by == "text" and corpus is None:
308
+ _fail("--match-by text compares chunk texts, so it needs the corpus: pass --corpus <path>.")
309
+
310
+ try:
311
+ split_enum = Split(split)
312
+ except ValueError:
313
+ _fail(f"--split must be dev or test, got {split!r}")
314
+ provider = _resolve_provider(llm)
315
+ config = load_config_file(config_path)
316
+
317
+ store = _open_store(store_path)
318
+ try:
319
+ index_version = sync_or_load_index(store, corpus)
320
+ if not store.question_ids(split_enum):
321
+ _fail(f"no active {split} questions in {store_path}; run `ragaudit generate --corpus <path>` first.")
322
+
323
+ adapter = build_adapter(adapter_path, config)
324
+ judge_client, judge = make_judge(provider, judge_model)
325
+ text_mode = match_by == "text"
326
+ config_hash = compute_config_hash(
327
+ config, file_sha256(adapter_path), judge_client.model, k, match_threshold if text_mode else None
328
+ )
329
+ chunk_texts = {chunk.chunk_id: chunk.text for chunk in _load_corpus_or_fail(corpus)} if text_mode else None
330
+ if text_mode:
331
+ typer.echo(f"Matching retrieval by text (threshold {match_threshold:g}), using the contexts the adapter returns.")
332
+ kwargs = dict(
333
+ chunk_texts=chunk_texts,
334
+ match_threshold=match_threshold,
335
+ config=config,
336
+ config_hash=config_hash,
337
+ index_version=index_version,
338
+ k=k,
339
+ regression_sample=regression_sample,
340
+ seed=seed,
341
+ full=full,
342
+ no_context_query=(lambda q: judge_client.complete(NO_CONTEXT_SYSTEM_PROMPT, q)) if ablation else None,
343
+ )
344
+ typer.echo(f"Evaluating the {split} split with adapter {adapter_path}, judge {judge_client.model}...")
345
+ if split_enum == Split.TEST:
346
+ summary = evaluate_test_split(store, adapter, judge, **kwargs)
347
+ else:
348
+ summary, _ = run_evaluation(store, store.dev_questions(), Split.DEV, adapter, judge, **kwargs)
349
+ finally:
350
+ store.close()
351
+
352
+ for line in format_eval_summary(summary):
353
+ typer.echo(line)
354
+ typer.echo(f"Saved eval run {summary.run_id} (config {config_hash[:12]}, index {index_version[:12]}) to {store_path}.")
355
+ return summary
356
+
357
+
358
+ # --- tune --------------------------------------------------------------------
359
+
360
+
361
+ def _config_text(config: dict) -> str:
362
+ return " ".join(f"{knob}={value}" for knob, value in config.items())
363
+
364
+
365
+ def format_tune_result(result: TuneResult) -> list[str]:
366
+ baseline, best = result.baseline, result.best
367
+ lines = [
368
+ f"Search stopped: {result.stopped_reason.replace('_', ' ')} after {len(result.trials)} trial(s); "
369
+ f"best is trial {best.index} ({best.source}).",
370
+ "",
371
+ "Final scores (pass rate, points). Test = locked split, evaluated once per config:",
372
+ f" {'':10}{'dev':>8}{'test':>8}",
373
+ f" {'baseline':10}{baseline.dev.score:8.1f}{result.baseline_test.score:8.1f}",
374
+ f" {'best':10}{best.dev.score:8.1f}{result.best_test.score:8.1f}",
375
+ "",
376
+ f"Improvement on test: {result.best_test.score - result.baseline_test.score:+.1f} points "
377
+ f"({result.baseline_test.n_questions} test question(s)).",
378
+ ]
379
+ gap = result.overfit_gap
380
+ if result.overfit_warning:
381
+ lines.append(
382
+ f"WARNING: likely overfitting. The best config scores {gap:.1f} points higher on dev than on test "
383
+ f"(threshold {result.overfit_threshold:g}). Its dev score is inflated by the search; trust the test score, "
384
+ "and consider more dev questions or a smaller search space."
385
+ )
386
+ else:
387
+ lines.append(
388
+ f"Dev-test gap for the best config: {gap:+.1f} points (threshold {result.overfit_threshold:g}): "
389
+ "no sign of overfitting to the dev set."
390
+ )
391
+ lines.append(f"LLM calls (judge + proposer): {result.llm_calls}.")
392
+ return lines
393
+
394
+
395
+ def write_best_config(result: TuneResult, path: Path) -> Path:
396
+ best = result.best
397
+ header = (
398
+ f"Best config from ragaudit tune {result.tune_id}\n"
399
+ f"strategy {result.strategy}, {len(result.trials)} trial(s), trial {best.index}; "
400
+ f"dev {best.dev.score:.1f}, locked test {result.best_test.score:.1f}\n"
401
+ f"Use with: ragaudit evaluate --adapter {result.adapter_path} --config {path}"
402
+ )
403
+ return yaml_io.dump(best.config, path, header=header)
404
+
405
+
406
+ def run_tune_command(
407
+ adapter_path: Path,
408
+ search_space_path: Path,
409
+ corpus: Path | None,
410
+ strategy: str,
411
+ max_trials: int,
412
+ max_llm_calls: int | None,
413
+ seed: int,
414
+ k: int,
415
+ overfit_threshold: float,
416
+ out: Path,
417
+ store_path: Path,
418
+ llm: str | None,
419
+ judge_model: str | None,
420
+ proposer_model: str | None,
421
+ ) -> TuneResult:
422
+ if strategy not in ("grid", "random", "llm"):
423
+ _fail(f"--strategy must be grid, random or llm, got {strategy!r}")
424
+ provider = _resolve_provider(llm)
425
+ try:
426
+ space = load_search_space(search_space_path)
427
+ except SearchSpaceError as exc:
428
+ _fail(str(exc))
429
+
430
+ store = _open_store(store_path)
431
+ try:
432
+ index_version = sync_or_load_index(store, corpus)
433
+ build_adapter(adapter_path, space.baseline)
434
+ base_factory = load_adapter_factory(str(adapter_path))
435
+
436
+ def factory(config: dict):
437
+ try:
438
+ return base_factory(config)
439
+ except (ValueError, AdapterLoadError) as exc:
440
+ raise TuneError(f"adapter rejected config {_config_text(config)}: {exc}") from exc
441
+
442
+ judge_client = make_client(provider, judge_model, mock_model="mock-judge")
443
+ proposer = make_client(provider, proposer_model, mock_model="mock-proposer") if strategy == "llm" else None
444
+ typer.echo(
445
+ f"Tuning {adapter_path} over {space.size} possible config(s) with strategy {strategy}, "
446
+ f"up to {max_trials} trial(s), scored on the dev split only..."
447
+ )
448
+
449
+ def on_trial(trial: Trial, best: Trial) -> None:
450
+ typer.echo(
451
+ f" trial {trial.index:>2} [{trial.source:<12}] dev {trial.dev.score:5.1f} "
452
+ f"(best {best.dev.score:5.1f}) {_config_text(trial.config)}"
453
+ )
454
+
455
+ try:
456
+ result = run_tune(
457
+ store,
458
+ factory,
459
+ file_sha256(adapter_path),
460
+ str(adapter_path),
461
+ space,
462
+ judge_client,
463
+ index_version,
464
+ strategy=strategy,
465
+ proposer_client=proposer,
466
+ max_trials=max_trials,
467
+ max_llm_calls=max_llm_calls,
468
+ seed=seed,
469
+ k=k,
470
+ overfit_threshold=overfit_threshold,
471
+ on_trial=on_trial,
472
+ )
473
+ except TuneError as exc:
474
+ _fail(str(exc))
475
+ result.best_config_path = str(write_best_config(result, out))
476
+ store.finish_tune_run(result.tune_id, result.finished_at, result.model_dump_json())
477
+ finally:
478
+ store.close()
479
+
480
+ typer.echo("")
481
+ for line in format_tune_result(result):
482
+ typer.echo(line)
483
+ typer.echo(f"Best config written to {out}. Trial log saved to {store_path} (tune {result.tune_id}).")
484
+ return result
485
+
486
+
487
+ def run_report(kind: str, store_path: Path, out: Path, run_id: str | None) -> Path:
488
+ """Render a v2 report: `generate` (a generate run) or `tune` (a tune run). The latest run unless run_id is given."""
489
+ from ragaudit.report.v2 import generate_generate_report, generate_tune_report, select_generate_examples
490
+
491
+ if not store_path.exists():
492
+ _fail(f"v2 store not found at {store_path}; run `ragaudit generate --corpus <path>` first.")
493
+ store = _open_store(store_path)
494
+ try:
495
+ if kind == "generate":
496
+ raw = store.load_generate_run(run_id)
497
+ if raw is None:
498
+ _fail(f"generate run '{run_id}' not found." if run_id else "no generate run found; run `ragaudit generate --corpus <path>` first.")
499
+ summary = GenerateSummary.model_validate_json(raw)
500
+ accepted, rejected = select_generate_examples(store, summary)
501
+ return generate_generate_report(summary, store.question_counts(), accepted, rejected, out)
502
+ result = store.load_tune_result(run_id)
503
+ if result is None:
504
+ _fail(f"tune run '{run_id}' not found." if run_id else "no finished tune run found; run `ragaudit tune` first.")
505
+ return generate_tune_report(result, out)
506
+ finally:
507
+ store.close()
ragaudit/env.py ADDED
@@ -0,0 +1,46 @@
1
+ """Load RAGAUDIT_* settings from a .env file in the working directory.
2
+
3
+ Only KEY=VALUE lines are read; comments and blank lines are skipped, and
4
+ surrounding quotes are stripped. Variables already set in the environment
5
+ always win, so an exported value is never replaced by the file.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ DOTENV_PATH = Path(".env")
12
+
13
+
14
+ def parse_dotenv(text: str) -> dict[str, str]:
15
+ values: dict[str, str] = {}
16
+ for raw_line in text.splitlines():
17
+ line = raw_line.strip()
18
+ if not line or line.startswith("#") or "=" not in line:
19
+ continue
20
+ if line.startswith("export "):
21
+ line = line[len("export "):]
22
+ key, _, value = line.partition("=")
23
+ key, value = key.strip(), value.strip()
24
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
25
+ value = value[1:-1]
26
+ elif " #" in value:
27
+ value = value.split(" #", 1)[0].rstrip()
28
+ if key:
29
+ values[key] = value
30
+ return values
31
+
32
+
33
+ def load_dotenv(path: Path = DOTENV_PATH) -> list[str]:
34
+ """Set variables from `path` that aren't already set. Returns the names it set (never the values)."""
35
+ if not path.is_file():
36
+ return []
37
+ try:
38
+ values = parse_dotenv(path.read_text())
39
+ except (OSError, UnicodeDecodeError):
40
+ return []
41
+ loaded = []
42
+ for key, value in values.items():
43
+ if key not in os.environ:
44
+ os.environ[key] = value
45
+ loaded.append(key)
46
+ return loaded
File without changes