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,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]
|
ragaudit/models/tune.py
ADDED
|
@@ -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
|