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/report/v2.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Renders the v2 generate and tune reports as single, self-contained HTML files.
|
|
2
|
+
|
|
3
|
+
Same approach as the v1 report (report/generator.py): a Jinja template
|
|
4
|
+
with the canonical style.css inlined, plus `v2.css` for the few v2-only
|
|
5
|
+
components (charts, callouts). style.css itself is untouched, so v1
|
|
6
|
+
reports render byte-for-byte as before.
|
|
7
|
+
|
|
8
|
+
Charts are inline SVG built here, so the reports need no JavaScript
|
|
9
|
+
library; a small script adds hover/focus tooltips. Every number a chart
|
|
10
|
+
shows is also in a table, so nothing depends on hovering.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import html
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
19
|
+
|
|
20
|
+
from ragaudit.generate.pipeline import GenerateSummary
|
|
21
|
+
from ragaudit.models.question import Question, QuestionStatus, Split
|
|
22
|
+
from ragaudit.models.tune import TuneResult
|
|
23
|
+
from ragaudit.report.generator import TEMPLATES_DIR, _load_style_css
|
|
24
|
+
|
|
25
|
+
REJECT_REASON_LABELS = {
|
|
26
|
+
"too_lexical": "Too lexical: still reused the passage's wording after one rewrite",
|
|
27
|
+
"relative_time": "Relative time: used words like \"recently\" or \"last quarter\"",
|
|
28
|
+
"unsupported": "Unsupported: the verifier couldn't find the fact or answer in the passage",
|
|
29
|
+
"unanswerable": "Unanswerable: the question can't be answered from the passage, or gives the answer away",
|
|
30
|
+
"ambiguous": "Ambiguous: more than one answer, or unclear what is asked",
|
|
31
|
+
"verifier_unparseable": "Verifier output unparseable: rejected to be safe",
|
|
32
|
+
"cross_split": "Cross-split: a multi-hop question whose chunks are in different splits",
|
|
33
|
+
}
|
|
34
|
+
FAILURE_CLASSES = [
|
|
35
|
+
("pass", "Pass", "v2-fill-pass"),
|
|
36
|
+
("generation_miss", "Generation miss", "v2-fill-gen"),
|
|
37
|
+
("retrieval_miss", "Retrieval miss", "v2-fill-ret"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_W = 720
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _e(value) -> str:
|
|
44
|
+
return html.escape(str(value), quote=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_v2_css() -> str:
|
|
48
|
+
try:
|
|
49
|
+
return resources.files("ragaudit.report").joinpath("templates", "v2.css").read_text(encoding="utf-8")
|
|
50
|
+
except (FileNotFoundError, ModuleNotFoundError, OSError) as exc:
|
|
51
|
+
raise RuntimeError("Could not load ragaudit/report/templates/v2.css from package data.") from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _env() -> Environment:
|
|
55
|
+
return Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=select_autoescape(["html", "jinja"]))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_mock(*models: str) -> bool:
|
|
59
|
+
return any(model.startswith("mock") for model in models if model)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bar(x: float, y: float, w: float, h: float, cls: str, tip: str, *, end: str = "top", r: float = 4) -> str:
|
|
63
|
+
"""A bar with a rounded data end and a square baseline end, carrying a tooltip."""
|
|
64
|
+
r = max(0.0, min(r, w / 2, h / 2))
|
|
65
|
+
if end == "top":
|
|
66
|
+
d = (
|
|
67
|
+
f"M{x:.1f},{y + h:.1f} V{y + r:.1f} Q{x:.1f},{y:.1f} {x + r:.1f},{y:.1f} "
|
|
68
|
+
f"H{x + w - r:.1f} Q{x + w:.1f},{y:.1f} {x + w:.1f},{y + r:.1f} V{y + h:.1f} Z"
|
|
69
|
+
)
|
|
70
|
+
elif end == "right":
|
|
71
|
+
d = (
|
|
72
|
+
f"M{x:.1f},{y:.1f} H{x + w - r:.1f} Q{x + w:.1f},{y:.1f} {x + w:.1f},{y + r:.1f} "
|
|
73
|
+
f"V{y + h - r:.1f} Q{x + w:.1f},{y + h:.1f} {x + w - r:.1f},{y + h:.1f} H{x:.1f} Z"
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
d = f"M{x:.1f},{y:.1f} H{x + w:.1f} V{y + h:.1f} H{x:.1f} Z"
|
|
77
|
+
return f'<path class="v2-mark {cls}" d="{d}" data-tip="{_e(tip)}" tabindex="0"></path>'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _svg(height: int, body: list[str], label: str) -> str:
|
|
81
|
+
return (
|
|
82
|
+
f'<svg class="v2-chart" viewBox="0 0 {_W} {height}" role="img" aria-label="{_e(label)}" '
|
|
83
|
+
f'preserveAspectRatio="xMidYMid meet">{"".join(body)}</svg>'
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _nice_step(raw: float) -> int:
|
|
88
|
+
"""The smallest 1/2/5 x 10^n step at or above raw (minimum 1)."""
|
|
89
|
+
magnitude = 1
|
|
90
|
+
while True:
|
|
91
|
+
for factor in (1, 2, 5):
|
|
92
|
+
if factor * magnitude >= raw:
|
|
93
|
+
return factor * magnitude
|
|
94
|
+
magnitude *= 10
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _y_grid(left: int, right: int, top: int, bottom: int, ticks: list[float], vmax: float, fmt=str) -> list[str]:
|
|
98
|
+
parts = []
|
|
99
|
+
for tick in ticks:
|
|
100
|
+
y = bottom - (bottom - top) * tick / vmax
|
|
101
|
+
parts.append(f'<line class="v2-grid" x1="{left}" x2="{right}" y1="{y:.1f}" y2="{y:.1f}"></line>')
|
|
102
|
+
parts.append(f'<text class="v2-tick" x="{left - 8}" y="{y + 4:.1f}" text-anchor="end">{_e(fmt(tick))}</text>')
|
|
103
|
+
return parts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- generate report charts --------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def overlap_histogram(values: list[float], threshold: float, bins: int = 10) -> tuple[str, list[dict]]:
|
|
110
|
+
"""Histogram of final lexical overlaps; bins at or below the threshold are the accent, above are muted."""
|
|
111
|
+
counts = [0] * bins
|
|
112
|
+
for value in values:
|
|
113
|
+
index = 0 if value <= 1 / bins else min(bins - 1, int(-(-value * bins // 1)) - 1)
|
|
114
|
+
counts[index] += 1
|
|
115
|
+
rows = []
|
|
116
|
+
for i, count in enumerate(counts):
|
|
117
|
+
low, high = i / bins, (i + 1) / bins
|
|
118
|
+
rows.append({"range": f"{'[' if i == 0 else '('}{low:.1f}, {high:.1f}]", "count": count, "kept": high <= threshold + 1e-9})
|
|
119
|
+
|
|
120
|
+
left, right, top, bottom = 44, _W - 16, 28, 230
|
|
121
|
+
peak = max(max(counts), 1)
|
|
122
|
+
step = _nice_step(peak / 4)
|
|
123
|
+
vmax = step * -(-peak // step)
|
|
124
|
+
ticks = [step * i for i in range(int(vmax // step) + 1)]
|
|
125
|
+
band = (right - left) / bins
|
|
126
|
+
body = _y_grid(left, right, top, bottom, ticks, vmax)
|
|
127
|
+
for i, row in enumerate(rows):
|
|
128
|
+
h = (bottom - top) * row["count"] / vmax
|
|
129
|
+
if h > 0:
|
|
130
|
+
cls = "v2-fill-accent" if row["kept"] else "v2-fill-muted"
|
|
131
|
+
verdict = "kept" if row["kept"] else "above threshold: rejected as too_lexical"
|
|
132
|
+
body.append(_bar(left + i * band + 1, bottom - h, band - 2, h, cls, f"{row['count']} question(s)\noverlap {row['range']}, {verdict}"))
|
|
133
|
+
for i in range(bins + 1):
|
|
134
|
+
if i % 2 == 0:
|
|
135
|
+
body.append(f'<text class="v2-tick" x="{left + i * band:.1f}" y="{bottom + 18}" text-anchor="middle">{i / bins:.1f}</text>')
|
|
136
|
+
tx = left + (right - left) * threshold
|
|
137
|
+
body.append(f'<line class="v2-rule" x1="{tx:.1f}" x2="{tx:.1f}" y1="{top - 10}" y2="{bottom}"></line>')
|
|
138
|
+
body.append(f'<text class="v2-label" x="{tx + 6:.1f}" y="{top - 2}">threshold {threshold:g}</text>')
|
|
139
|
+
body.append(f'<text class="v2-axis-title" x="{(left + right) / 2:.1f}" y="{bottom + 36}" text-anchor="middle">share of the question\'s content words found in its source chunk</text>')
|
|
140
|
+
return _svg(272, body, "Histogram of lexical overlap"), rows
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def reason_bars(reasons: dict[str, int]) -> str:
|
|
144
|
+
items = sorted(reasons.items(), key=lambda item: (-item[1], item[0]))
|
|
145
|
+
if not items:
|
|
146
|
+
return ""
|
|
147
|
+
row_h, top, left, right = 34, 8, 170, _W - 60
|
|
148
|
+
vmax = max(count for _, count in items)
|
|
149
|
+
body = []
|
|
150
|
+
for i, (reason, count) in enumerate(items):
|
|
151
|
+
y = top + i * row_h
|
|
152
|
+
w = (right - left) * count / vmax
|
|
153
|
+
body.append(f'<text class="v2-label" x="{left - 10}" y="{y + 16}" text-anchor="end">{_e(reason)}</text>')
|
|
154
|
+
body.append(_bar(left, y + 4, w, 18, "v2-fill-accent", f"{count} rejected\n{REJECT_REASON_LABELS.get(reason, reason)}", end="right"))
|
|
155
|
+
body.append(f'<text class="v2-value" x="{left + w + 8:.1f}" y="{y + 17}">{count}</text>')
|
|
156
|
+
return _svg(top + row_h * len(items), body, "Rejected questions by reason")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- tune report charts ------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def trial_chart(result: TuneResult) -> str:
|
|
163
|
+
trials = result.trials
|
|
164
|
+
left, right, top, bottom = 44, _W - 24, 24, 230
|
|
165
|
+
n = len(trials)
|
|
166
|
+
span = max(n - 1, 1)
|
|
167
|
+
|
|
168
|
+
def x_of(index: int) -> float:
|
|
169
|
+
return left + 12 + (right - left - 24) * index / span if n > 1 else (left + right) / 2
|
|
170
|
+
|
|
171
|
+
def y_of(score: float) -> float:
|
|
172
|
+
return bottom - (bottom - top) * score / 100
|
|
173
|
+
|
|
174
|
+
body = _y_grid(left, right, top, bottom, [0, 25, 50, 75, 100], 100)
|
|
175
|
+
best_so_far, running = [], -1.0
|
|
176
|
+
for trial in trials:
|
|
177
|
+
running = max(running, trial.dev.score)
|
|
178
|
+
best_so_far.append(running)
|
|
179
|
+
if n > 1:
|
|
180
|
+
points = []
|
|
181
|
+
for i, score in enumerate(best_so_far):
|
|
182
|
+
if i:
|
|
183
|
+
points.append(f"{x_of(i):.1f},{y_of(best_so_far[i - 1]):.1f}")
|
|
184
|
+
points.append(f"{x_of(i):.1f},{y_of(score):.1f}")
|
|
185
|
+
body.append(f'<polyline class="v2-line-ref" points="{" ".join(points)}"></polyline>')
|
|
186
|
+
for trial in trials:
|
|
187
|
+
cx, cy = x_of(trial.index), y_of(trial.dev.score)
|
|
188
|
+
changed = ", ".join(f"{k}={v}" for k, v in trial.config.items() if result.baseline.config.get(k) != v) or "baseline config"
|
|
189
|
+
tip = f"{trial.dev.score:.1f} dev\ntrial {trial.index} ({trial.source})\n{changed}"
|
|
190
|
+
cls = "v2-dot v2-dot-best" if trial.index == result.best_index else "v2-dot"
|
|
191
|
+
body.append(f'<circle class="{cls}" cx="{cx:.1f}" cy="{cy:.1f}" r="5"></circle>')
|
|
192
|
+
body.append(f'<circle class="v2-hit" cx="{cx:.1f}" cy="{cy:.1f}" r="12" data-tip="{_e(tip)}" tabindex="0"></circle>')
|
|
193
|
+
for index, name in ((result.baseline_index, "baseline"), (result.best_index, "best")):
|
|
194
|
+
if name == "best" and index == result.baseline_index:
|
|
195
|
+
continue
|
|
196
|
+
trial = trials[index]
|
|
197
|
+
anchor = "start" if x_of(index) < (left + right) / 2 else "end"
|
|
198
|
+
dx = 10 if anchor == "start" else -10
|
|
199
|
+
body.append(
|
|
200
|
+
f'<text class="v2-label" x="{x_of(index) + dx:.1f}" y="{y_of(trial.dev.score) - 10:.1f}" '
|
|
201
|
+
f'text-anchor="{anchor}">{name} {trial.dev.score:.1f}</text>'
|
|
202
|
+
)
|
|
203
|
+
step = max(1, n // 10)
|
|
204
|
+
for i in range(0, n, step):
|
|
205
|
+
body.append(f'<text class="v2-tick" x="{x_of(i):.1f}" y="{bottom + 18}" text-anchor="middle">{i}</text>')
|
|
206
|
+
body.append(f'<text class="v2-axis-title" x="{(left + right) / 2:.1f}" y="{bottom + 36}" text-anchor="middle">trial</text>')
|
|
207
|
+
return _svg(272, body, "Dev score per trial")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def dev_test_columns(result: TuneResult) -> str:
|
|
211
|
+
groups = [("Baseline", result.baseline.dev.score, result.baseline_test.score), ("Best", result.best.dev.score, result.best_test.score)]
|
|
212
|
+
left, right, top, bottom = 44, _W - 16, 28, 210
|
|
213
|
+
body = _y_grid(left, right, top, bottom, [0, 25, 50, 75, 100], 100)
|
|
214
|
+
band = (right - left) / len(groups)
|
|
215
|
+
for g, (name, dev, test) in enumerate(groups):
|
|
216
|
+
center = left + band * (g + 0.5)
|
|
217
|
+
for offset, value, cls, split in ((-32, dev, "v2-fill-dev", "dev"), (8, test, "v2-fill-test", "locked test")):
|
|
218
|
+
h = (bottom - top) * value / 100
|
|
219
|
+
x = center + offset
|
|
220
|
+
if h > 0:
|
|
221
|
+
body.append(_bar(x, bottom - h, 24, h, cls, f"{value:.1f} {split}\n{name.lower()} config"))
|
|
222
|
+
body.append(f'<text class="v2-value" x="{x + 12:.1f}" y="{bottom - h - 6:.1f}" text-anchor="middle">{value:.1f}</text>')
|
|
223
|
+
body.append(f'<text class="v2-label" x="{center:.1f}" y="{bottom + 20}" text-anchor="middle">{name}</text>')
|
|
224
|
+
return _svg(232, body, "Dev and test score, baseline and best")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def failure_stack(rows: list[tuple[str, dict[str, int]]]) -> str:
|
|
228
|
+
row_h, top, left, right = 40, 6, 130, _W - 16
|
|
229
|
+
body = []
|
|
230
|
+
for i, (label, counts) in enumerate(rows):
|
|
231
|
+
y = top + i * row_h
|
|
232
|
+
total = sum(counts.get(key, 0) for key, _, _ in FAILURE_CLASSES) or 1
|
|
233
|
+
body.append(f'<text class="v2-label" x="{left - 10}" y="{y + 17}" text-anchor="end">{_e(label)}</text>')
|
|
234
|
+
x = left
|
|
235
|
+
segments = [(key, name, cls, counts.get(key, 0)) for key, name, cls in FAILURE_CLASSES if counts.get(key, 0)]
|
|
236
|
+
for j, (key, name, cls, count) in enumerate(segments):
|
|
237
|
+
w = (right - left) * count / total
|
|
238
|
+
last = j == len(segments) - 1
|
|
239
|
+
draw_w = w - (0 if last else 2)
|
|
240
|
+
share = count / total * 100
|
|
241
|
+
body.append(_bar(x, y + 2, max(draw_w, 0.5), 24, cls, f"{count} ({share:.0f}%)\n{name}, {label}", end="right" if last else "none"))
|
|
242
|
+
text = f"{count}"
|
|
243
|
+
if draw_w > len(text) * 7 + 12:
|
|
244
|
+
body.append(f'<text class="v2-inside" x="{x + draw_w / 2:.1f}" y="{y + 18}" text-anchor="middle">{text}</text>')
|
|
245
|
+
x += w
|
|
246
|
+
return _svg(top + row_h * len(rows), body, "Failure classes before and after tuning")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --- page assembly -----------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _write(template_name: str, output_path: str | Path, **context) -> Path:
|
|
253
|
+
output_path = Path(output_path)
|
|
254
|
+
page = _env().get_template(template_name).render(style_css=_load_style_css(), v2_css=_load_v2_css(), **context)
|
|
255
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
output_path.write_text(page, encoding="utf-8")
|
|
257
|
+
return output_path
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _question_row(q: Question) -> dict:
|
|
261
|
+
return {
|
|
262
|
+
"question": q.question,
|
|
263
|
+
"answer": q.gold_answer,
|
|
264
|
+
"fact": q.fact,
|
|
265
|
+
"chunks": ", ".join(q.chunk_ids),
|
|
266
|
+
"type": q.question_type.value,
|
|
267
|
+
"overlap": f"{q.lexical_overlap:.2f}" if q.lexical_overlap is not None else "n/a",
|
|
268
|
+
"reason": q.reject_reason,
|
|
269
|
+
"draft": q.metadata.get("draft_question"),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def generate_generate_report(
|
|
274
|
+
summary: GenerateSummary,
|
|
275
|
+
totals: dict,
|
|
276
|
+
accepted_examples: list[Question],
|
|
277
|
+
rejected_examples: list[Question],
|
|
278
|
+
output_path: str | Path,
|
|
279
|
+
) -> Path:
|
|
280
|
+
"""Render a generate run. Accepted examples must be dev-split only; the caller guarantees it."""
|
|
281
|
+
if any(q.split != Split.DEV for q in [*accepted_examples, *rejected_examples]):
|
|
282
|
+
raise ValueError("generate report examples must come from the dev split only")
|
|
283
|
+
histogram, histogram_rows = overlap_histogram(summary.lexical_overlaps, summary.lexical_threshold)
|
|
284
|
+
reasons = sorted(summary.reject_reasons.items(), key=lambda item: (-item[1], item[0]))
|
|
285
|
+
checked = summary.generated + summary.rejected
|
|
286
|
+
rejected_by_reason: dict[str, list[dict]] = {}
|
|
287
|
+
for q in rejected_examples:
|
|
288
|
+
rejected_by_reason.setdefault(q.reject_reason or "unknown", []).append(_question_row(q))
|
|
289
|
+
|
|
290
|
+
return _write(
|
|
291
|
+
"generate_report.html.jinja",
|
|
292
|
+
output_path,
|
|
293
|
+
summary=summary,
|
|
294
|
+
totals=totals["by_split_status"],
|
|
295
|
+
is_mock=_is_mock(summary.generator_model, summary.verifier_model),
|
|
296
|
+
checked=checked,
|
|
297
|
+
pass_pct=round(summary.generated / checked * 100, 1) if checked else 0.0,
|
|
298
|
+
top_reason=reasons[0] if reasons else None,
|
|
299
|
+
reasons=[{"reason": r, "count": c, "label": REJECT_REASON_LABELS.get(r, r)} for r, c in reasons],
|
|
300
|
+
reason_chart=reason_bars(summary.reject_reasons),
|
|
301
|
+
histogram=histogram,
|
|
302
|
+
histogram_rows=histogram_rows,
|
|
303
|
+
overlap_median=sorted(summary.lexical_overlaps)[len(summary.lexical_overlaps) // 2] if summary.lexical_overlaps else None,
|
|
304
|
+
accepted=[_question_row(q) for q in accepted_examples],
|
|
305
|
+
rejected_by_reason=rejected_by_reason,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def generate_tune_report(result: TuneResult, output_path: str | Path) -> Path:
|
|
310
|
+
if result.baseline_test is None or result.best_test is None:
|
|
311
|
+
raise ValueError("tune result has no final test evaluation; it can't be reported")
|
|
312
|
+
baseline, best = result.baseline, result.best
|
|
313
|
+
knobs = [
|
|
314
|
+
{"knob": knob, "baseline": baseline.config.get(knob), "best": best.config.get(knob), "changed": baseline.config.get(knob) != best.config.get(knob)}
|
|
315
|
+
for knob in result.search_space
|
|
316
|
+
]
|
|
317
|
+
sources = Counter(t.source for t in result.trials)
|
|
318
|
+
trial_rows = [
|
|
319
|
+
{
|
|
320
|
+
"index": t.index,
|
|
321
|
+
"source": t.source,
|
|
322
|
+
"score": t.dev.score,
|
|
323
|
+
"counts": t.dev.failure_counts,
|
|
324
|
+
"changed": ", ".join(f"{k}={v}" for k, v in t.config.items() if baseline.config.get(k) != v) or "(baseline)",
|
|
325
|
+
"rationale": t.rationale or "",
|
|
326
|
+
"is_best": t.index == result.best_index,
|
|
327
|
+
}
|
|
328
|
+
for t in result.trials
|
|
329
|
+
]
|
|
330
|
+
stack_rows = [
|
|
331
|
+
("Baseline, dev", baseline.dev.failure_counts),
|
|
332
|
+
("Best, dev", best.dev.failure_counts),
|
|
333
|
+
("Baseline, test", result.baseline_test.failure_counts),
|
|
334
|
+
("Best, test", result.best_test.failure_counts),
|
|
335
|
+
]
|
|
336
|
+
return _write(
|
|
337
|
+
"tune_report.html.jinja",
|
|
338
|
+
output_path,
|
|
339
|
+
result=result,
|
|
340
|
+
baseline=baseline,
|
|
341
|
+
best=best,
|
|
342
|
+
is_mock=_is_mock(result.judge_model),
|
|
343
|
+
improvement=round(result.best_test.score - result.baseline_test.score, 1),
|
|
344
|
+
dev_improvement=round(best.dev.score - baseline.dev.score, 1),
|
|
345
|
+
trial_chart=trial_chart(result),
|
|
346
|
+
dev_test_chart=dev_test_columns(result),
|
|
347
|
+
failure_chart=failure_stack(stack_rows),
|
|
348
|
+
stack_rows=stack_rows,
|
|
349
|
+
failure_classes=FAILURE_CLASSES,
|
|
350
|
+
knobs=knobs,
|
|
351
|
+
trial_rows=trial_rows,
|
|
352
|
+
sources=dict(sources),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def select_generate_examples(store, summary: GenerateSummary, limit: int = 10) -> tuple[list[Question], list[Question]]:
|
|
357
|
+
"""Accepted and rejected examples, both from the dev split only.
|
|
358
|
+
|
|
359
|
+
Splits are per chunk, so even a rejected test-split question would show
|
|
360
|
+
text from a test chunk; the report never does.
|
|
361
|
+
"""
|
|
362
|
+
accepted = [q for q in store.dev_questions() if q.created_at >= summary.created_at] or store.dev_questions()
|
|
363
|
+
rejected_all = [
|
|
364
|
+
q
|
|
365
|
+
for q in store.load_questions(split=Split.DEV, statuses=[QuestionStatus.REJECTED])
|
|
366
|
+
if q.created_at >= summary.created_at
|
|
367
|
+
]
|
|
368
|
+
by_reason: dict[str, list[Question]] = {}
|
|
369
|
+
for q in sorted(rejected_all, key=lambda q: q.created_at):
|
|
370
|
+
by_reason.setdefault(q.reject_reason or "unknown", []).append(q)
|
|
371
|
+
rejected = [q for questions in by_reason.values() for q in questions[:3]]
|
|
372
|
+
accepted = sorted(accepted, key=lambda q: (q.question_type.value != "multi_hop", q.created_at))
|
|
373
|
+
picked = accepted[: limit // 2] + [q for q in accepted if q.question_type.value == "single"][limit // 2 : limit]
|
|
374
|
+
seen, unique = set(), []
|
|
375
|
+
for q in picked:
|
|
376
|
+
if q.question_id not in seen:
|
|
377
|
+
seen.add(q.question_id)
|
|
378
|
+
unique.append(q)
|
|
379
|
+
return unique[:limit], rejected
|
|
380
|
+
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Re-runs LUCKY_PASS candidates with no context to empirically confirm them."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from ragaudit.models.scoring import AblationResult, ScoredCase, Verdict
|
|
6
|
+
from ragaudit.models.testset import TestCase
|
|
7
|
+
|
|
8
|
+
NoContextQuery = Callable[[str], str]
|
|
9
|
+
"""question -> answer, produced with retrieval/context disabled entirely."""
|
|
10
|
+
|
|
11
|
+
CorrectnessCheck = Callable[[str, str, str], bool]
|
|
12
|
+
"""question, expected_answer, candidate_answer -> is_correct"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_ablation(
|
|
16
|
+
scored_cases: list[ScoredCase],
|
|
17
|
+
test_cases: list[TestCase],
|
|
18
|
+
no_context_query: NoContextQuery,
|
|
19
|
+
check_correctness: CorrectnessCheck,
|
|
20
|
+
) -> list[ScoredCase]:
|
|
21
|
+
"""Populate AblationResult for LUCKY_PASS rows only.
|
|
22
|
+
|
|
23
|
+
For every ScoredCase whose verdict is LUCKY_PASS, re-asks the question
|
|
24
|
+
with no context at all. If the answer is still correct, retrieval was
|
|
25
|
+
contributing nothing — confirms_lucky_pass is True. All other verdicts
|
|
26
|
+
are returned unchanged (ablation stays None).
|
|
27
|
+
"""
|
|
28
|
+
test_case_by_id = {tc.test_id: tc for tc in test_cases}
|
|
29
|
+
updated: list[ScoredCase] = []
|
|
30
|
+
|
|
31
|
+
for scored_case in scored_cases:
|
|
32
|
+
if scored_case.verdict != Verdict.LUCKY_PASS:
|
|
33
|
+
updated.append(scored_case)
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
test_case = test_case_by_id[scored_case.test_id]
|
|
37
|
+
no_context_answer = no_context_query(test_case.question)
|
|
38
|
+
is_correct = check_correctness(test_case.question, test_case.expected_answer, no_context_answer)
|
|
39
|
+
ablation = AblationResult(
|
|
40
|
+
answer=no_context_answer,
|
|
41
|
+
is_correct=is_correct,
|
|
42
|
+
confirms_lucky_pass=is_correct,
|
|
43
|
+
)
|
|
44
|
+
updated.append(scored_case.model_copy(update={"ablation": ablation}))
|
|
45
|
+
|
|
46
|
+
return updated
|