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.
- ragaudit/__init__.py +1 -0
- ragaudit/adapters/__init__.py +0 -0
- ragaudit/adapters/base.py +109 -0
- ragaudit/adapters/callable_adapter.py +15 -0
- ragaudit/cli.py +540 -0
- ragaudit/cli_easy.py +458 -0
- ragaudit/cli_v2.py +507 -0
- ragaudit/env.py +46 -0
- ragaudit/generate/__init__.py +0 -0
- ragaudit/generate/pipeline.py +486 -0
- ragaudit/generate/prompts.py +116 -0
- ragaudit/generate/testset_generator.py +146 -0
- ragaudit/generate/text.py +75 -0
- ragaudit/ingest/__init__.py +0 -0
- ragaudit/ingest/chunker.py +53 -0
- ragaudit/ingest/corpus.py +120 -0
- ragaudit/ingest/loader.py +49 -0
- ragaudit/ingest/versioning.py +92 -0
- ragaudit/llm/__init__.py +0 -0
- ragaudit/llm/client.py +86 -0
- ragaudit/llm/factory.py +70 -0
- ragaudit/llm/json_output.py +72 -0
- ragaudit/llm/mock.py +552 -0
- ragaudit/models/__init__.py +0 -0
- ragaudit/models/adapter.py +39 -0
- ragaudit/models/document.py +28 -0
- ragaudit/models/evaluation.py +70 -0
- ragaudit/models/question.py +152 -0
- ragaudit/models/run.py +44 -0
- ragaudit/models/scoring.py +82 -0
- ragaudit/models/testset.py +30 -0
- ragaudit/models/tune.py +56 -0
- ragaudit/report/__init__.py +0 -0
- ragaudit/report/generator.py +155 -0
- ragaudit/report/plain_summary.py +122 -0
- ragaudit/report/templates/_v2_tooltip.jinja +43 -0
- ragaudit/report/templates/generate_report.html.jinja +175 -0
- ragaudit/report/templates/report.html.jinja +252 -0
- ragaudit/report/templates/style.css +1095 -0
- ragaudit/report/templates/tune_report.html.jinja +158 -0
- ragaudit/report/templates/v2.css +286 -0
- ragaudit/report/v2.py +380 -0
- ragaudit/runner/__init__.py +0 -0
- ragaudit/runner/ablation.py +46 -0
- ragaudit/runner/evaluate.py +325 -0
- ragaudit/runner/executor.py +98 -0
- ragaudit/runner/locked.py +37 -0
- ragaudit/runner/summary.py +45 -0
- ragaudit/scaffold.py +252 -0
- ragaudit/scoring/__init__.py +0 -0
- ragaudit/scoring/correctness.py +94 -0
- ragaudit/scoring/groundedness.py +86 -0
- ragaudit/scoring/retrieval.py +42 -0
- ragaudit/scoring/text_match.py +51 -0
- ragaudit/scoring/verdict.py +14 -0
- ragaudit/storage/__init__.py +0 -0
- ragaudit/storage/db.py +81 -0
- ragaudit/storage/lock.py +36 -0
- ragaudit/storage/repository.py +216 -0
- ragaudit/storage/store.py +536 -0
- ragaudit/tune/__init__.py +7 -0
- ragaudit/tune/devset.py +16 -0
- ragaudit/tune/loop.py +208 -0
- ragaudit/tune/search_space.py +140 -0
- ragaudit/tune/strategies.py +197 -0
- ragaudit/yaml_io.py +199 -0
- ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
- ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
- ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
- ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|