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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""A plain-English summary of a run: what's wrong, in at most six lines.
|
|
2
|
+
|
|
3
|
+
Shared by `ragaudit check` (printed in the terminal) and the top of the
|
|
4
|
+
HTML run report, so both say the same thing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ragaudit.models.run import RunSummary
|
|
10
|
+
from ragaudit.models.scoring import ScoredCase, Verdict
|
|
11
|
+
|
|
12
|
+
HEALTHY_TRUE_PASS_RATE = 0.8
|
|
13
|
+
"""A run is healthy when at least this share of answers is correct and backed by the right passage."""
|
|
14
|
+
|
|
15
|
+
# Tie-break order when two failure classes are equally common: a retrieval
|
|
16
|
+
# problem undermines everything downstream, and a lucky pass hides one.
|
|
17
|
+
_FAILURE_ORDER = (Verdict.RETRIEVAL_FAILURE, Verdict.LUCKY_PASS, Verdict.GENERATION_FAILURE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PlainSummary:
|
|
22
|
+
status: str
|
|
23
|
+
"""healthy, needs_work, or empty."""
|
|
24
|
+
verdict: str
|
|
25
|
+
retrieval: str
|
|
26
|
+
correctness: str
|
|
27
|
+
lucky: str
|
|
28
|
+
fix: str
|
|
29
|
+
dominant: Verdict | None
|
|
30
|
+
|
|
31
|
+
def lines(self) -> list[str]:
|
|
32
|
+
return [self.verdict, self.retrieval, self.correctness, self.lucky, f"Most useful fix: {self.fix}"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _pct(rate: float) -> str:
|
|
36
|
+
return f"{rate:.0%}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dominant_failure(summary: RunSummary) -> Verdict | None:
|
|
40
|
+
counts = {verdict: summary.verdict_counts.get(verdict, 0) for verdict in _FAILURE_ORDER}
|
|
41
|
+
worst = max(_FAILURE_ORDER, key=lambda verdict: (counts[verdict], -_FAILURE_ORDER.index(verdict)))
|
|
42
|
+
return worst if counts[worst] else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fix(dominant: Verdict | None, count: int, no_contexts: bool, match_by: str, no_id_hits: bool = False) -> str:
|
|
46
|
+
if no_id_hits:
|
|
47
|
+
return (
|
|
48
|
+
"no retrieved chunk id matched ragaudit's, which usually means your pipeline uses its own chunks. "
|
|
49
|
+
"Rerun with --match-by text, or return ragaudit's chunk ids in retrieved_chunk_ids."
|
|
50
|
+
)
|
|
51
|
+
if no_contexts:
|
|
52
|
+
return (
|
|
53
|
+
"your adapter returned no retrieved passages, so retrieval could not be scored. Return the text "
|
|
54
|
+
"of each retrieved passage in RAGResponse.contexts."
|
|
55
|
+
)
|
|
56
|
+
if dominant is None:
|
|
57
|
+
return "nothing stands out. Keep this check in CI to catch regressions."
|
|
58
|
+
if dominant == Verdict.RETRIEVAL_FAILURE:
|
|
59
|
+
hint = "retrieve more passages (raise top-k), add keyword/hybrid search, or try smaller chunks"
|
|
60
|
+
if match_by == "id":
|
|
61
|
+
hint += "; if your pipeline chunks the docs itself, rerun with --match-by text"
|
|
62
|
+
return f"improve retrieval. For {count} question(s) the right passage was not found and the answer was wrong: {hint}."
|
|
63
|
+
if dominant == Verdict.LUCKY_PASS:
|
|
64
|
+
return (
|
|
65
|
+
f"don't trust the correct answers yet. {count} were right without the right passage being retrieved, "
|
|
66
|
+
"so the model answered from memory. Fix retrieval first, and tell the model to answer only from the context."
|
|
67
|
+
)
|
|
68
|
+
return (
|
|
69
|
+
f"improve answer generation. For {count} question(s) the right passage was retrieved but the answer was "
|
|
70
|
+
"still wrong: tighten the prompt to answer from the context, show it fewer but better passages, or use a stronger model."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_plain_summary(summary: RunSummary, scored_cases: list[ScoredCase]) -> PlainSummary:
|
|
75
|
+
total = summary.total_cases
|
|
76
|
+
if total == 0:
|
|
77
|
+
return PlainSummary(
|
|
78
|
+
status="empty",
|
|
79
|
+
verdict="Verdict: NO RESULTS. The run had no questions to score.",
|
|
80
|
+
retrieval="Retrieval hit rate: n/a",
|
|
81
|
+
correctness="Answer correctness: n/a",
|
|
82
|
+
lucky="Lucky passes: n/a",
|
|
83
|
+
fix="generate questions first (check that the docs folder has .txt or .md files).",
|
|
84
|
+
dominant=None,
|
|
85
|
+
)
|
|
86
|
+
counts = summary.verdict_counts
|
|
87
|
+
true_pass = counts.get(Verdict.TRUE_PASS, 0)
|
|
88
|
+
lucky = counts.get(Verdict.LUCKY_PASS, 0)
|
|
89
|
+
hits = sum(1 for case in scored_cases if case.retrieval.hit)
|
|
90
|
+
correct = sum(1 for case in scored_cases if case.correctness.is_correct)
|
|
91
|
+
match_by = next((case.retrieval.match_by for case in scored_cases), "id")
|
|
92
|
+
no_contexts = match_by == "text" and not any(case.response.contexts for case in scored_cases)
|
|
93
|
+
dominant = dominant_failure(summary)
|
|
94
|
+
|
|
95
|
+
if true_pass / total >= HEALTHY_TRUE_PASS_RATE and not no_contexts:
|
|
96
|
+
status = "healthy"
|
|
97
|
+
verdict = (
|
|
98
|
+
f"Verdict: HEALTHY. {true_pass} of {total} answers were correct and came from the right passage."
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
status = "needs_work"
|
|
102
|
+
problem = {
|
|
103
|
+
Verdict.RETRIEVAL_FAILURE: "retrieval is missing the right passage",
|
|
104
|
+
Verdict.LUCKY_PASS: "correct answers are not coming from retrieval",
|
|
105
|
+
Verdict.GENERATION_FAILURE: "answers are wrong even when retrieval is right",
|
|
106
|
+
None: "too few answers are both correct and grounded",
|
|
107
|
+
}[None if no_contexts else dominant]
|
|
108
|
+
verdict = f"Verdict: NEEDS WORK. Only {true_pass} of {total} answers were correct and from the right passage; main problem: {problem}."
|
|
109
|
+
|
|
110
|
+
confirmed = summary.ablation_confirmed_count
|
|
111
|
+
lucky_line = f"Lucky passes: {lucky} (correct answer, but the right passage was not retrieved)"
|
|
112
|
+
if lucky and confirmed is not None:
|
|
113
|
+
lucky_line += f"; {confirmed} still correct with no context at all, so answered from memory"
|
|
114
|
+
return PlainSummary(
|
|
115
|
+
status=status,
|
|
116
|
+
verdict=verdict,
|
|
117
|
+
retrieval=f"Retrieval hit rate: {_pct(summary.retrieval_hit_rate)} ({hits} of {total} found the passage the question came from, matched by {match_by})",
|
|
118
|
+
correctness=f"Answer correctness: {_pct(summary.answer_correctness_rate)} ({correct} of {total} judged correct)",
|
|
119
|
+
lucky=lucky_line,
|
|
120
|
+
fix=_fix(dominant, counts.get(dominant, 0) if dominant else 0, no_contexts, match_by, match_by == "id" and hits == 0),
|
|
121
|
+
dominant=dominant,
|
|
122
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
(function () {
|
|
3
|
+
"use strict";
|
|
4
|
+
// One tooltip per chart figure. Each mark carries data-tip: the first
|
|
5
|
+
// line is the value (bold), the rest is context. textContent only.
|
|
6
|
+
Array.prototype.slice.call(document.querySelectorAll(".v2-figure")).forEach(function (figure) {
|
|
7
|
+
var tip = document.createElement("div");
|
|
8
|
+
tip.className = "v2-tooltip";
|
|
9
|
+
tip.hidden = true;
|
|
10
|
+
figure.appendChild(tip);
|
|
11
|
+
|
|
12
|
+
function show(mark, clientX, clientY) {
|
|
13
|
+
var lines = (mark.getAttribute("data-tip") || "").split("\n");
|
|
14
|
+
tip.textContent = "";
|
|
15
|
+
var head = document.createElement("strong");
|
|
16
|
+
head.textContent = lines[0];
|
|
17
|
+
tip.appendChild(head);
|
|
18
|
+
if (lines.length > 1) {
|
|
19
|
+
tip.appendChild(document.createTextNode(lines.slice(1).join("\n")));
|
|
20
|
+
}
|
|
21
|
+
tip.hidden = false;
|
|
22
|
+
var box = figure.getBoundingClientRect();
|
|
23
|
+
var x = clientX - box.left + 14;
|
|
24
|
+
var y = clientY - box.top + 14;
|
|
25
|
+
if (x + tip.offsetWidth > box.width) { x = Math.max(0, clientX - box.left - tip.offsetWidth - 14); }
|
|
26
|
+
tip.style.left = x + "px";
|
|
27
|
+
tip.style.top = y + "px";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hide() { tip.hidden = true; }
|
|
31
|
+
|
|
32
|
+
Array.prototype.slice.call(figure.querySelectorAll("[data-tip]")).forEach(function (mark) {
|
|
33
|
+
mark.addEventListener("mousemove", function (event) { show(mark, event.clientX, event.clientY); });
|
|
34
|
+
mark.addEventListener("mouseleave", hide);
|
|
35
|
+
mark.addEventListener("focus", function () {
|
|
36
|
+
var r = mark.getBoundingClientRect();
|
|
37
|
+
show(mark, r.left + r.width / 2, r.top);
|
|
38
|
+
});
|
|
39
|
+
mark.addEventListener("blur", hide);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
})();
|
|
43
|
+
</script>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ragaudit generate report — {{ summary.run_id }}</title>
|
|
7
|
+
<style>
|
|
8
|
+
{{ style_css | safe }}
|
|
9
|
+
{{ v2_css | safe }}
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<div class="container">
|
|
14
|
+
|
|
15
|
+
<header class="report-header">
|
|
16
|
+
<h1>ragaudit generate report{% if is_mock %}<span class="v2-badge-mock">built with the mock LLM</span>{% endif %}</h1>
|
|
17
|
+
<div class="meta">
|
|
18
|
+
Run <code>{{ summary.run_id }}</code> ·
|
|
19
|
+
generator <code>{{ summary.generator_model }}</code> ·
|
|
20
|
+
verifier <code>{{ summary.verifier_model }}</code> ·
|
|
21
|
+
index <code>{{ (summary.index_version or "n/a")[:12] }}</code> ·
|
|
22
|
+
started {{ summary.created_at }}
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<section class="headline">
|
|
27
|
+
<h2>The headline finding</h2>
|
|
28
|
+
<p class="headline-sentence">
|
|
29
|
+
{% if checked == 0 %}
|
|
30
|
+
Nothing new to generate in this run: every chunk already had questions or had been tried before.
|
|
31
|
+
{% else %}
|
|
32
|
+
Of the <strong>{{ checked }}</strong> candidate questions checked this run, <strong>{{ summary.generated }} ({{ pass_pct }}%)</strong>
|
|
33
|
+
passed every check and were saved.
|
|
34
|
+
{% if top_reason %}
|
|
35
|
+
The most common reason a question was thrown out was <strong>{{ top_reason[0] }}</strong> ({{ top_reason[1] }} of {{ summary.rejected }} rejected).
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% endif %}
|
|
38
|
+
</p>
|
|
39
|
+
{% if is_mock %}
|
|
40
|
+
<div class="v2-callout warn">These questions were written and checked by ragaudit's offline mock LLM, which is deterministic and deliberately flawed so every rejection path gets exercised. Real runs use a real generator and a different verifier model.</div>
|
|
41
|
+
{% endif %}
|
|
42
|
+
<div class="stats-grid">
|
|
43
|
+
<div class="stat"><span class="value">{{ summary.generated }}</span><span class="label">Questions saved this run</span></div>
|
|
44
|
+
<div class="stat"><span class="value">{{ summary.rejected }}</span><span class="label">Rejected this run</span></div>
|
|
45
|
+
<div class="stat"><span class="value">{{ summary.split_counts.get("dev", 0) }} / {{ summary.split_counts.get("test", 0) }}</span><span class="label">New dev / new test</span></div>
|
|
46
|
+
<div class="stat"><span class="value">{{ summary.multi_hop_generated }}</span><span class="label">Multi-hop saved</span></div>
|
|
47
|
+
</div>
|
|
48
|
+
{% if summary.stopped_reason %}
|
|
49
|
+
<div class="v2-callout warn">Stopped early ({{ summary.stopped_reason }}): {{ summary.chunks_remaining }} chunk(s) and {{ summary.pairs_remaining }} chunk pair(s) are still pending. Run <code>ragaudit generate</code> again to continue where it stopped.</div>
|
|
50
|
+
{% endif %}
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<section>
|
|
54
|
+
<h2>What's in the store now</h2>
|
|
55
|
+
<p>Every chunk is assigned to dev or test once, from a hash of its id, and its questions inherit that split, so no chunk feeds both. Editing a chunk never moves it. <code>tune</code> only ever sees the dev split; the test split stays locked until the final score.</p>
|
|
56
|
+
<div class="v2-table-wrap">
|
|
57
|
+
<table class="v2-table">
|
|
58
|
+
<thead><tr><th>Split</th><th class="num">Active</th><th class="num">Stale</th><th class="num">Rejected</th></tr></thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
{% for split in ["dev", "test"] %}
|
|
61
|
+
<tr>
|
|
62
|
+
<td>{{ split }}{% if split == "test" %} (locked){% endif %}</td>
|
|
63
|
+
<td class="num">{{ totals[split]["active"] }}</td>
|
|
64
|
+
<td class="num">{{ totals[split]["stale"] }}</td>
|
|
65
|
+
<td class="num">{{ totals[split]["rejected"] }}</td>
|
|
66
|
+
</tr>
|
|
67
|
+
{% endfor %}
|
|
68
|
+
{% set unsplit = totals.get("none", {}).get("rejected", 0) %}{% if unsplit %}
|
|
69
|
+
<tr>
|
|
70
|
+
<td>neither (cross-split multi-hop)</td>
|
|
71
|
+
<td class="num">0</td>
|
|
72
|
+
<td class="num">0</td>
|
|
73
|
+
<td class="num">{{ unsplit }}</td>
|
|
74
|
+
</tr>
|
|
75
|
+
{% endif %}
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
</div>
|
|
79
|
+
{% if summary.corpus_diff %}
|
|
80
|
+
<p class="meta">Corpus this run: {{ summary.corpus_chunks }} chunks
|
|
81
|
+
({% for key, value in summary.corpus_diff.items() %}{{ value }} {{ key }}{% if not loop.last %}, {% endif %}{% endfor %}).
|
|
82
|
+
Processed {{ summary.chunks_processed }} chunk(s), skipped {{ summary.chunks_skipped }} that already had questions,
|
|
83
|
+
{{ summary.pairs_processed }} multi-hop pair(s); {{ summary.duplicates }} duplicate(s); {{ summary.llm_calls }} LLM call(s).</p>
|
|
84
|
+
{% endif %}
|
|
85
|
+
</section>
|
|
86
|
+
|
|
87
|
+
<section>
|
|
88
|
+
<h2>Why questions were rejected</h2>
|
|
89
|
+
{% if reasons %}
|
|
90
|
+
<figure class="v2-figure">{{ reason_chart | safe }}</figure>
|
|
91
|
+
<div class="v2-table-wrap">
|
|
92
|
+
<table class="v2-table">
|
|
93
|
+
<thead><tr><th>Reason</th><th class="num">Rejected</th><th>Meaning</th></tr></thead>
|
|
94
|
+
<tbody>
|
|
95
|
+
{% for r in reasons %}
|
|
96
|
+
<tr><td><code>{{ r.reason }}</code></td><td class="num">{{ r.count }}</td><td>{{ r.label }}</td></tr>
|
|
97
|
+
{% endfor %}
|
|
98
|
+
</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
</div>
|
|
101
|
+
{% else %}
|
|
102
|
+
<p>No questions were rejected this run.</p>
|
|
103
|
+
{% endif %}
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section>
|
|
107
|
+
<h2>Lexical overlap</h2>
|
|
108
|
+
<p>
|
|
109
|
+
A question that copies its source passage's wording can be answered by keyword search alone, which makes
|
|
110
|
+
a retriever look better than it is. Each question is rewritten once; if more than
|
|
111
|
+
<strong>{{ (summary.lexical_threshold * 100) | round(0) | int }}%</strong> of its content words still appear in
|
|
112
|
+
the passage, it gets one more rewrite and is then rejected as <code>too_lexical</code>.
|
|
113
|
+
{% if overlap_median is not none %}Median overlap this run: <strong>{{ "%.2f" | format(overlap_median) }}</strong>.{% endif %}
|
|
114
|
+
</p>
|
|
115
|
+
{% if summary.lexical_overlaps %}
|
|
116
|
+
<figure class="v2-figure">{{ histogram | safe }}</figure>
|
|
117
|
+
<ul class="v2-legend">
|
|
118
|
+
<li><span class="v2-swatch" style="background: var(--accent)"></span>At or under the threshold: kept for verification</li>
|
|
119
|
+
<li><span class="v2-swatch" style="background: var(--v2-muted-mark)"></span>Over the threshold: rejected</li>
|
|
120
|
+
</ul>
|
|
121
|
+
<details class="v2-data">
|
|
122
|
+
<summary>Show as a table</summary>
|
|
123
|
+
<table class="v2-table">
|
|
124
|
+
<thead><tr><th>Overlap</th><th class="num">Questions</th><th>Outcome</th></tr></thead>
|
|
125
|
+
<tbody>
|
|
126
|
+
{% for row in histogram_rows %}
|
|
127
|
+
<tr><td>{{ row.range }}</td><td class="num">{{ row.count }}</td><td>{{ "kept" if row.kept else "rejected" }}</td></tr>
|
|
128
|
+
{% endfor %}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</details>
|
|
132
|
+
{% else %}
|
|
133
|
+
<p>No questions reached the overlap check this run.</p>
|
|
134
|
+
{% endif %}
|
|
135
|
+
</section>
|
|
136
|
+
|
|
137
|
+
<section>
|
|
138
|
+
<h2>Example questions (dev split)</h2>
|
|
139
|
+
<p>A sample of saved questions. Only dev-split questions are shown, so the report never leaks the locked test set.</p>
|
|
140
|
+
{% for q in accepted %}
|
|
141
|
+
<div class="v2-q">
|
|
142
|
+
<div><span class="v2-tag">{{ q.type }}</span><span class="v2-tag">overlap {{ q.overlap }}</span></div>
|
|
143
|
+
<div class="q">{{ q.question }}</div>
|
|
144
|
+
<div class="a">Answer: {{ q.answer }}</div>
|
|
145
|
+
<div class="src">Fact: {{ q.fact }} · from <code>{{ q.chunks }}</code></div>
|
|
146
|
+
</div>
|
|
147
|
+
{% else %}
|
|
148
|
+
<p>No dev-split questions yet.</p>
|
|
149
|
+
{% endfor %}
|
|
150
|
+
</section>
|
|
151
|
+
|
|
152
|
+
{% if rejected_by_reason %}
|
|
153
|
+
<section>
|
|
154
|
+
<h2>Example rejections</h2>
|
|
155
|
+
<p>Up to three per reason, from the dev split only, so the report never shows text from a test chunk.</p>
|
|
156
|
+
{% for reason, rows in rejected_by_reason.items() %}
|
|
157
|
+
<details>
|
|
158
|
+
<summary><code>{{ reason }}</code> ({{ rows | length }} shown)</summary>
|
|
159
|
+
{% for q in rows %}
|
|
160
|
+
<div class="v2-q">
|
|
161
|
+
<div class="q">{{ q.question }}</div>
|
|
162
|
+
{% if q.draft and q.draft != q.question %}<div class="a">First draft: {{ q.draft }}</div>{% endif %}
|
|
163
|
+
<div class="a">Answer: {{ q.answer }} · overlap {{ q.overlap }}</div>
|
|
164
|
+
<div class="src">Fact: {{ q.fact }} · from <code>{{ q.chunks }}</code></div>
|
|
165
|
+
</div>
|
|
166
|
+
{% endfor %}
|
|
167
|
+
</details>
|
|
168
|
+
{% endfor %}
|
|
169
|
+
</section>
|
|
170
|
+
{% endif %}
|
|
171
|
+
|
|
172
|
+
</div>
|
|
173
|
+
{% include "_v2_tooltip.jinja" %}
|
|
174
|
+
</body>
|
|
175
|
+
</html>
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ragaudit report — {{ run.config.run_id }}</title>
|
|
7
|
+
<style>
|
|
8
|
+
{{ style_css | safe }}
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="container">
|
|
13
|
+
|
|
14
|
+
<header class="report-header">
|
|
15
|
+
<h1>ragaudit report</h1>
|
|
16
|
+
<div class="meta">
|
|
17
|
+
Run <code>{{ run.config.run_id }}</code> ·
|
|
18
|
+
test set <code>{{ run.config.testset_id }}</code> ·
|
|
19
|
+
adapter <code>{{ run.config.adapter_path }}</code> ·
|
|
20
|
+
started {{ run.config.started_at }}
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<section class="summary-box summary-{{ plain_summary.status }}" aria-label="Summary">
|
|
25
|
+
<h2>In plain English</h2>
|
|
26
|
+
<ul class="summary-lines">
|
|
27
|
+
{% for label, text in summary_lines %}
|
|
28
|
+
<li><strong>{{ label }}:</strong> {{ text }}</li>
|
|
29
|
+
{% endfor %}
|
|
30
|
+
</ul>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<section class="headline">
|
|
34
|
+
<h2>The headline finding</h2>
|
|
35
|
+
<p class="headline-sentence">
|
|
36
|
+
{% if headline.kind == "lucky_pass" %}
|
|
37
|
+
Out of {{ total_cases }} test questions, <strong>{{ headline.count }} ({{ headline.pct }}%)</strong>
|
|
38
|
+
were answered correctly even though the pipeline never retrieved the passage the answer
|
|
39
|
+
actually came from. In other words, the model got these right despite retrieval —
|
|
40
|
+
not because of it.
|
|
41
|
+
{% elif headline.kind == "retrieval_failure" %}
|
|
42
|
+
The dominant story in this run is retrieval failure:
|
|
43
|
+
<strong>{{ headline.count }} of {{ total_cases }} questions ({{ headline.pct }}%)</strong>
|
|
44
|
+
were answered wrong because the pipeline never retrieved the passage that held the answer.
|
|
45
|
+
There were no lucky passes to mask it — when retrieval missed, the answer came back
|
|
46
|
+
wrong too.
|
|
47
|
+
{% elif headline.kind == "generation_failure" %}
|
|
48
|
+
The dominant story in this run is generation failure:
|
|
49
|
+
<strong>{{ headline.count }} of {{ total_cases }} questions ({{ headline.pct }}%)</strong>
|
|
50
|
+
were answered wrong even though the pipeline retrieved the right passage — the answer
|
|
51
|
+
was right there in the context and the model still got it wrong.
|
|
52
|
+
{% else %}
|
|
53
|
+
Every correct answer in this run (<strong>{{ headline.count }} of {{ total_cases }}</strong>)
|
|
54
|
+
was backed by a real, on-target retrieval, and there were no lucky passes and no failures
|
|
55
|
+
to report.
|
|
56
|
+
{% endif %}
|
|
57
|
+
</p>
|
|
58
|
+
<div class="stats-grid">
|
|
59
|
+
<div class="stat">
|
|
60
|
+
<span class="value">{{ total_cases }}</span>
|
|
61
|
+
<span class="label">Total questions</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="stat">
|
|
64
|
+
<span class="value">{{ (summary.retrieval_hit_rate * 100) | round(1) }}%</span>
|
|
65
|
+
<span class="label">Retrieval hit rate</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="stat">
|
|
68
|
+
<span class="value">{{ (summary.answer_correctness_rate * 100) | round(1) }}%</span>
|
|
69
|
+
<span class="label">Answer correctness rate</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="stat">
|
|
72
|
+
<span class="value">
|
|
73
|
+
{% if summary.mean_groundedness is not none %}{{ (summary.mean_groundedness * 100) | round(1) }}%{% else %}n/a{% endif %}
|
|
74
|
+
</span>
|
|
75
|
+
<span class="label">Mean groundedness</span>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
|
|
80
|
+
<section class="matrix">
|
|
81
|
+
<h2>Verdict breakdown</h2>
|
|
82
|
+
<table class="matrix-table">
|
|
83
|
+
<thead>
|
|
84
|
+
<tr>
|
|
85
|
+
<th></th>
|
|
86
|
+
<th>Retrieved</th>
|
|
87
|
+
<th>Not retrieved</th>
|
|
88
|
+
</tr>
|
|
89
|
+
</thead>
|
|
90
|
+
<tbody>
|
|
91
|
+
<tr>
|
|
92
|
+
<th>Correct</th>
|
|
93
|
+
<td class="cell-true-pass">{{ verdict_matrix.true_pass }}<span class="cell-label">True pass</span></td>
|
|
94
|
+
<td class="cell-lucky-pass">{{ verdict_matrix.lucky_pass }}<span class="cell-label">Lucky pass</span></td>
|
|
95
|
+
</tr>
|
|
96
|
+
<tr>
|
|
97
|
+
<th>Wrong</th>
|
|
98
|
+
<td class="cell-generation-failure">{{ verdict_matrix.generation_failure }}<span class="cell-label">Generation failure</span></td>
|
|
99
|
+
<td class="cell-retrieval-failure">{{ verdict_matrix.retrieval_failure }}<span class="cell-label">Retrieval failure</span></td>
|
|
100
|
+
</tr>
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section class="ablation">
|
|
106
|
+
<h2>Ablation check</h2>
|
|
107
|
+
{% if lucky_pass_count == 0 %}
|
|
108
|
+
<p>No lucky-pass candidates were found in this run, so ablation was skipped — there was
|
|
109
|
+
nothing that qualified for it.</p>
|
|
110
|
+
{% elif not run.config.run_ablation %}
|
|
111
|
+
<p>Ablation was disabled for this run, so the {{ lucky_pass_count }} lucky-pass case(s) above are
|
|
112
|
+
unconfirmed candidates.</p>
|
|
113
|
+
{% else %}
|
|
114
|
+
<p>
|
|
115
|
+
Of the {{ lucky_pass_count }} lucky-pass case(s), <strong>{{ ablation_confirmed_count }}</strong>
|
|
116
|
+
stayed correct even with retrieval turned off entirely — confirming the model didn't
|
|
117
|
+
need retrieval to answer them.
|
|
118
|
+
</p>
|
|
119
|
+
{% endif %}
|
|
120
|
+
</section>
|
|
121
|
+
|
|
122
|
+
<section class="judge-failures">
|
|
123
|
+
<h2>Judge reliability</h2>
|
|
124
|
+
<p>
|
|
125
|
+
{% if judge_failure_count > 0 %}
|
|
126
|
+
<strong>{{ judge_failure_count }}</strong> of {{ total_cases }} case(s) had a judge response
|
|
127
|
+
that could not be parsed as valid JSON. Those cases were conservatively scored as
|
|
128
|
+
incorrect / not grounded rather than silently dropped — treat their verdicts with
|
|
129
|
+
extra caution.
|
|
130
|
+
{% else %}
|
|
131
|
+
All judge responses in this run parsed successfully — 0 parsing failures.
|
|
132
|
+
{% endif %}
|
|
133
|
+
</p>
|
|
134
|
+
</section>
|
|
135
|
+
|
|
136
|
+
<section class="cases">
|
|
137
|
+
<h2>Per-case results</h2>
|
|
138
|
+
<div class="filter-bar" role="group" aria-label="Filter by verdict">
|
|
139
|
+
<button type="button" class="filter-btn active" data-filter="all">All ({{ total_cases }})</button>
|
|
140
|
+
<button type="button" class="filter-btn" data-filter="true_pass">True pass ({{ verdict_matrix.true_pass }})</button>
|
|
141
|
+
<button type="button" class="filter-btn" data-filter="lucky_pass">Lucky pass ({{ verdict_matrix.lucky_pass }})</button>
|
|
142
|
+
<button type="button" class="filter-btn" data-filter="generation_failure">Generation failure ({{ verdict_matrix.generation_failure }})</button>
|
|
143
|
+
<button type="button" class="filter-btn" data-filter="retrieval_failure">Retrieval failure ({{ verdict_matrix.retrieval_failure }})</button>
|
|
144
|
+
</div>
|
|
145
|
+
<table id="case-table">
|
|
146
|
+
<thead>
|
|
147
|
+
<tr>
|
|
148
|
+
<th data-sort="question">Question</th>
|
|
149
|
+
<th data-sort="verdict">Verdict</th>
|
|
150
|
+
<th data-sort="rank">Retrieval rank</th>
|
|
151
|
+
<th>Details</th>
|
|
152
|
+
</tr>
|
|
153
|
+
</thead>
|
|
154
|
+
<tbody>
|
|
155
|
+
{% for row in rows %}
|
|
156
|
+
<tr class="case-row" data-verdict="{{ row.verdict }}" data-rank="{{ row.retrieval_rank if row.retrieval_rank is not none else -1 }}">
|
|
157
|
+
<td class="case-question">{{ row.question }}</td>
|
|
158
|
+
<td class="case-verdict"><span class="badge badge-{{ row.verdict }}">{{ row.verdict_label }}</span></td>
|
|
159
|
+
<td class="case-rank" data-label="Retrieval rank">{% if row.retrieval_rank is not none %}{{ row.retrieval_rank }}{% else %}not retrieved{% endif %}</td>
|
|
160
|
+
<td class="case-details">
|
|
161
|
+
<details>
|
|
162
|
+
<summary>Show</summary>
|
|
163
|
+
<dl>
|
|
164
|
+
<dt>Expected answer</dt>
|
|
165
|
+
<dd>{{ row.expected_answer }}</dd>
|
|
166
|
+
<dt>Model answer</dt>
|
|
167
|
+
<dd>{{ row.answer }}</dd>
|
|
168
|
+
<dt>Retrieved contexts</dt>
|
|
169
|
+
<dd class="contexts">
|
|
170
|
+
{% if row.contexts %}
|
|
171
|
+
<ol>
|
|
172
|
+
{% for ctx in row.contexts %}
|
|
173
|
+
<li>{{ ctx }}</li>
|
|
174
|
+
{% endfor %}
|
|
175
|
+
</ol>
|
|
176
|
+
{% else %}
|
|
177
|
+
<em>none</em>
|
|
178
|
+
{% endif %}
|
|
179
|
+
</dd>
|
|
180
|
+
{% if row.judge_parse_failed %}
|
|
181
|
+
<dt>Judge warning</dt>
|
|
182
|
+
<dd class="warning">A judge response for this case could not be parsed; it was conservatively scored as incorrect / not grounded.</dd>
|
|
183
|
+
{% endif %}
|
|
184
|
+
{% if row.ablation %}
|
|
185
|
+
<dt>Ablation (no-context) answer</dt>
|
|
186
|
+
<dd>{{ row.ablation.answer }}</dd>
|
|
187
|
+
<dt>Ablation confirms lucky pass</dt>
|
|
188
|
+
<dd>{{ "Yes" if row.ablation.confirms_lucky_pass else "No" }}</dd>
|
|
189
|
+
{% endif %}
|
|
190
|
+
</dl>
|
|
191
|
+
</details>
|
|
192
|
+
</td>
|
|
193
|
+
</tr>
|
|
194
|
+
{% endfor %}
|
|
195
|
+
</tbody>
|
|
196
|
+
</table>
|
|
197
|
+
</section>
|
|
198
|
+
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<script>
|
|
202
|
+
(function () {
|
|
203
|
+
"use strict";
|
|
204
|
+
|
|
205
|
+
var filterButtons = Array.prototype.slice.call(document.querySelectorAll(".filter-btn"));
|
|
206
|
+
var rows = Array.prototype.slice.call(document.querySelectorAll("#case-table tbody tr"));
|
|
207
|
+
|
|
208
|
+
filterButtons.forEach(function (btn) {
|
|
209
|
+
btn.addEventListener("click", function () {
|
|
210
|
+
filterButtons.forEach(function (b) { b.classList.remove("active"); });
|
|
211
|
+
btn.classList.add("active");
|
|
212
|
+
var filter = btn.getAttribute("data-filter");
|
|
213
|
+
rows.forEach(function (row) {
|
|
214
|
+
var show = filter === "all" || row.getAttribute("data-verdict") === filter;
|
|
215
|
+
row.style.display = show ? "" : "none";
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
var sortState = {};
|
|
221
|
+
Array.prototype.slice.call(document.querySelectorAll("#case-table th[data-sort]")).forEach(function (th) {
|
|
222
|
+
th.addEventListener("click", function () {
|
|
223
|
+
var key = th.getAttribute("data-sort");
|
|
224
|
+
var ascending = !sortState[key];
|
|
225
|
+
sortState = {};
|
|
226
|
+
sortState[key] = ascending;
|
|
227
|
+
|
|
228
|
+
var tbody = document.querySelector("#case-table tbody");
|
|
229
|
+
var sorted = rows.slice().sort(function (a, b) {
|
|
230
|
+
var av, bv;
|
|
231
|
+
if (key === "rank") {
|
|
232
|
+
av = parseFloat(a.getAttribute("data-rank"));
|
|
233
|
+
bv = parseFloat(b.getAttribute("data-rank"));
|
|
234
|
+
} else if (key === "verdict") {
|
|
235
|
+
av = a.getAttribute("data-verdict");
|
|
236
|
+
bv = b.getAttribute("data-verdict");
|
|
237
|
+
} else {
|
|
238
|
+
av = a.cells[0].textContent.trim().toLowerCase();
|
|
239
|
+
bv = b.cells[0].textContent.trim().toLowerCase();
|
|
240
|
+
}
|
|
241
|
+
if (av < bv) { return ascending ? -1 : 1; }
|
|
242
|
+
if (av > bv) { return ascending ? 1 : -1; }
|
|
243
|
+
return 0;
|
|
244
|
+
});
|
|
245
|
+
sorted.forEach(function (row) { tbody.appendChild(row); });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
})();
|
|
249
|
+
</script>
|
|
250
|
+
|
|
251
|
+
</body>
|
|
252
|
+
</html>
|