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
@@ -0,0 +1,70 @@
1
+ """v2 per-question evaluation results and their aggregate."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from .scoring import AblationResult, CorrectnessResult, Verdict
9
+
10
+
11
+ class FailureClass(StrEnum):
12
+ PASS = "pass"
13
+ """A gold chunk was retrieved in the top k and the answer is correct."""
14
+ RETRIEVAL_MISS = "retrieval_miss"
15
+ """Gold chunk(s) not retrieved in the top k, whether or not the answer happened to be right."""
16
+ GENERATION_MISS = "generation_miss"
17
+ """Gold chunk(s) retrieved, answer still wrong."""
18
+
19
+
20
+ class QuestionResult(BaseModel):
21
+ """One question run through a pipeline config and scored. Never contains the gold answer."""
22
+
23
+ question_id: str
24
+ split: str
25
+ answer: str
26
+ retrieved_chunk_ids: list[str] = Field(default_factory=list)
27
+ recall_at_k: float
28
+ """Share of the gold chunk_ids found in the top k (0 or 1 for single-chunk questions)."""
29
+ reciprocal_rank: float
30
+ """1 / (1 + rank of the first gold chunk in the top k), or 0 if none was retrieved."""
31
+ retrieval_hit: bool
32
+ """Every gold chunk was retrieved in the top k."""
33
+ correctness: CorrectnessResult
34
+ failure_class: FailureClass
35
+ verdict: Verdict
36
+ """v1's retrieval-contribution signal: a correct answer without retrieval is a LUCKY_PASS."""
37
+ ablation: AblationResult | None = None
38
+ adapter_error: str | None = None
39
+ latency_ms: float | None = None
40
+
41
+
42
+ class EvalSummary(BaseModel):
43
+ """Aggregate of one evaluation: the headline score is the pass rate, in points (0-100)."""
44
+
45
+ run_id: str
46
+ created_at: datetime
47
+ split: str
48
+ config_hash: str
49
+ config: dict = Field(default_factory=dict)
50
+ index_version: str
51
+ k: int
52
+ mode: str
53
+ """full (config never evaluated), regression (index changed: new + sampled old), or cached (index unchanged)."""
54
+ n_questions: int
55
+ n_active_in_split: int
56
+ n_new: int = 0
57
+ n_sampled_old: int = 0
58
+ n_cached: int = 0
59
+ n_run: int = 0
60
+ score: float
61
+ """Pass rate x 100: retrieval hit AND correct answer. Lucky passes don't count."""
62
+ answer_accuracy: float
63
+ recall_at_k: float
64
+ mrr: float
65
+ failure_counts: dict[str, int]
66
+ verdict_counts: dict[str, int]
67
+ judge_failures: int = 0
68
+ adapter_errors: int = 0
69
+ lucky_pass_confirmed: int | None = None
70
+ """Of LUCKY_PASS results, how many ablation confirmed (None if ablation didn't run)."""
@@ -0,0 +1,152 @@
1
+ """v2 questions: a verified fact, the question that tests it, and where it came from."""
2
+
3
+ import hashlib
4
+ from datetime import datetime, timezone
5
+ from enum import StrEnum
6
+
7
+ from pydantic import BaseModel, Field, field_validator, model_validator
8
+
9
+ DEV_FRACTION_PERCENT = 70
10
+ """Share of chunks assigned to the dev split; the rest go to the locked test split."""
11
+
12
+ SPLIT_SALT = "ragaudit-chunk-split-v1"
13
+ """Never change this: doing so would reshuffle every chunk (and its questions) between splits."""
14
+
15
+
16
+ class QuestionType(StrEnum):
17
+ SINGLE = "single"
18
+ MULTI_HOP = "multi_hop"
19
+
20
+
21
+ class Split(StrEnum):
22
+ DEV = "dev"
23
+ TEST = "test"
24
+
25
+
26
+ class QuestionStatus(StrEnum):
27
+ ACTIVE = "active"
28
+ STALE = "stale"
29
+ """Its source chunk changed or was deleted. Never scored."""
30
+ REJECTED = "rejected"
31
+ """Failed a generation check; reject_reason says which. Never scored."""
32
+
33
+
34
+ class RejectReason(StrEnum):
35
+ TOO_LEXICAL = "too_lexical"
36
+ RELATIVE_TIME = "relative_time"
37
+ UNSUPPORTED = "unsupported"
38
+ UNANSWERABLE = "unanswerable"
39
+ AMBIGUOUS = "ambiguous"
40
+ VERIFIER_UNPARSEABLE = "verifier_unparseable"
41
+ CROSS_SPLIT = "cross_split"
42
+ """A multi-hop question whose source chunks are not all in the same split."""
43
+
44
+
45
+ def compute_question_id(chunk_hashes: list[str], fact: str) -> str:
46
+ """Stable id: hash of the source chunk hash(es) plus the fact text.
47
+
48
+ The same fact extracted from the same chunk content always gets the
49
+ same id, so re-running generation is idempotent.
50
+ """
51
+ payload = "|".join(chunk_hashes) + "\n" + fact.strip()
52
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
53
+
54
+
55
+ def assign_split(chunk_id: str) -> Split:
56
+ """Deterministically assign a chunk to dev (70%) or test (30%) from its chunk_id alone.
57
+
58
+ The split is per chunk, not per question: if questions from one chunk
59
+ could land on both sides, tuning on the dev ones would teach the
60
+ pipeline that chunk's content and inflate the test score. It hashes
61
+ the chunk *id*, not its content, so editing a chunk never moves it
62
+ between splits, and adding chunks never moves old ones.
63
+ """
64
+ digest = hashlib.sha256(f"{SPLIT_SALT}:{chunk_id}".encode("utf-8")).hexdigest()
65
+ return Split.DEV if int(digest[:8], 16) % 100 < DEV_FRACTION_PERCENT else Split.TEST
66
+
67
+
68
+ def split_for_chunks(chunk_ids: list[str]) -> Split | None:
69
+ """The split shared by every source chunk, or None when they disagree (a cross-split multi-hop question)."""
70
+ splits = {assign_split(chunk_id) for chunk_id in chunk_ids}
71
+ return splits.pop() if len(splits) == 1 else None
72
+
73
+
74
+ def utc_now() -> datetime:
75
+ return datetime.now(timezone.utc)
76
+
77
+
78
+ class Question(BaseModel):
79
+ """One generated question, linked to the chunk(s) its gold answer came from."""
80
+
81
+ question_id: str
82
+ chunk_ids: list[str]
83
+ """One entry normally; two or more for multi-hop questions."""
84
+ chunk_hashes: list[str]
85
+ """Content hash of each source chunk, parallel to chunk_ids."""
86
+ fact: str
87
+ question: str
88
+ gold_answer: str
89
+ question_type: QuestionType = QuestionType.SINGLE
90
+ split: Split | None
91
+ """Its source chunks' split. None only for questions rejected as cross_split, which belong to neither."""
92
+ status: QuestionStatus = QuestionStatus.ACTIVE
93
+ reject_reason: str | None = None
94
+ created_at: datetime = Field(default_factory=utc_now)
95
+ """Absolute UTC timestamp."""
96
+ source_timestamp: datetime | None = None
97
+ """When the source material was captured, if the corpus provides it."""
98
+ generator_model: str
99
+ verifier_model: str | None = None
100
+ lexical_overlap: float | None = None
101
+ """Fraction of the question's content tokens that appear in its source chunk(s)."""
102
+ metadata: dict[str, str] = Field(default_factory=dict)
103
+
104
+ @field_validator("created_at", "source_timestamp")
105
+ @classmethod
106
+ def _force_utc(cls, value: datetime | None) -> datetime | None:
107
+ if value is None:
108
+ return None
109
+ if value.tzinfo is None:
110
+ return value.replace(tzinfo=timezone.utc)
111
+ return value.astimezone(timezone.utc)
112
+
113
+ @model_validator(mode="after")
114
+ def _split_required_unless_rejected(self) -> "Question":
115
+ if self.split is None and self.status != QuestionStatus.REJECTED:
116
+ raise ValueError("only rejected questions may have no split")
117
+ return self
118
+
119
+ @classmethod
120
+ def create(
121
+ cls,
122
+ chunk_ids: list[str],
123
+ chunk_hashes: list[str],
124
+ fact: str,
125
+ question: str,
126
+ gold_answer: str,
127
+ generator_model: str,
128
+ **kwargs,
129
+ ) -> "Question":
130
+ """Build a Question with its id and split derived the one canonical way.
131
+
132
+ A question whose source chunks span both splits is always rejected
133
+ as cross_split, whatever status the caller asked for.
134
+ """
135
+ question_id = compute_question_id(chunk_hashes, fact)
136
+ split = split_for_chunks(chunk_ids)
137
+ if split is None:
138
+ kwargs["status"] = QuestionStatus.REJECTED
139
+ kwargs["reject_reason"] = RejectReason.CROSS_SPLIT.value
140
+ question_type = QuestionType.MULTI_HOP if len(chunk_ids) > 1 else QuestionType.SINGLE
141
+ return cls(
142
+ question_id=question_id,
143
+ chunk_ids=chunk_ids,
144
+ chunk_hashes=chunk_hashes,
145
+ fact=fact,
146
+ question=question,
147
+ gold_answer=gold_answer,
148
+ question_type=kwargs.pop("question_type", question_type),
149
+ split=split,
150
+ generator_model=generator_model,
151
+ **kwargs,
152
+ )
ragaudit/models/run.py ADDED
@@ -0,0 +1,44 @@
1
+ """Models for a full audit run and its aggregate summary."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from .scoring import ScoredCase, Verdict
8
+
9
+
10
+ class RunConfig(BaseModel):
11
+ """Configuration a run was executed with."""
12
+
13
+ run_id: str
14
+ testset_id: str
15
+ adapter_path: str
16
+ correctness_judge_model: str
17
+ groundedness_judge_model: str
18
+ run_ablation: bool = True
19
+ started_at: datetime
20
+
21
+
22
+ class RunSummary(BaseModel):
23
+ """Aggregate stats computed after all cases are scored — the report's headline."""
24
+
25
+ total_cases: int
26
+ verdict_counts: dict[Verdict, int]
27
+ retrieval_hit_rate: float
28
+ answer_correctness_rate: float
29
+ lucky_pass_rate: float
30
+ """The headline metric."""
31
+ mean_groundedness: float | None = None
32
+ ablation_confirmed_count: int | None = None
33
+ """Of LUCKY_PASS cases, how many ablation confirmed."""
34
+ judge_failure_count: int = 0
35
+ """Cases where the correctness judge, groundedness judge, or both failed to parse."""
36
+
37
+
38
+ class Run(BaseModel):
39
+ """A full audit run: config, every scored case, and the aggregate summary."""
40
+
41
+ config: RunConfig
42
+ finished_at: datetime | None = None
43
+ scored_cases: list[ScoredCase]
44
+ summary: RunSummary | None = None
@@ -0,0 +1,82 @@
1
+ """Per-case scoring results and the verdict that cross-references them."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from .adapter import RAGResponse
8
+
9
+
10
+ class Verdict(StrEnum):
11
+ """The cross-reference of answer correctness against retrieval hit."""
12
+
13
+ TRUE_PASS = "true_pass"
14
+ LUCKY_PASS = "lucky_pass"
15
+ GENERATION_FAILURE = "generation_failure"
16
+ RETRIEVAL_FAILURE = "retrieval_failure"
17
+
18
+
19
+ class CorrectnessResult(BaseModel):
20
+ """LLM-judged comparison of RAGResponse.answer against TestCase.expected_answer."""
21
+
22
+ is_correct: bool
23
+ score: float | None = None
24
+ """Raw judge score in [0, 1], if the judge emits one."""
25
+ rationale: str | None = None
26
+ judge_model: str
27
+ judge_parse_failed: bool = False
28
+ """True if the judge's response couldn't be parsed and is_correct fell back to False."""
29
+
30
+
31
+ class RetrievalResult(BaseModel):
32
+ """Whether retrieval found the gold chunk — no LLM involved.
33
+
34
+ By id (the default): set membership of expected_chunk_id. By text: the
35
+ gold chunk's text overlaps one of the returned contexts (see
36
+ ragaudit.scoring.text_match).
37
+ """
38
+
39
+ hit: bool
40
+ expected_chunk_id: str
41
+ retrieved_chunk_ids: list[str]
42
+ rank: int | None = None
43
+ """Index of the first match: in retrieved_chunk_ids (by id) or in the contexts (by text)."""
44
+ match_by: str = "id"
45
+ """"id" or "text"."""
46
+ match_score: float | None = None
47
+ """By text only: the best overlap between the gold chunk and any returned context."""
48
+
49
+
50
+ class GroundednessResult(BaseModel):
51
+ """Whether the answer is supported by the contexts the adapter actually returned."""
52
+
53
+ is_grounded: bool
54
+ score: float | None = None
55
+ rationale: str | None = None
56
+ judge_model: str
57
+ judge_parse_failed: bool = False
58
+ """True if the judge's response couldn't be parsed and is_grounded fell back to False."""
59
+
60
+
61
+ class AblationResult(BaseModel):
62
+ """Re-run with retrieval disabled, to empirically confirm a candidate LUCKY_PASS.
63
+
64
+ Only populated for cases that scored correct + not-retrieved.
65
+ """
66
+
67
+ answer: str
68
+ is_correct: bool
69
+ confirms_lucky_pass: bool
70
+ """True iff the answer is still correct with no retrieval."""
71
+
72
+
73
+ class ScoredCase(BaseModel):
74
+ """One TestCase run through a pipeline and fully scored."""
75
+
76
+ test_id: str
77
+ response: RAGResponse
78
+ correctness: CorrectnessResult
79
+ retrieval: RetrievalResult
80
+ groundedness: GroundednessResult
81
+ verdict: Verdict
82
+ ablation: AblationResult | None = None
@@ -0,0 +1,30 @@
1
+ """Models for generated test sets: questions with known ground-truth sources."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class TestCase(BaseModel):
9
+ """One generated question with its ground-truth source chunk."""
10
+
11
+ test_id: str
12
+ question: str
13
+ expected_answer: str
14
+ source_chunk_id: str
15
+ source_doc_id: str
16
+ """Denormalized, avoids a join for report rendering."""
17
+ generation_model: str
18
+ created_at: datetime
19
+ metadata: dict[str, str] = Field(default_factory=dict)
20
+
21
+
22
+ class TestSet(BaseModel):
23
+ """A generated batch of TestCases tied to a specific corpus state."""
24
+
25
+ testset_id: str
26
+ corpus_hash: str
27
+ """Hash over all Document.content_hash, for reproducibility/staleness checks."""
28
+ created_at: datetime
29
+ generation_model: str
30
+ test_cases: list[TestCase]
@@ -0,0 +1,56 @@
1
+ """v2 tuning: one trial per evaluated config, and the run's outcome."""
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from .evaluation import EvalSummary
8
+
9
+
10
+ class Trial(BaseModel):
11
+ """One pipeline config evaluated on the dev split."""
12
+
13
+ index: int
14
+ config: dict
15
+ config_hash: str
16
+ source: str
17
+ """baseline, grid, random, llm, or llm_fallback (the LLM's proposal was invalid; a random one was used)."""
18
+ rationale: str | None = None
19
+ dev: EvalSummary
20
+ created_at: datetime
21
+
22
+
23
+ class TuneResult(BaseModel):
24
+ tune_id: str
25
+ created_at: datetime
26
+ finished_at: datetime | None = None
27
+ strategy: str
28
+ search_space: dict[str, list]
29
+ adapter_path: str
30
+ judge_model: str
31
+ index_version: str
32
+ k: int
33
+ seed: int
34
+ max_trials: int
35
+ trials: list[Trial] = Field(default_factory=list)
36
+ baseline_index: int = 0
37
+ best_index: int = 0
38
+ baseline_test: EvalSummary | None = None
39
+ """Aggregate only: tune never sees test questions or answers."""
40
+ best_test: EvalSummary | None = None
41
+ overfit_threshold: float = 10.0
42
+ overfit_gap: float | None = None
43
+ """Best config's dev score minus its test score, in points."""
44
+ overfit_warning: bool = False
45
+ stopped_reason: str
46
+ """max_trials, search_space_exhausted, or max_llm_calls."""
47
+ llm_calls: int = 0
48
+ best_config_path: str | None = None
49
+
50
+ @property
51
+ def baseline(self) -> Trial:
52
+ return self.trials[self.baseline_index]
53
+
54
+ @property
55
+ def best(self) -> Trial:
56
+ return self.trials[self.best_index]
File without changes
@@ -0,0 +1,155 @@
1
+ """Renders a Run into a single, self-contained static HTML report.
2
+
3
+ The report needs question text for its per-case table, which lives on
4
+ TestCase, not ScoredCase — so this takes both the Run and the TestSet it
5
+ was run against, cross-referenced by test_id.
6
+ """
7
+
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
12
+
13
+ from ragaudit.models.run import Run
14
+ from ragaudit.models.scoring import ScoredCase, Verdict
15
+ from ragaudit.models.testset import TestSet
16
+ from ragaudit.report.plain_summary import build_plain_summary
17
+
18
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
19
+
20
+ _VERDICT_LABELS = {
21
+ Verdict.TRUE_PASS: "True pass",
22
+ Verdict.LUCKY_PASS: "Lucky pass",
23
+ Verdict.GENERATION_FAILURE: "Generation failure",
24
+ Verdict.RETRIEVAL_FAILURE: "Retrieval failure",
25
+ }
26
+
27
+
28
+ def _build_case_row(case: ScoredCase, question: str, expected_answer: str) -> dict:
29
+ return {
30
+ "test_id": case.test_id,
31
+ "question": question,
32
+ "expected_answer": expected_answer,
33
+ "answer": case.response.answer,
34
+ "contexts": case.response.contexts,
35
+ "verdict": case.verdict.value,
36
+ "verdict_label": _VERDICT_LABELS[case.verdict],
37
+ "retrieval_rank": case.retrieval.rank,
38
+ "judge_parse_failed": case.correctness.judge_parse_failed or case.groundedness.judge_parse_failed,
39
+ "ablation": (
40
+ {"answer": case.ablation.answer, "confirms_lucky_pass": case.ablation.confirms_lucky_pass}
41
+ if case.ablation is not None
42
+ else None
43
+ ),
44
+ }
45
+
46
+
47
+ def _compute_headline(total_cases: int, verdict_matrix: dict) -> dict:
48
+ """Pick the single dominant finding to state in the headline sentence.
49
+
50
+ Lucky passes are always the headline when any exist — that is the
51
+ tool's core claim. Otherwise, headline the largest failure category
52
+ (retrieval beats generation on a tie) so a run full of failures never
53
+ reads as quietly clean just because nothing was "lucky." Only when
54
+ there are no lucky passes and no failures at all does the headline
55
+ turn positive.
56
+ """
57
+
58
+ def pct(count: int) -> float:
59
+ return round(count / total_cases * 100, 1) if total_cases else 0.0
60
+
61
+ lucky_pass = verdict_matrix["lucky_pass"]
62
+ if lucky_pass > 0:
63
+ return {"kind": "lucky_pass", "count": lucky_pass, "pct": pct(lucky_pass)}
64
+
65
+ failure_counts = {
66
+ "retrieval_failure": verdict_matrix["retrieval_failure"],
67
+ "generation_failure": verdict_matrix["generation_failure"],
68
+ }
69
+ dominant_kind = max(failure_counts, key=failure_counts.get)
70
+ dominant_count = failure_counts[dominant_kind]
71
+ if dominant_count > 0:
72
+ return {"kind": dominant_kind, "count": dominant_count, "pct": pct(dominant_count)}
73
+
74
+ return {"kind": "all_true_pass", "count": verdict_matrix["true_pass"], "pct": pct(verdict_matrix["true_pass"])}
75
+
76
+
77
+ def _build_case_rows(run: Run, test_set: TestSet) -> list[dict]:
78
+ test_case_by_id = {tc.test_id: tc for tc in test_set.test_cases}
79
+ rows = []
80
+ for case in run.scored_cases:
81
+ test_case = test_case_by_id.get(case.test_id)
82
+ question = test_case.question if test_case else "(question not found in test set)"
83
+ expected_answer = test_case.expected_answer if test_case else ""
84
+ rows.append(_build_case_row(case, question, expected_answer))
85
+ return rows
86
+
87
+
88
+ def _load_style_css() -> str:
89
+ """Load the canonical stylesheet from package data.
90
+
91
+ A silently unstyled report is worse than a crash — if the stylesheet
92
+ can't be loaded (e.g. it was left out of a packaging change), raise
93
+ rather than falling back to an empty string.
94
+ """
95
+ try:
96
+ css = resources.files("ragaudit.report").joinpath("templates", "style.css").read_text(encoding="utf-8")
97
+ except (FileNotFoundError, ModuleNotFoundError, OSError) as exc:
98
+ raise RuntimeError(
99
+ "Could not load the report stylesheet from package data "
100
+ "(ragaudit/report/templates/style.css). The report cannot be rendered without it."
101
+ ) from exc
102
+
103
+ if not css.strip():
104
+ raise RuntimeError("The report stylesheet (ragaudit/report/templates/style.css) is empty.")
105
+
106
+ return css
107
+
108
+
109
+ def generate_report(run: Run, test_set: TestSet, output_path: str | Path) -> Path:
110
+ """Render `run` into a self-contained HTML file at `output_path`.
111
+
112
+ The canonical stylesheet (ragaudit/report/templates/style.css, shipped
113
+ as package data) is inlined into the output, so the resulting file has
114
+ no external dependencies. Returns output_path.
115
+ """
116
+ if run.summary is None:
117
+ raise ValueError("Run.summary must be computed (see runner.summary.compute_run_summary) before reporting")
118
+
119
+ output_path = Path(output_path)
120
+ summary = run.summary
121
+
122
+ env = Environment(
123
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
124
+ autoescape=select_autoescape(["html", "jinja"]),
125
+ )
126
+ template = env.get_template("report.html.jinja")
127
+
128
+ verdict_matrix = {
129
+ "true_pass": summary.verdict_counts.get(Verdict.TRUE_PASS, 0),
130
+ "lucky_pass": summary.verdict_counts.get(Verdict.LUCKY_PASS, 0),
131
+ "generation_failure": summary.verdict_counts.get(Verdict.GENERATION_FAILURE, 0),
132
+ "retrieval_failure": summary.verdict_counts.get(Verdict.RETRIEVAL_FAILURE, 0),
133
+ }
134
+ lucky_pass_count = verdict_matrix["lucky_pass"]
135
+
136
+ plain_summary = build_plain_summary(summary, run.scored_cases)
137
+ html = template.render(
138
+ plain_summary=plain_summary,
139
+ summary_lines=[tuple(line.split(": ", 1)) for line in plain_summary.lines()],
140
+ run=run,
141
+ summary=summary,
142
+ style_css=_load_style_css(),
143
+ rows=_build_case_rows(run, test_set),
144
+ verdict_matrix=verdict_matrix,
145
+ headline=_compute_headline(summary.total_cases, verdict_matrix),
146
+ lucky_pass_count=lucky_pass_count,
147
+ lucky_pass_pct=round(summary.lucky_pass_rate * 100, 1),
148
+ total_cases=summary.total_cases,
149
+ judge_failure_count=summary.judge_failure_count,
150
+ ablation_confirmed_count=summary.ablation_confirmed_count,
151
+ )
152
+
153
+ output_path.parent.mkdir(parents=True, exist_ok=True)
154
+ output_path.write_text(html, encoding="utf-8")
155
+ return output_path