alignmenter 0.0.4__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.
- alignmenter/__init__.py +14 -0
- alignmenter/cli.py +1815 -0
- alignmenter/config.py +99 -0
- alignmenter/data/configs/demo_config.yaml +15 -0
- alignmenter/data/configs/judges/safety_prompt.txt +2 -0
- alignmenter/data/configs/persona/default.yaml +15 -0
- alignmenter/data/configs/run.yaml +12 -0
- alignmenter/data/configs/safety_keywords.yaml +7 -0
- alignmenter/data/datasets/demo_conversations.jsonl +60 -0
- alignmenter/providers/__init__.py +47 -0
- alignmenter/providers/anthropic.py +87 -0
- alignmenter/providers/base.py +57 -0
- alignmenter/providers/classifiers.py +83 -0
- alignmenter/providers/embeddings.py +126 -0
- alignmenter/providers/judges.py +105 -0
- alignmenter/providers/local.py +102 -0
- alignmenter/providers/openai.py +151 -0
- alignmenter/reporting/__init__.py +6 -0
- alignmenter/reporting/html.py +721 -0
- alignmenter/reporting/json_out.py +33 -0
- alignmenter/run_config.py +106 -0
- alignmenter/runner.py +410 -0
- alignmenter/scorers/__init__.py +7 -0
- alignmenter/scorers/authenticity.py +337 -0
- alignmenter/scorers/safety.py +231 -0
- alignmenter/scorers/stability.py +104 -0
- alignmenter/scripts/__init__.py +1 -0
- alignmenter/scripts/bootstrap_dataset.py +142 -0
- alignmenter/scripts/calibrate_persona.py +196 -0
- alignmenter/scripts/run_openai_demo.py +74 -0
- alignmenter/scripts/sanitize_dataset.py +185 -0
- alignmenter/utils/__init__.py +7 -0
- alignmenter/utils/io.py +47 -0
- alignmenter/utils/tokens.py +46 -0
- alignmenter/utils/yaml.py +15 -0
- alignmenter-0.0.4.dist-info/METADATA +681 -0
- alignmenter-0.0.4.dist-info/RECORD +41 -0
- alignmenter-0.0.4.dist-info/WHEEL +5 -0
- alignmenter-0.0.4.dist-info/entry_points.txt +2 -0
- alignmenter-0.0.4.dist-info/licenses/LICENSE +201 -0
- alignmenter-0.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""JSON artifact reporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from alignmenter.utils.io import write_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JSONReporter:
|
|
12
|
+
"""Write JSON artifacts summarizing a run."""
|
|
13
|
+
|
|
14
|
+
def write(
|
|
15
|
+
self,
|
|
16
|
+
run_dir: Path,
|
|
17
|
+
summary: dict[str, Any],
|
|
18
|
+
scores: dict[str, Any],
|
|
19
|
+
sessions: list,
|
|
20
|
+
**extras: Any,
|
|
21
|
+
) -> Path:
|
|
22
|
+
"""Persist JSON outputs to *run_dir*."""
|
|
23
|
+
|
|
24
|
+
payload = {
|
|
25
|
+
"run": summary,
|
|
26
|
+
"scores": scores,
|
|
27
|
+
}
|
|
28
|
+
scorecards = extras.get("scorecards")
|
|
29
|
+
if scorecards:
|
|
30
|
+
payload["scorecards"] = scorecards
|
|
31
|
+
path = Path(run_dir) / "report.json"
|
|
32
|
+
write_json(path, payload)
|
|
33
|
+
return path
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Utilities for loading run configuration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from alignmenter.utils import load_yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve(base: Path, value: Optional[str]) -> Optional[Path]:
|
|
12
|
+
if not value:
|
|
13
|
+
return None
|
|
14
|
+
candidate = Path(value)
|
|
15
|
+
if not candidate.is_absolute():
|
|
16
|
+
candidate = (base / candidate).resolve()
|
|
17
|
+
return candidate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_run_options(path: Path) -> dict[str, Any]:
|
|
21
|
+
data = load_yaml(path) or {}
|
|
22
|
+
base = path.parent
|
|
23
|
+
|
|
24
|
+
options: dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
# Direct fields or legacy fallbacks
|
|
27
|
+
options["run_id"] = data.get("run_id")
|
|
28
|
+
options["model"] = data.get("model") or data.get("providers", {}).get("primary")
|
|
29
|
+
options["compare_model"] = data.get("compare_model") or data.get("providers", {}).get("compare")
|
|
30
|
+
|
|
31
|
+
dataset = data.get("dataset")
|
|
32
|
+
if dataset:
|
|
33
|
+
options["dataset"] = _resolve(base, dataset)
|
|
34
|
+
|
|
35
|
+
persona = data.get("persona") or data.get("persona_pack")
|
|
36
|
+
if persona:
|
|
37
|
+
options["persona"] = _resolve(base, persona)
|
|
38
|
+
|
|
39
|
+
keywords = (
|
|
40
|
+
data.get("keywords")
|
|
41
|
+
or data.get("keyword_lists")
|
|
42
|
+
or data.get("scorers", {}).get("safety", {}).get("keyword_lists")
|
|
43
|
+
)
|
|
44
|
+
if keywords:
|
|
45
|
+
options["keywords"] = _resolve(base, keywords)
|
|
46
|
+
|
|
47
|
+
embedding = (
|
|
48
|
+
data.get("embedding")
|
|
49
|
+
or data.get("embedding_provider")
|
|
50
|
+
or data.get("scorers", {}).get("authenticity", {}).get("embedding_model")
|
|
51
|
+
)
|
|
52
|
+
if embedding:
|
|
53
|
+
options["embedding"] = embedding
|
|
54
|
+
|
|
55
|
+
judge_section = data.get("judge")
|
|
56
|
+
safety_section = data.get("scorers", {}).get("safety", {})
|
|
57
|
+
if not isinstance(judge_section, dict):
|
|
58
|
+
judge_section = safety_section.get("judge") if isinstance(safety_section, dict) else None
|
|
59
|
+
|
|
60
|
+
if isinstance(judge_section, dict):
|
|
61
|
+
if judge_section.get("provider"):
|
|
62
|
+
options["judge_provider"] = judge_section.get("provider")
|
|
63
|
+
if judge_section.get("budget") is not None:
|
|
64
|
+
options["judge_budget"] = judge_section.get("budget")
|
|
65
|
+
if judge_section.get("budget_usd") is not None:
|
|
66
|
+
options["judge_budget_usd"] = judge_section.get("budget_usd")
|
|
67
|
+
if judge_section.get("price_per_1k_input") is not None:
|
|
68
|
+
options["judge_price_per_1k_input"] = judge_section.get("price_per_1k_input")
|
|
69
|
+
if judge_section.get("price_per_1k_output") is not None:
|
|
70
|
+
options["judge_price_per_1k_output"] = judge_section.get("price_per_1k_output")
|
|
71
|
+
if judge_section.get("estimated_tokens_per_call") is not None:
|
|
72
|
+
options["judge_estimated_tokens_per_call"] = judge_section.get("estimated_tokens_per_call")
|
|
73
|
+
if judge_section.get("estimated_prompt_tokens_per_call") is not None:
|
|
74
|
+
options["judge_estimated_prompt_tokens_per_call"] = judge_section.get("estimated_prompt_tokens_per_call")
|
|
75
|
+
if judge_section.get("estimated_completion_tokens_per_call") is not None:
|
|
76
|
+
options["judge_estimated_completion_tokens_per_call"] = judge_section.get("estimated_completion_tokens_per_call")
|
|
77
|
+
if judge_section.get("offline_classifier"):
|
|
78
|
+
options["safety_classifier"] = judge_section.get("offline_classifier")
|
|
79
|
+
|
|
80
|
+
if options.get("judge_provider") is None and data.get("judge_provider"):
|
|
81
|
+
options["judge_provider"] = data.get("judge_provider")
|
|
82
|
+
if options.get("judge_budget") is None and data.get("judge_budget") is not None:
|
|
83
|
+
options["judge_budget"] = data.get("judge_budget")
|
|
84
|
+
for alias, key in (
|
|
85
|
+
("judge_budget_usd", "judge_budget_usd"),
|
|
86
|
+
("judge_price_per_1k_input", "judge_price_per_1k_input"),
|
|
87
|
+
("judge_price_per_1k_output", "judge_price_per_1k_output"),
|
|
88
|
+
("judge_estimated_tokens_per_call", "judge_estimated_tokens_per_call"),
|
|
89
|
+
):
|
|
90
|
+
if options.get(alias) is None and data.get(key) is not None:
|
|
91
|
+
options[alias] = data.get(key)
|
|
92
|
+
|
|
93
|
+
safety_section = data.get("scorers", {}).get("safety", {})
|
|
94
|
+
if isinstance(safety_section, dict) and safety_section.get("offline_classifier") and options.get("safety_classifier") is None:
|
|
95
|
+
options["safety_classifier"] = safety_section.get("offline_classifier")
|
|
96
|
+
if options.get("safety_classifier") is None and data.get("safety_classifier"):
|
|
97
|
+
options["safety_classifier"] = data.get("safety_classifier")
|
|
98
|
+
|
|
99
|
+
report = data.get("report", {})
|
|
100
|
+
if isinstance(report, dict):
|
|
101
|
+
if report.get("out_dir"):
|
|
102
|
+
options["report_out_dir"] = _resolve(base, report.get("out_dir"))
|
|
103
|
+
if report.get("include_raw") is not None:
|
|
104
|
+
options["include_raw"] = bool(report.get("include_raw"))
|
|
105
|
+
|
|
106
|
+
return options
|
alignmenter/runner.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Run orchestration pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from alignmenter.providers.base import ChatProvider
|
|
12
|
+
from alignmenter.reporting.html import HTMLReporter
|
|
13
|
+
from alignmenter.reporting.json_out import JSONReporter
|
|
14
|
+
from alignmenter.utils.io import read_jsonl, write_json, write_jsonl
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RunConfig:
|
|
19
|
+
"""Configuration for a single evaluation run."""
|
|
20
|
+
|
|
21
|
+
model: str
|
|
22
|
+
dataset_path: Path
|
|
23
|
+
persona_path: Path
|
|
24
|
+
run_id: str = "alignmenter_run"
|
|
25
|
+
compare_model: Optional[str] = None
|
|
26
|
+
report_out_dir: Path = Path("reports")
|
|
27
|
+
include_raw: bool = True
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
self.dataset_path = Path(self.dataset_path)
|
|
31
|
+
self.persona_path = Path(self.persona_path)
|
|
32
|
+
self.report_out_dir = Path(self.report_out_dir)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Session:
|
|
37
|
+
"""Grouped conversation session."""
|
|
38
|
+
|
|
39
|
+
session_id: str
|
|
40
|
+
turns: List[dict]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Runner:
|
|
44
|
+
"""Coordinates provider calls, scoring, and reporting."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
config: RunConfig,
|
|
49
|
+
scorers: Iterable,
|
|
50
|
+
compare_scorers: Optional[Iterable] = None,
|
|
51
|
+
reporters: Optional[Iterable] = None,
|
|
52
|
+
*,
|
|
53
|
+
provider: Optional[ChatProvider] = None,
|
|
54
|
+
compare_provider: Optional[ChatProvider] = None,
|
|
55
|
+
generate_transcripts: bool = True,
|
|
56
|
+
compare_generate: Optional[bool] = None,
|
|
57
|
+
progress_callback: Optional[Callable[[int], None]] = None,
|
|
58
|
+
compare_progress_callback: Optional[Callable[[int], None]] = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.config = config
|
|
61
|
+
self.scorers = list(scorers)
|
|
62
|
+
self.compare_scorers = list(compare_scorers or [])
|
|
63
|
+
self.reporters = list(reporters or [JSONReporter(), HTMLReporter()])
|
|
64
|
+
self.provider = provider
|
|
65
|
+
self.compare_provider = compare_provider
|
|
66
|
+
self.generate_transcripts = bool(generate_transcripts and provider is not None)
|
|
67
|
+
if compare_generate is None:
|
|
68
|
+
compare_generate = generate_transcripts
|
|
69
|
+
self.compare_generate = bool(compare_generate and compare_provider is not None)
|
|
70
|
+
self.progress_callback = progress_callback if self.generate_transcripts else None
|
|
71
|
+
self.compare_progress_callback = (
|
|
72
|
+
compare_progress_callback if self.compare_generate else None
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def execute(self) -> Path:
|
|
76
|
+
"""Execute an evaluation run and return the artifact directory."""
|
|
77
|
+
|
|
78
|
+
records = load_dataset(self.config.dataset_path)
|
|
79
|
+
|
|
80
|
+
primary_records, primary_usage = self._prepare_transcripts(
|
|
81
|
+
records,
|
|
82
|
+
provider=self.provider if self.generate_transcripts else None,
|
|
83
|
+
model_identifier=self.config.model,
|
|
84
|
+
progress_callback=self.progress_callback,
|
|
85
|
+
)
|
|
86
|
+
primary_sessions = group_sessions(primary_records)
|
|
87
|
+
|
|
88
|
+
compare_records: Optional[list[dict[str, Any]]] = None
|
|
89
|
+
compare_usage: dict[str, int] = {}
|
|
90
|
+
compare_sessions: Optional[list[Session]] = None
|
|
91
|
+
|
|
92
|
+
if self.compare_scorers:
|
|
93
|
+
compare_records, compare_usage = self._prepare_transcripts(
|
|
94
|
+
records,
|
|
95
|
+
provider=self.compare_provider if self.compare_generate else None,
|
|
96
|
+
model_identifier=self.config.compare_model,
|
|
97
|
+
progress_callback=self.compare_progress_callback,
|
|
98
|
+
)
|
|
99
|
+
compare_sessions = group_sessions(compare_records)
|
|
100
|
+
|
|
101
|
+
primary_scores = self._run_scorers(self.scorers, primary_sessions)
|
|
102
|
+
score_results: dict[str, Any] = {"primary": primary_scores}
|
|
103
|
+
|
|
104
|
+
compare_scores: dict[str, Any] = {}
|
|
105
|
+
if self.compare_scorers and compare_sessions is not None:
|
|
106
|
+
compare_scores = self._run_scorers(self.compare_scorers, compare_sessions)
|
|
107
|
+
score_results["compare"] = compare_scores
|
|
108
|
+
score_results["diff"] = compute_diffs(primary_scores, compare_scores)
|
|
109
|
+
|
|
110
|
+
run_at = datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
|
111
|
+
run_dir = prepare_run_directory(self.config.report_out_dir, run_at, self.config.run_id)
|
|
112
|
+
|
|
113
|
+
transcript_info: dict[str, dict[str, str]] = {}
|
|
114
|
+
transcripts_dir = run_dir / "transcripts"
|
|
115
|
+
transcripts_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
primary_transcript_path = transcripts_dir / f"{_slugify_model(self.config.model)}.jsonl"
|
|
118
|
+
write_jsonl(primary_transcript_path, primary_records)
|
|
119
|
+
transcript_info["primary"] = {
|
|
120
|
+
"model": self.config.model,
|
|
121
|
+
"path": str(primary_transcript_path.relative_to(run_dir)),
|
|
122
|
+
"source": "generated" if self.generate_transcripts else "dataset",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if compare_records is not None:
|
|
126
|
+
compare_model = self.config.compare_model or "compare"
|
|
127
|
+
compare_transcript_path = transcripts_dir / f"{_slugify_model(compare_model)}.jsonl"
|
|
128
|
+
write_jsonl(compare_transcript_path, compare_records)
|
|
129
|
+
transcript_info["compare"] = {
|
|
130
|
+
"model": compare_model,
|
|
131
|
+
"path": str(compare_transcript_path.relative_to(run_dir)),
|
|
132
|
+
"source": "generated" if self.compare_generate else "dataset",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
run_summary = {
|
|
136
|
+
"run_id": self.config.run_id,
|
|
137
|
+
"model": self.config.model,
|
|
138
|
+
"compare_model": self.config.compare_model,
|
|
139
|
+
"dataset_path": str(self.config.dataset_path),
|
|
140
|
+
"persona_path": str(self.config.persona_path),
|
|
141
|
+
"run_at": run_at,
|
|
142
|
+
"session_count": len(primary_sessions),
|
|
143
|
+
"turn_count": len(primary_records),
|
|
144
|
+
"transcripts": transcript_info,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
usage_summary: dict[str, dict[str, int]] = {}
|
|
148
|
+
if primary_usage:
|
|
149
|
+
usage_summary["primary"] = {"model": self.config.model, **primary_usage}
|
|
150
|
+
if compare_usage:
|
|
151
|
+
usage_summary["compare"] = {"model": self.config.compare_model, **compare_usage}
|
|
152
|
+
if usage_summary:
|
|
153
|
+
run_summary["usage"] = usage_summary
|
|
154
|
+
|
|
155
|
+
write_json(run_dir / "run.json", run_summary)
|
|
156
|
+
scorecards = build_scorecards(primary_scores, compare_scores, score_results.get("diff", {}))
|
|
157
|
+
write_json(run_dir / "results.json", {"scores": score_results, "scorecards": scorecards})
|
|
158
|
+
|
|
159
|
+
aggregates = build_aggregates(score_results)
|
|
160
|
+
write_json(run_dir / "aggregates.json", aggregates)
|
|
161
|
+
|
|
162
|
+
for reporter in self.reporters:
|
|
163
|
+
reporter.write(run_dir, run_summary, score_results, primary_sessions, scorecards=scorecards)
|
|
164
|
+
|
|
165
|
+
if self.config.include_raw:
|
|
166
|
+
write_json(
|
|
167
|
+
run_dir / "raw.json",
|
|
168
|
+
{"sessions": [session.__dict__ for session in primary_sessions]},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return run_dir
|
|
172
|
+
|
|
173
|
+
def _run_scorers(self, scorers: Iterable, sessions: list[Session]) -> dict:
|
|
174
|
+
results = {}
|
|
175
|
+
for scorer in scorers:
|
|
176
|
+
results[scorer.id] = scorer.score(sessions)
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
def _prepare_transcripts(
|
|
180
|
+
self,
|
|
181
|
+
records: Iterable[dict[str, Any]],
|
|
182
|
+
*,
|
|
183
|
+
provider: Optional[ChatProvider],
|
|
184
|
+
model_identifier: Optional[str],
|
|
185
|
+
progress_callback: Optional[Callable[[int], None]] = None,
|
|
186
|
+
) -> Tuple[List[dict[str, Any]], dict[str, int]]:
|
|
187
|
+
grouped = _group_records(records)
|
|
188
|
+
output: List[dict[str, Any]] = []
|
|
189
|
+
usage = _UsageAccumulator()
|
|
190
|
+
|
|
191
|
+
for session_id in grouped:
|
|
192
|
+
conversation: List[dict[str, str]] = []
|
|
193
|
+
for turn in grouped[session_id]:
|
|
194
|
+
record = copy.deepcopy(turn)
|
|
195
|
+
role = (record.get("role") or "user").strip().lower()
|
|
196
|
+
|
|
197
|
+
if role == "assistant" and provider is not None:
|
|
198
|
+
baseline = record.get("text")
|
|
199
|
+
if baseline:
|
|
200
|
+
metadata = _ensure_metadata(record)
|
|
201
|
+
metadata.setdefault("baseline_text", baseline)
|
|
202
|
+
|
|
203
|
+
response = provider.chat([dict(msg) for msg in conversation])
|
|
204
|
+
generated_text = (response.text or "").strip()
|
|
205
|
+
record["text"] = generated_text
|
|
206
|
+
|
|
207
|
+
metadata = _ensure_metadata(record)
|
|
208
|
+
metadata["generated_by"] = model_identifier or getattr(provider, "name", "provider")
|
|
209
|
+
if response.usage:
|
|
210
|
+
metadata["usage"] = response.usage
|
|
211
|
+
usage.add(response.usage)
|
|
212
|
+
|
|
213
|
+
conversation.append({"role": "assistant", "content": generated_text})
|
|
214
|
+
if progress_callback:
|
|
215
|
+
progress_callback(1)
|
|
216
|
+
else:
|
|
217
|
+
conversation.append({"role": role or "user", "content": record.get("text", "")})
|
|
218
|
+
|
|
219
|
+
output.append(record)
|
|
220
|
+
|
|
221
|
+
return output, usage.as_dict()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def load_dataset(path: Path) -> list[dict]:
|
|
225
|
+
"""Load the dataset located at *path*."""
|
|
226
|
+
|
|
227
|
+
return read_jsonl(path)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def group_sessions(records: Iterable[dict]) -> list[Session]:
|
|
231
|
+
"""Group flat dataset records into ordered sessions."""
|
|
232
|
+
|
|
233
|
+
sessions: dict[str, list[dict]] = {}
|
|
234
|
+
for record in records:
|
|
235
|
+
session_id = record.get("session_id")
|
|
236
|
+
if not session_id:
|
|
237
|
+
raise ValueError("Dataset record missing 'session_id'.")
|
|
238
|
+
sessions.setdefault(session_id, []).append(record)
|
|
239
|
+
|
|
240
|
+
grouped: list[Session] = []
|
|
241
|
+
for session_id, turns in sessions.items():
|
|
242
|
+
ordered = sorted(turns, key=lambda item: item.get("turn_index", 0))
|
|
243
|
+
grouped.append(Session(session_id=session_id, turns=ordered))
|
|
244
|
+
|
|
245
|
+
grouped.sort(key=lambda session: session.session_id)
|
|
246
|
+
return grouped
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def prepare_run_directory(base_dir: Path, run_at: str, run_id: str) -> Path:
|
|
250
|
+
"""Create a timestamped run directory."""
|
|
251
|
+
|
|
252
|
+
timestamp = run_at.replace(":", "-").replace("Z", "")
|
|
253
|
+
run_dir = base_dir / f"{timestamp}_{run_id}"
|
|
254
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
return run_dir
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def compute_diffs(primary: dict, compare: dict) -> dict:
|
|
259
|
+
"""Compute numeric differences between primary and compare results."""
|
|
260
|
+
|
|
261
|
+
diffs: dict = {}
|
|
262
|
+
for scorer_id, primary_result in primary.items():
|
|
263
|
+
compare_result = compare.get(scorer_id)
|
|
264
|
+
if not isinstance(primary_result, dict) or not isinstance(compare_result, dict):
|
|
265
|
+
continue
|
|
266
|
+
diff_values = {}
|
|
267
|
+
for key, value in primary_result.items():
|
|
268
|
+
comp_value = compare_result.get(key)
|
|
269
|
+
if isinstance(value, (int, float)) and isinstance(comp_value, (int, float)):
|
|
270
|
+
diff_values[key] = round(value - comp_value, 3)
|
|
271
|
+
if diff_values:
|
|
272
|
+
diffs[scorer_id] = diff_values
|
|
273
|
+
return diffs
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_aggregates(score_results: dict) -> dict:
|
|
277
|
+
"""Produce lightweight aggregates for reports."""
|
|
278
|
+
|
|
279
|
+
aggregates: dict[str, dict] = {}
|
|
280
|
+
for scope in ("primary", "compare", "diff"):
|
|
281
|
+
result_set = score_results.get(scope)
|
|
282
|
+
if not isinstance(result_set, dict):
|
|
283
|
+
continue
|
|
284
|
+
scoped = {}
|
|
285
|
+
for scorer_id, values in result_set.items():
|
|
286
|
+
if isinstance(values, dict):
|
|
287
|
+
scoped[scorer_id] = {
|
|
288
|
+
key: value
|
|
289
|
+
for key, value in values.items()
|
|
290
|
+
if isinstance(value, (int, float))
|
|
291
|
+
}
|
|
292
|
+
if scoped:
|
|
293
|
+
aggregates[scope] = scoped
|
|
294
|
+
return {"aggregates": aggregates}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_scorecards(primary: dict, compare: dict, diff: dict) -> list[dict]:
|
|
298
|
+
"""Create scorecard summaries for headline metrics."""
|
|
299
|
+
|
|
300
|
+
config = {
|
|
301
|
+
"authenticity": ("mean", "Authenticity Score"),
|
|
302
|
+
"safety": ("score", "Safety Score"),
|
|
303
|
+
"stability": ("stability", "Stability"),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
scorecards: list[dict] = []
|
|
307
|
+
for scorer_id, (metric_key, label) in config.items():
|
|
308
|
+
primary_metrics = primary.get(scorer_id)
|
|
309
|
+
primary_value = _extract_metric(primary_metrics, metric_key)
|
|
310
|
+
if primary_value is None:
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
card = {
|
|
314
|
+
"id": scorer_id,
|
|
315
|
+
"label": label,
|
|
316
|
+
"metric": metric_key,
|
|
317
|
+
"primary": primary_value,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
compare_metrics = compare.get(scorer_id) if isinstance(compare, dict) else None
|
|
321
|
+
if isinstance(compare_metrics, dict) and compare_metrics:
|
|
322
|
+
compare_value = _extract_metric(compare_metrics, metric_key)
|
|
323
|
+
if compare_value is not None:
|
|
324
|
+
card["compare"] = compare_value
|
|
325
|
+
|
|
326
|
+
diff_metrics = diff.get(scorer_id) if isinstance(diff, dict) else None
|
|
327
|
+
if isinstance(diff_metrics, dict) and diff_metrics:
|
|
328
|
+
diff_value = _extract_metric(diff_metrics, metric_key)
|
|
329
|
+
if diff_value is not None:
|
|
330
|
+
card["diff"] = diff_value
|
|
331
|
+
|
|
332
|
+
scorecards.append(card)
|
|
333
|
+
|
|
334
|
+
return scorecards
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _extract_metric(metrics: Optional[dict], key: str) -> Optional[float]:
|
|
338
|
+
if isinstance(metrics, dict):
|
|
339
|
+
value = metrics.get(key)
|
|
340
|
+
if isinstance(value, (int, float)):
|
|
341
|
+
return float(value)
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _group_records(records: Iterable[dict[str, Any]]) -> Dict[str, List[dict[str, Any]]]:
|
|
346
|
+
grouped: Dict[str, List[dict[str, Any]]] = {}
|
|
347
|
+
for record in records:
|
|
348
|
+
session_id = record.get("session_id")
|
|
349
|
+
if not session_id:
|
|
350
|
+
raise ValueError("Dataset record missing 'session_id'.")
|
|
351
|
+
grouped.setdefault(session_id, []).append(record)
|
|
352
|
+
|
|
353
|
+
for turns in grouped.values():
|
|
354
|
+
turns.sort(key=lambda item: item.get("turn_index", 0))
|
|
355
|
+
|
|
356
|
+
return {session_id: grouped[session_id] for session_id in sorted(grouped)}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ensure_metadata(record: dict[str, Any]) -> dict[str, Any]:
|
|
360
|
+
metadata = record.get("metadata")
|
|
361
|
+
if not isinstance(metadata, dict):
|
|
362
|
+
metadata = {}
|
|
363
|
+
record["metadata"] = metadata
|
|
364
|
+
return metadata
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _slugify_model(identifier: Optional[str]) -> str:
|
|
368
|
+
if not identifier:
|
|
369
|
+
return "model"
|
|
370
|
+
slug = [ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in identifier]
|
|
371
|
+
collapsed = "".join(slug).strip("_")
|
|
372
|
+
return collapsed or "model"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _UsageAccumulator:
|
|
376
|
+
"""Track token usage totals for provider calls."""
|
|
377
|
+
|
|
378
|
+
def __init__(self) -> None:
|
|
379
|
+
self.prompt = 0
|
|
380
|
+
self.completion = 0
|
|
381
|
+
self.total = 0
|
|
382
|
+
|
|
383
|
+
def add(self, usage: dict[str, Any]) -> None:
|
|
384
|
+
self.prompt += _safe_int(usage.get("prompt_tokens"))
|
|
385
|
+
self.completion += _safe_int(usage.get("completion_tokens"))
|
|
386
|
+
self.total += _safe_int(usage.get("total_tokens"))
|
|
387
|
+
|
|
388
|
+
def as_dict(self) -> dict[str, int]:
|
|
389
|
+
return {
|
|
390
|
+
key: value
|
|
391
|
+
for key, value in {
|
|
392
|
+
"prompt_tokens": self.prompt,
|
|
393
|
+
"completion_tokens": self.completion,
|
|
394
|
+
"total_tokens": self.total,
|
|
395
|
+
}.items()
|
|
396
|
+
if value
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _safe_int(value: Any) -> int:
|
|
401
|
+
if value is None:
|
|
402
|
+
return 0
|
|
403
|
+
if isinstance(value, bool):
|
|
404
|
+
return int(value)
|
|
405
|
+
if isinstance(value, (int, float)):
|
|
406
|
+
return int(value)
|
|
407
|
+
try:
|
|
408
|
+
return int(str(value))
|
|
409
|
+
except (ValueError, TypeError):
|
|
410
|
+
return 0
|