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
alignmenter/cli.py
ADDED
|
@@ -0,0 +1,1815 @@
|
|
|
1
|
+
"""Command-line interface scaffold for Alignmenter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
import typer
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from alignmenter.config import get_settings
|
|
20
|
+
from alignmenter.providers import load_chat_provider
|
|
21
|
+
from alignmenter.providers.base import parse_provider_model
|
|
22
|
+
from alignmenter.providers.classifiers import load_safety_classifier
|
|
23
|
+
from alignmenter.providers.judges import load_judge_provider
|
|
24
|
+
from alignmenter.providers.openai import OpenAICustomGPTProvider
|
|
25
|
+
from alignmenter.run_config import load_run_options
|
|
26
|
+
from alignmenter.runner import RunConfig, Runner
|
|
27
|
+
from alignmenter.scorers.authenticity import AuthenticityScorer
|
|
28
|
+
from alignmenter.scorers.safety import SafetyScorer
|
|
29
|
+
from alignmenter.scorers.stability import StabilityScorer
|
|
30
|
+
app = typer.Typer(help="Alignmenter — audit your model's alignment signals.")
|
|
31
|
+
|
|
32
|
+
persona_app = typer.Typer(help="Persona helper commands.")
|
|
33
|
+
dataset_app = typer.Typer(help="Dataset helper commands.")
|
|
34
|
+
import_app = typer.Typer(help="Import helpers.")
|
|
35
|
+
|
|
36
|
+
app.add_typer(persona_app, name="persona")
|
|
37
|
+
app.add_typer(dataset_app, name="dataset")
|
|
38
|
+
app.add_typer(import_app, name="import")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@import_app.command("gpt")
|
|
42
|
+
def import_gpt(
|
|
43
|
+
instructions: Path = typer.Option(..., "--instructions", help="Path to instructions text file."),
|
|
44
|
+
name: str = typer.Option(..., "--name", help="Display name for the persona."),
|
|
45
|
+
out: Path = typer.Option(..., "--out", help="Where to write the persona YAML."),
|
|
46
|
+
allow_overwrite: bool = typer.Option(False, "--force", help="Overwrite the output file if it exists."),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Import Custom GPT instructions into a persona pack."""
|
|
49
|
+
|
|
50
|
+
if not instructions.exists():
|
|
51
|
+
raise typer.BadParameter(f"Instructions file not found: {instructions}")
|
|
52
|
+
if out.exists() and not allow_overwrite:
|
|
53
|
+
raise typer.BadParameter(f"Persona file {out} already exists. Use --force to overwrite.")
|
|
54
|
+
|
|
55
|
+
text = instructions.read_text(encoding="utf-8").strip()
|
|
56
|
+
if not text:
|
|
57
|
+
raise typer.BadParameter("Instructions file is empty.")
|
|
58
|
+
|
|
59
|
+
typer.echo("Parsing GPT instructions...")
|
|
60
|
+
persona_doc = _persona_from_instructions(name, text)
|
|
61
|
+
|
|
62
|
+
_ensure_parent(out)
|
|
63
|
+
with out.open("w", encoding="utf-8") as handle:
|
|
64
|
+
yaml.safe_dump(persona_doc, handle, sort_keys=False)
|
|
65
|
+
|
|
66
|
+
typer.secho(f"Imported persona written to {out}", fg=typer.colors.GREEN)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent
|
|
70
|
+
SOURCE_ROOT = PACKAGE_ROOT.parent # e.g., .../site-packages or repo /src
|
|
71
|
+
REPO_ROOT = SOURCE_ROOT.parent if SOURCE_ROOT.name == "src" else SOURCE_ROOT
|
|
72
|
+
PACKAGE_NAME = PACKAGE_ROOT.name
|
|
73
|
+
PROJECT_ROOT = REPO_ROOT
|
|
74
|
+
|
|
75
|
+
DATA_DIR = PACKAGE_ROOT / "data"
|
|
76
|
+
CONFIGS_DIR = DATA_DIR / "configs"
|
|
77
|
+
PERSONA_DIR = CONFIGS_DIR / "persona"
|
|
78
|
+
DATASETS_DIR = DATA_DIR / "datasets"
|
|
79
|
+
SAFETY_KEYWORDS = CONFIGS_DIR / "safety_keywords.yaml"
|
|
80
|
+
|
|
81
|
+
MODEL_BASE_CHOICES: list[dict[str, Any]] = [
|
|
82
|
+
{
|
|
83
|
+
"id": "openai-gpt4o-mini",
|
|
84
|
+
"label": "OpenAI GPT-4o mini",
|
|
85
|
+
"value": "openai:gpt-4o-mini",
|
|
86
|
+
"description": "Fast, production-ready default with balanced cost",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "openai-gpt-4.1-mini",
|
|
90
|
+
"label": "OpenAI GPT-4.1 mini",
|
|
91
|
+
"value": "openai:gpt-4.1-mini",
|
|
92
|
+
"description": "Higher quality OpenAI model with vision + tools",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "anthropic-claude-sonnet",
|
|
96
|
+
"label": "Anthropic Claude 3.5 Sonnet",
|
|
97
|
+
"value": "anthropic:claude-3-5-sonnet-20241022",
|
|
98
|
+
"description": "Anthropic's flagship for nuanced brand copy",
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
MODEL_OPTION_CUSTOM_GPT = {
|
|
103
|
+
"id": "custom-gpt",
|
|
104
|
+
"label": "OpenAI Custom GPT (requires gpt:// ID)",
|
|
105
|
+
"value": None,
|
|
106
|
+
"description": "Use a GPT Builder persona for brand voice benchmarking",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
MODEL_OPTION_LOCAL = {
|
|
110
|
+
"id": "local-endpoint",
|
|
111
|
+
"label": "Local endpoint (OpenAI-compatible)",
|
|
112
|
+
"value": None,
|
|
113
|
+
"description": "Point to your own server (e.g. vLLM, Ollama) with a model name",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
MODEL_OPTION_MANUAL = {
|
|
117
|
+
"id": "manual",
|
|
118
|
+
"label": "Manual entry",
|
|
119
|
+
"value": None,
|
|
120
|
+
"description": "Type a custom provider:model string",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
EMBEDDING_CHOICES: list[dict[str, Any]] = [
|
|
124
|
+
{
|
|
125
|
+
"id": "hashed",
|
|
126
|
+
"label": "Deterministic hashed embeddings (offline default)",
|
|
127
|
+
"value": "hashed",
|
|
128
|
+
"description": "No external calls; great for demos and CI",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "st-all-minilm",
|
|
132
|
+
"label": "Sentence Transformers: all-MiniLM-L6-v2",
|
|
133
|
+
"value": "sentence-transformer:all-MiniLM-L6-v2",
|
|
134
|
+
"description": "Lightweight English encoder for style similarity",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "openai-embed-small",
|
|
138
|
+
"label": "OpenAI text-embedding-3-small",
|
|
139
|
+
"value": "openai:text-embedding-3-small",
|
|
140
|
+
"description": "Affordable OpenAI embeddings for higher accuracy",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"id": "openai-embed-large",
|
|
144
|
+
"label": "OpenAI text-embedding-3-large",
|
|
145
|
+
"value": "openai:text-embedding-3-large",
|
|
146
|
+
"description": "Highest fidelity OpenAI embeddings",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "manual",
|
|
150
|
+
"label": "Manual entry",
|
|
151
|
+
"value": None,
|
|
152
|
+
"description": "Type any embedding provider identifier",
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _ensure_parent(path: Path) -> None:
|
|
158
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_path(candidate: str | Path) -> Path:
|
|
162
|
+
path = Path(candidate)
|
|
163
|
+
if path.exists():
|
|
164
|
+
return path
|
|
165
|
+
if not path.is_absolute():
|
|
166
|
+
normalized = path
|
|
167
|
+
if normalized.parts and normalized.parts[0] == PACKAGE_NAME:
|
|
168
|
+
normalized = Path(*normalized.parts[1:])
|
|
169
|
+
search_roots = [DATA_DIR, REPO_ROOT]
|
|
170
|
+
for root in search_roots:
|
|
171
|
+
fallback = root / normalized
|
|
172
|
+
if fallback.exists():
|
|
173
|
+
return fallback
|
|
174
|
+
raise typer.BadParameter(f"Path not found: {candidate}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _prompt_choice(
|
|
178
|
+
title: str,
|
|
179
|
+
options: list[dict[str, Any]],
|
|
180
|
+
*,
|
|
181
|
+
default_index: Optional[int] = None,
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
while True:
|
|
184
|
+
typer.echo(f"{title}:")
|
|
185
|
+
for idx, option in enumerate(options, start=1):
|
|
186
|
+
line = f" {idx}. {option['label']}"
|
|
187
|
+
description = option.get("description")
|
|
188
|
+
if description:
|
|
189
|
+
line += f" — {description}"
|
|
190
|
+
typer.echo(line)
|
|
191
|
+
|
|
192
|
+
prompt_label = "Select option"
|
|
193
|
+
default_value: Optional[str] = None
|
|
194
|
+
if default_index is not None:
|
|
195
|
+
prompt_label += f" [{default_index + 1}]"
|
|
196
|
+
default_value = str(default_index + 1)
|
|
197
|
+
|
|
198
|
+
choice_raw = typer.prompt(
|
|
199
|
+
prompt_label,
|
|
200
|
+
default=default_value if default_value is not None else "",
|
|
201
|
+
show_default=False,
|
|
202
|
+
).strip()
|
|
203
|
+
|
|
204
|
+
if not choice_raw:
|
|
205
|
+
if default_index is not None:
|
|
206
|
+
return options[default_index]
|
|
207
|
+
typer.secho("Please choose an option by number.", fg=typer.colors.YELLOW)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
choice_idx = int(choice_raw) - 1
|
|
212
|
+
except ValueError:
|
|
213
|
+
typer.secho("Please enter the number of an option.", fg=typer.colors.YELLOW)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if 0 <= choice_idx < len(options):
|
|
217
|
+
return options[choice_idx]
|
|
218
|
+
|
|
219
|
+
typer.secho("Invalid selection. Try again.", fg=typer.colors.YELLOW)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _find_model_default_index(model_identifier: Optional[str], choices: list[dict[str, Any]]) -> Optional[int]:
|
|
223
|
+
if not model_identifier:
|
|
224
|
+
return None
|
|
225
|
+
if model_identifier.startswith("openai-gpt:"):
|
|
226
|
+
for idx, option in enumerate(choices):
|
|
227
|
+
if option.get("id") == "custom-gpt":
|
|
228
|
+
return idx
|
|
229
|
+
if model_identifier.startswith("local:"):
|
|
230
|
+
for idx, option in enumerate(choices):
|
|
231
|
+
if option.get("id") == "local-endpoint":
|
|
232
|
+
return idx
|
|
233
|
+
for idx, option in enumerate(choices):
|
|
234
|
+
if option.get("value") == model_identifier:
|
|
235
|
+
return idx
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _extract_custom_gpt_id(model_identifier: Optional[str]) -> Optional[str]:
|
|
240
|
+
if not model_identifier:
|
|
241
|
+
return None
|
|
242
|
+
if model_identifier.startswith("openai-gpt:"):
|
|
243
|
+
return model_identifier.split(":", 1)[1]
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_local_identifier(identifier: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
248
|
+
if not identifier or not identifier.startswith("local:"):
|
|
249
|
+
return None, None
|
|
250
|
+
body = identifier.split(":", 1)[1]
|
|
251
|
+
endpoint, sep, model = body.partition("|")
|
|
252
|
+
return (endpoint or None), (model or None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def init(
|
|
257
|
+
env_path: Path = typer.Option(
|
|
258
|
+
Path(".env"),
|
|
259
|
+
help="Location for the environment file Alignmenter reads (defaults to project .env).",
|
|
260
|
+
),
|
|
261
|
+
config_path: Path = typer.Option(
|
|
262
|
+
Path("configs/run.yaml"),
|
|
263
|
+
help="Path for a starter run configuration YAML.",
|
|
264
|
+
),
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Interactively configure provider credentials and defaults."""
|
|
267
|
+
|
|
268
|
+
typer.secho("Alignmenter setup", fg=typer.colors.CYAN, bold=True)
|
|
269
|
+
typer.echo("Answer a few questions to wire up providers, budgets, and defaults.")
|
|
270
|
+
|
|
271
|
+
cwd = Path.cwd()
|
|
272
|
+
env_path = env_path if env_path.is_absolute() else cwd / env_path
|
|
273
|
+
config_path = config_path if config_path.is_absolute() else cwd / config_path
|
|
274
|
+
|
|
275
|
+
settings = get_settings()
|
|
276
|
+
env_entries = _load_env(env_path)
|
|
277
|
+
active_env_key = os.getenv("OPENAI_API_KEY") or settings.openai_api_key or ""
|
|
278
|
+
|
|
279
|
+
use_openai = typer.confirm(
|
|
280
|
+
"Configure OpenAI access?", default=bool(env_entries.get("OPENAI_API_KEY") or settings.openai_api_key)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
openai_key = ""
|
|
284
|
+
store_openai_in_file = False
|
|
285
|
+
existing_env_key = env_entries.get("OPENAI_API_KEY")
|
|
286
|
+
if use_openai:
|
|
287
|
+
if existing_env_key:
|
|
288
|
+
typer.echo("Found an existing OpenAI key in alignmenter/.env. Leave blank to keep it or enter a new key.")
|
|
289
|
+
elif active_env_key:
|
|
290
|
+
typer.echo("Detected OPENAI_API_KEY in your shell environment. Leave blank to keep using that value.")
|
|
291
|
+
else:
|
|
292
|
+
typer.echo("Provide an OpenAI API key (or leave blank if you plan to export OPENAI_API_KEY manually).")
|
|
293
|
+
|
|
294
|
+
openai_input = typer.prompt(
|
|
295
|
+
"OpenAI API key",
|
|
296
|
+
default="" if active_env_key and not existing_env_key else (existing_env_key or ""),
|
|
297
|
+
show_default=False,
|
|
298
|
+
).strip()
|
|
299
|
+
|
|
300
|
+
if openai_input:
|
|
301
|
+
openai_key = openai_input
|
|
302
|
+
store_openai_in_file = typer.confirm(
|
|
303
|
+
"Save this OpenAI key to alignmenter/.env for future runs?",
|
|
304
|
+
default=bool(existing_env_key),
|
|
305
|
+
)
|
|
306
|
+
if not store_openai_in_file:
|
|
307
|
+
typer.secho(
|
|
308
|
+
"The key will not be written to disk. Export OPENAI_API_KEY in your shell before running Alignmenter.",
|
|
309
|
+
fg=typer.colors.YELLOW,
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
if existing_env_key:
|
|
313
|
+
openai_key = existing_env_key
|
|
314
|
+
store_openai_in_file = True
|
|
315
|
+
elif active_env_key:
|
|
316
|
+
openai_key = active_env_key
|
|
317
|
+
store_openai_in_file = False
|
|
318
|
+
typer.secho(
|
|
319
|
+
"Using OPENAI_API_KEY from your environment. Run commands in the same shell to keep using it.",
|
|
320
|
+
fg=typer.colors.BLUE,
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
openai_key = ""
|
|
324
|
+
store_openai_in_file = False
|
|
325
|
+
|
|
326
|
+
embedding_default = env_entries.get("ALIGNMENTER_EMBEDDING_PROVIDER") or settings.embedding_provider or "hashed"
|
|
327
|
+
embedding_default_index = next(
|
|
328
|
+
(idx for idx, option in enumerate(EMBEDDING_CHOICES) if option.get("value") == embedding_default),
|
|
329
|
+
None,
|
|
330
|
+
)
|
|
331
|
+
embedding_choice = _prompt_choice(
|
|
332
|
+
"Embedding provider",
|
|
333
|
+
EMBEDDING_CHOICES,
|
|
334
|
+
default_index=embedding_default_index,
|
|
335
|
+
)
|
|
336
|
+
if embedding_choice.get("id") == "manual":
|
|
337
|
+
embedding_provider = typer.prompt(
|
|
338
|
+
"Embedding provider identifier",
|
|
339
|
+
default=embedding_default,
|
|
340
|
+
).strip()
|
|
341
|
+
if not embedding_provider:
|
|
342
|
+
embedding_provider = embedding_default or "hashed"
|
|
343
|
+
else:
|
|
344
|
+
embedding_provider = str(embedding_choice.get("value"))
|
|
345
|
+
|
|
346
|
+
custom_gpt_id = ""
|
|
347
|
+
if use_openai:
|
|
348
|
+
custom_gpt_id = typer.prompt(
|
|
349
|
+
"Default Custom GPT id (gpt://...), leave blank to skip",
|
|
350
|
+
default=env_entries.get("ALIGNMENTER_CUSTOM_GPT_ID") or settings.custom_gpt_id or "",
|
|
351
|
+
show_default=False,
|
|
352
|
+
).strip()
|
|
353
|
+
|
|
354
|
+
previous_model = env_entries.get("ALIGNMENTER_DEFAULT_MODEL") or settings.default_model
|
|
355
|
+
if not custom_gpt_id:
|
|
356
|
+
custom_gpt_id = _extract_custom_gpt_id(previous_model) or ""
|
|
357
|
+
if custom_gpt_id:
|
|
358
|
+
suggested_model = f"openai-gpt:{custom_gpt_id}"
|
|
359
|
+
elif previous_model:
|
|
360
|
+
suggested_model = previous_model
|
|
361
|
+
else:
|
|
362
|
+
suggested_model = "openai:gpt-4o-mini"
|
|
363
|
+
|
|
364
|
+
model_choices = [*MODEL_BASE_CHOICES]
|
|
365
|
+
model_choices.append(MODEL_OPTION_CUSTOM_GPT)
|
|
366
|
+
model_choices.append(MODEL_OPTION_LOCAL)
|
|
367
|
+
model_choices.append(MODEL_OPTION_MANUAL)
|
|
368
|
+
|
|
369
|
+
default_model_index = _find_model_default_index(suggested_model, model_choices)
|
|
370
|
+
selected_model_option = _prompt_choice(
|
|
371
|
+
"Default chat model",
|
|
372
|
+
model_choices,
|
|
373
|
+
default_index=default_model_index,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
custom_gpt_env_value: Optional[str] = None
|
|
377
|
+
if selected_model_option.get("id") == "custom-gpt":
|
|
378
|
+
custom_gpt_id = typer.prompt(
|
|
379
|
+
"Custom GPT identifier (gpt://...)",
|
|
380
|
+
default=custom_gpt_id,
|
|
381
|
+
show_default=False,
|
|
382
|
+
).strip()
|
|
383
|
+
if not custom_gpt_id:
|
|
384
|
+
typer.secho("No Custom GPT id provided. Falling back to OpenAI GPT-4o mini.", fg=typer.colors.YELLOW)
|
|
385
|
+
default_model = "openai:gpt-4o-mini"
|
|
386
|
+
custom_gpt_env_value = None
|
|
387
|
+
else:
|
|
388
|
+
default_model = f"openai-gpt:{custom_gpt_id}"
|
|
389
|
+
custom_gpt_env_value = custom_gpt_id
|
|
390
|
+
elif selected_model_option.get("id") == "local-endpoint":
|
|
391
|
+
default_endpoint, default_local_model = _parse_local_identifier(previous_model)
|
|
392
|
+
endpoint = typer.prompt(
|
|
393
|
+
"Local endpoint URL",
|
|
394
|
+
default=default_endpoint or "http://localhost:8000/v1/chat/completions",
|
|
395
|
+
).strip()
|
|
396
|
+
local_model = typer.prompt(
|
|
397
|
+
"Local model name",
|
|
398
|
+
default=default_local_model or "llama3",
|
|
399
|
+
).strip()
|
|
400
|
+
if not endpoint or not local_model:
|
|
401
|
+
typer.secho("Endpoint and model are required. Using manual entry fallback.", fg=typer.colors.YELLOW)
|
|
402
|
+
default_model = typer.prompt(
|
|
403
|
+
"Provider:model identifier",
|
|
404
|
+
default=suggested_model,
|
|
405
|
+
).strip()
|
|
406
|
+
else:
|
|
407
|
+
default_model = f"local:{endpoint}|{local_model}"
|
|
408
|
+
custom_gpt_id = ""
|
|
409
|
+
custom_gpt_env_value = None
|
|
410
|
+
elif selected_model_option.get("id") == "manual":
|
|
411
|
+
default_model = typer.prompt(
|
|
412
|
+
"Provider:model identifier",
|
|
413
|
+
default=suggested_model,
|
|
414
|
+
).strip()
|
|
415
|
+
custom_gpt_id = ""
|
|
416
|
+
custom_gpt_env_value = None
|
|
417
|
+
else:
|
|
418
|
+
default_model = str(selected_model_option.get("value"))
|
|
419
|
+
custom_gpt_id = ""
|
|
420
|
+
custom_gpt_env_value = None
|
|
421
|
+
|
|
422
|
+
use_judge = typer.confirm(
|
|
423
|
+
"Enable safety judge?",
|
|
424
|
+
default=bool(env_entries.get("ALIGNMENTER_JUDGE_PROVIDER") or settings.judge_provider),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
judge_provider = None
|
|
428
|
+
judge_budget_calls: Optional[int] = None
|
|
429
|
+
judge_budget_usd: Optional[float] = None
|
|
430
|
+
judge_price_in: Optional[float] = None
|
|
431
|
+
judge_price_out: Optional[float] = None
|
|
432
|
+
judge_tokens: Optional[int] = None
|
|
433
|
+
|
|
434
|
+
if use_judge:
|
|
435
|
+
judge_provider = typer.prompt(
|
|
436
|
+
"Judge provider (provider:model)",
|
|
437
|
+
default=env_entries.get("ALIGNMENTER_JUDGE_PROVIDER")
|
|
438
|
+
or settings.judge_provider
|
|
439
|
+
or "openai:gpt-4o-mini",
|
|
440
|
+
).strip()
|
|
441
|
+
judge_budget_calls = _prompt_optional_int(
|
|
442
|
+
"Maximum judge calls per run (blank for none)",
|
|
443
|
+
env_entries.get("ALIGNMENTER_JUDGE_BUDGET") or settings.judge_budget,
|
|
444
|
+
)
|
|
445
|
+
judge_budget_usd = _prompt_optional_float(
|
|
446
|
+
"Judge budget in USD (blank for none)",
|
|
447
|
+
env_entries.get("ALIGNMENTER_JUDGE_BUDGET_USD") or settings.judge_budget_usd,
|
|
448
|
+
)
|
|
449
|
+
judge_price_in = _prompt_optional_float(
|
|
450
|
+
"Price per 1K prompt tokens (USD)",
|
|
451
|
+
env_entries.get("ALIGNMENTER_JUDGE_PRICE_PER_1K_INPUT") or settings.judge_price_per_1k_input,
|
|
452
|
+
)
|
|
453
|
+
judge_price_out = _prompt_optional_float(
|
|
454
|
+
"Price per 1K completion tokens (USD)",
|
|
455
|
+
env_entries.get("ALIGNMENTER_JUDGE_PRICE_PER_1K_OUTPUT") or settings.judge_price_per_1k_output,
|
|
456
|
+
)
|
|
457
|
+
judge_tokens = _prompt_optional_int(
|
|
458
|
+
"Estimated tokens per judge call",
|
|
459
|
+
env_entries.get("ALIGNMENTER_JUDGE_ESTIMATED_TOKENS_PER_CALL")
|
|
460
|
+
or settings.judge_estimated_tokens_per_call,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
env_updates: dict[str, Optional[str]] = {
|
|
464
|
+
"OPENAI_API_KEY": openai_key if (use_openai and store_openai_in_file and openai_key) else None,
|
|
465
|
+
"ALIGNMENTER_DEFAULT_MODEL": default_model or None,
|
|
466
|
+
"ALIGNMENTER_EMBEDDING_PROVIDER": embedding_provider or None,
|
|
467
|
+
"ALIGNMENTER_JUDGE_PROVIDER": judge_provider or None,
|
|
468
|
+
"ALIGNMENTER_JUDGE_BUDGET": str(judge_budget_calls) if judge_budget_calls is not None else None,
|
|
469
|
+
"ALIGNMENTER_JUDGE_BUDGET_USD": _format_float(judge_budget_usd),
|
|
470
|
+
"ALIGNMENTER_JUDGE_PRICE_PER_1K_INPUT": _format_float(judge_price_in),
|
|
471
|
+
"ALIGNMENTER_JUDGE_PRICE_PER_1K_OUTPUT": _format_float(judge_price_out),
|
|
472
|
+
"ALIGNMENTER_JUDGE_ESTIMATED_TOKENS_PER_CALL": str(judge_tokens) if judge_tokens is not None else None,
|
|
473
|
+
"ALIGNMENTER_CUSTOM_GPT_ID": custom_gpt_env_value or None,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
_write_env(env_path, env_updates, existing=env_entries)
|
|
477
|
+
if openai_key:
|
|
478
|
+
os.environ["OPENAI_API_KEY"] = openai_key
|
|
479
|
+
get_settings.cache_clear()
|
|
480
|
+
|
|
481
|
+
_write_run_config(
|
|
482
|
+
config_path,
|
|
483
|
+
model=default_model,
|
|
484
|
+
embedding=embedding_provider,
|
|
485
|
+
judge_provider=judge_provider,
|
|
486
|
+
judge_budget=judge_budget_calls,
|
|
487
|
+
judge_budget_usd=judge_budget_usd,
|
|
488
|
+
judge_price_in=judge_price_in,
|
|
489
|
+
judge_price_out=judge_price_out,
|
|
490
|
+
judge_tokens=judge_tokens,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
typer.secho(f"✓ Environment updated -> {env_path}", fg=typer.colors.GREEN)
|
|
494
|
+
typer.secho(f"✓ Run config written -> {config_path}", fg=typer.colors.GREEN)
|
|
495
|
+
display_path = _relative_to_cwd(config_path)
|
|
496
|
+
typer.echo(f"Next: run `alignmenter run --config {display_path}`")
|
|
497
|
+
|
|
498
|
+
@app.command()
|
|
499
|
+
def run(
|
|
500
|
+
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to run configuration YAML."),
|
|
501
|
+
model: Optional[str] = typer.Option(None, help="Primary model identifier (provider:model-id)."),
|
|
502
|
+
dataset: Optional[str] = typer.Option(None, help="Path to conversation dataset."),
|
|
503
|
+
persona: Optional[str] = typer.Option(None, help="Persona pack to evaluate against."),
|
|
504
|
+
compare: Optional[str] = typer.Option(
|
|
505
|
+
None, help="Optional secondary model identifier for diff runs."
|
|
506
|
+
),
|
|
507
|
+
out: Optional[str] = typer.Option(None, help="Output directory for run artifacts."),
|
|
508
|
+
keywords: Optional[str] = typer.Option(None, help="Safety keyword configuration file."),
|
|
509
|
+
embedding: Optional[str] = typer.Option(None, help="Embedding provider identifier (e.g. 'sentence-transformer:all-MiniLM-L6-v2')."),
|
|
510
|
+
judge: Optional[str] = typer.Option(None, help="Safety judge provider identifier (e.g. 'openai:gpt-4o-mini')."),
|
|
511
|
+
judge_budget: Optional[int] = typer.Option(None, help="Maximum LLM judge calls per run."),
|
|
512
|
+
generate_transcripts: bool = typer.Option(
|
|
513
|
+
True,
|
|
514
|
+
"--generate/--no-generate",
|
|
515
|
+
help="Call the model to generate fresh transcripts before scoring.",
|
|
516
|
+
),
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Execute an evaluation run."""
|
|
519
|
+
|
|
520
|
+
settings = get_settings()
|
|
521
|
+
config_options: dict[str, object] = {}
|
|
522
|
+
if config:
|
|
523
|
+
config_path = _resolve_path(config)
|
|
524
|
+
config_options = load_run_options(config_path)
|
|
525
|
+
inputs, run_config = _prepare_run_inputs(
|
|
526
|
+
settings=settings,
|
|
527
|
+
config_options=config_options,
|
|
528
|
+
model=model,
|
|
529
|
+
dataset=dataset,
|
|
530
|
+
persona=persona,
|
|
531
|
+
keywords=keywords,
|
|
532
|
+
out=out,
|
|
533
|
+
compare=compare,
|
|
534
|
+
judge=judge,
|
|
535
|
+
judge_budget=judge_budget,
|
|
536
|
+
embedding=embedding,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
assistant_turns = _lazy_assistant_turn_counter(inputs.dataset_path)
|
|
540
|
+
_maybe_warn_about_cost(inputs, assistant_turns)
|
|
541
|
+
|
|
542
|
+
regenerate, provider, compare_provider_obj = _initialise_providers(
|
|
543
|
+
inputs.model_identifier,
|
|
544
|
+
inputs.compare_identifier,
|
|
545
|
+
generate_transcripts,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
safety_classifier = load_safety_classifier(inputs.classifier_identifier)
|
|
549
|
+
judge_provider = _initialise_judge_provider(inputs.judge_identifier)
|
|
550
|
+
scorers, compare_scorers = _build_scorers_for_run(
|
|
551
|
+
inputs,
|
|
552
|
+
safety_classifier=safety_classifier,
|
|
553
|
+
judge_provider=judge_provider,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
primary_progress, compare_progress = _build_progress_managers(
|
|
557
|
+
inputs,
|
|
558
|
+
regenerate,
|
|
559
|
+
assistant_turns,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
with primary_progress as primary_cb, compare_progress as compare_cb:
|
|
563
|
+
runner = Runner(
|
|
564
|
+
config=run_config,
|
|
565
|
+
scorers=scorers,
|
|
566
|
+
compare_scorers=compare_scorers,
|
|
567
|
+
provider=provider,
|
|
568
|
+
compare_provider=compare_provider_obj,
|
|
569
|
+
generate_transcripts=regenerate,
|
|
570
|
+
compare_generate=regenerate,
|
|
571
|
+
progress_callback=primary_cb,
|
|
572
|
+
compare_progress_callback=compare_cb,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
run_dir = runner.execute()
|
|
577
|
+
except Exception as exc: # noqa: BLE001 - present friendly message
|
|
578
|
+
typer.secho(f"Run failed: {exc}", fg=typer.colors.RED)
|
|
579
|
+
raise typer.Exit(code=1) from exc
|
|
580
|
+
|
|
581
|
+
_print_run_summary(run_dir)
|
|
582
|
+
report_path = run_dir / "index.html"
|
|
583
|
+
target = report_path if report_path.exists() else run_dir
|
|
584
|
+
typer.echo(f"Report written to: {_humanize_path(target)}")
|
|
585
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
586
|
+
_offer_report_open(run_dir)
|
|
587
|
+
else:
|
|
588
|
+
typer.echo(f"Open in browser: alignmenter report --path {_humanize_path(run_dir)}")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@app.command()
|
|
592
|
+
def demo(
|
|
593
|
+
model: str = typer.Option("openai:gpt-4o-mini", help="Demo model to evaluate."),
|
|
594
|
+
out: str = typer.Option("reports/demo", help="Directory for demo artifacts."),
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Convenience wrapper around run for demo datasets."""
|
|
597
|
+
typer.secho("Running demo evaluation...")
|
|
598
|
+
run(
|
|
599
|
+
model=model,
|
|
600
|
+
config=None,
|
|
601
|
+
dataset=str(DATASETS_DIR / "demo_conversations.jsonl"),
|
|
602
|
+
persona=str(PERSONA_DIR / "default.yaml"),
|
|
603
|
+
compare=None,
|
|
604
|
+
out=out,
|
|
605
|
+
keywords=str(SAFETY_KEYWORDS),
|
|
606
|
+
embedding=None,
|
|
607
|
+
judge=None,
|
|
608
|
+
judge_budget=None,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@app.command()
|
|
613
|
+
def report(
|
|
614
|
+
last: bool = typer.Option(False, "--last", help="Open the most recent report."),
|
|
615
|
+
path: Optional[str] = typer.Option(None, "--path", help="Path to specific report directory."),
|
|
616
|
+
reports_dir: str = typer.Option("reports", help="Base reports directory."),
|
|
617
|
+
) -> None:
|
|
618
|
+
"""Open or view reports."""
|
|
619
|
+
import platform
|
|
620
|
+
import subprocess
|
|
621
|
+
|
|
622
|
+
if not last and not path:
|
|
623
|
+
raise typer.BadParameter("Either --last or --path must be specified.")
|
|
624
|
+
|
|
625
|
+
if path:
|
|
626
|
+
report_dir = Path(path)
|
|
627
|
+
else:
|
|
628
|
+
# Find most recent report
|
|
629
|
+
reports_base = Path(reports_dir)
|
|
630
|
+
if not reports_base.exists():
|
|
631
|
+
raise typer.BadParameter(f"Reports directory not found: {reports_base}")
|
|
632
|
+
|
|
633
|
+
subdirs = [d for d in reports_base.iterdir() if d.is_dir()]
|
|
634
|
+
if not subdirs:
|
|
635
|
+
raise typer.BadParameter(f"No reports found in {reports_base}")
|
|
636
|
+
|
|
637
|
+
# Sort by modification time, most recent first
|
|
638
|
+
report_dir = max(subdirs, key=lambda d: d.stat().st_mtime)
|
|
639
|
+
|
|
640
|
+
html_path = report_dir / "index.html"
|
|
641
|
+
if not html_path.exists():
|
|
642
|
+
raise typer.BadParameter(f"No HTML report found at {html_path}")
|
|
643
|
+
|
|
644
|
+
typer.secho(f"Opening report: {html_path}", fg=typer.colors.GREEN)
|
|
645
|
+
|
|
646
|
+
# Open in browser
|
|
647
|
+
system = platform.system()
|
|
648
|
+
try:
|
|
649
|
+
if system == "Darwin": # macOS
|
|
650
|
+
subprocess.run(["open", str(html_path)], check=True)
|
|
651
|
+
elif system == "Linux":
|
|
652
|
+
subprocess.run(["xdg-open", str(html_path)], check=True)
|
|
653
|
+
elif system == "Windows":
|
|
654
|
+
subprocess.run(["start", str(html_path)], shell=True, check=True)
|
|
655
|
+
else:
|
|
656
|
+
typer.echo(f"Could not open browser. Please open: {html_path}")
|
|
657
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
658
|
+
typer.echo(f"Could not open browser. Please open: {html_path}")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _slugify(name: str) -> str:
|
|
662
|
+
slug = "".join(ch.lower() if ch.isalnum() else "_" for ch in name)
|
|
663
|
+
while "__" in slug:
|
|
664
|
+
slug = slug.replace("__", "_")
|
|
665
|
+
return slug.strip("_") or "persona"
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@persona_app.command("scaffold")
|
|
669
|
+
def persona_scaffold(
|
|
670
|
+
name: str = typer.Option(..., "--name", help="Display name for the persona."),
|
|
671
|
+
out: Optional[Path] = typer.Option(None, "--out", help="Path for the generated YAML."),
|
|
672
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
673
|
+
) -> None:
|
|
674
|
+
"""Generate a starter persona YAML template."""
|
|
675
|
+
|
|
676
|
+
slug = _slugify(name)
|
|
677
|
+
default_dir = Path("configs/persona")
|
|
678
|
+
target = out or (default_dir / f"{slug}.yaml")
|
|
679
|
+
if not target.is_absolute():
|
|
680
|
+
target = Path.cwd() / target
|
|
681
|
+
_ensure_parent(target)
|
|
682
|
+
|
|
683
|
+
if target.exists() and not force:
|
|
684
|
+
raise typer.BadParameter(f"Persona file {target} already exists. Use --force to overwrite.")
|
|
685
|
+
|
|
686
|
+
content = (
|
|
687
|
+
f"id: {slug}_v1\n"
|
|
688
|
+
f"display_name: {name}\n"
|
|
689
|
+
"exemplars:\n"
|
|
690
|
+
" - \"Describe tone, humor, and formality expectations.\"\n"
|
|
691
|
+
" - \"Add another exemplar guiding brevity or vocabulary.\"\n"
|
|
692
|
+
"lexicon:\n"
|
|
693
|
+
" preferred: [\"signal\", \"precision\"]\n"
|
|
694
|
+
" avoid: [\"lol\", \"super hyped\"]\n"
|
|
695
|
+
"style_rules:\n"
|
|
696
|
+
" sentence_length: {max_avg: 16}\n"
|
|
697
|
+
" contractions: {allowed: true}\n"
|
|
698
|
+
" emojis: {allowed: false}\n"
|
|
699
|
+
"safety_rules:\n"
|
|
700
|
+
" disallowed_topics: []\n"
|
|
701
|
+
" brand_notes: \"Add extra guardrails here.\"\n"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
target.write_text(content)
|
|
705
|
+
typer.echo(f"Persona template written to {target}")
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@persona_app.command("export")
|
|
709
|
+
def persona_export(
|
|
710
|
+
dataset: Path = typer.Option(
|
|
711
|
+
DATASETS_DIR / "demo_conversations.jsonl",
|
|
712
|
+
"--dataset",
|
|
713
|
+
help="Dataset file to export from (JSONL).",
|
|
714
|
+
),
|
|
715
|
+
out: Path = typer.Option(Path("persona_export.csv"), "--out", help="Output CSV path."),
|
|
716
|
+
persona_id: Optional[str] = typer.Option(None, "--persona-id", help="Filter to a single persona."),
|
|
717
|
+
format: str = typer.Option(
|
|
718
|
+
"csv",
|
|
719
|
+
"--format",
|
|
720
|
+
help="Export format: 'csv' (default) or 'labelstudio'.",
|
|
721
|
+
),
|
|
722
|
+
) -> None:
|
|
723
|
+
"""Export assistant turns for persona annotation."""
|
|
724
|
+
|
|
725
|
+
from alignmenter.utils.io import read_jsonl # avoid circular import
|
|
726
|
+
|
|
727
|
+
records = read_jsonl(dataset)
|
|
728
|
+
if persona_id:
|
|
729
|
+
records = [r for r in records if r.get("persona_id") == persona_id]
|
|
730
|
+
|
|
731
|
+
assistant_turns = [
|
|
732
|
+
r
|
|
733
|
+
for r in records
|
|
734
|
+
if r.get("role") == "assistant" and r.get("text")
|
|
735
|
+
]
|
|
736
|
+
|
|
737
|
+
if not assistant_turns:
|
|
738
|
+
raise typer.BadParameter("No assistant turns found matching criteria.")
|
|
739
|
+
|
|
740
|
+
export_format = format.lower()
|
|
741
|
+
_ensure_parent(out)
|
|
742
|
+
|
|
743
|
+
if export_format == "csv":
|
|
744
|
+
import csv
|
|
745
|
+
|
|
746
|
+
with out.open("w", newline="", encoding="utf-8") as handle:
|
|
747
|
+
writer = csv.DictWriter(
|
|
748
|
+
handle,
|
|
749
|
+
fieldnames=["persona_id", "session_id", "turn_index", "text", "tags"],
|
|
750
|
+
)
|
|
751
|
+
writer.writeheader()
|
|
752
|
+
for turn in assistant_turns:
|
|
753
|
+
writer.writerow(
|
|
754
|
+
{
|
|
755
|
+
"persona_id": turn.get("persona_id", ""),
|
|
756
|
+
"session_id": turn.get("session_id", ""),
|
|
757
|
+
"turn_index": turn.get("turn_index", ""),
|
|
758
|
+
"text": turn.get("text", ""),
|
|
759
|
+
"tags": ";".join(turn.get("tags", [])),
|
|
760
|
+
}
|
|
761
|
+
)
|
|
762
|
+
elif export_format == "labelstudio":
|
|
763
|
+
tasks = []
|
|
764
|
+
for turn in assistant_turns:
|
|
765
|
+
tasks.append(
|
|
766
|
+
{
|
|
767
|
+
"data": {
|
|
768
|
+
"persona_id": turn.get("persona_id", ""),
|
|
769
|
+
"session_id": turn.get("session_id", ""),
|
|
770
|
+
"turn_index": turn.get("turn_index", ""),
|
|
771
|
+
"text": turn.get("text", ""),
|
|
772
|
+
"tags": turn.get("tags", []),
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
with out.open("w", encoding="utf-8") as handle:
|
|
778
|
+
json.dump(tasks, handle, indent=2, ensure_ascii=False)
|
|
779
|
+
else:
|
|
780
|
+
raise typer.BadParameter("Unsupported format. Choose 'csv' or 'labelstudio'.")
|
|
781
|
+
|
|
782
|
+
typer.echo(f"Exported {len(assistant_turns)} turns to {out} ({export_format})")
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@persona_app.command("sync-gpt")
|
|
786
|
+
def persona_sync_gpt(
|
|
787
|
+
gpt_id: str = typer.Argument(..., help="Custom GPT identifier (gpt://...)"),
|
|
788
|
+
out: Optional[Path] = typer.Option(None, "--out", help="Where to write the synced persona YAML."),
|
|
789
|
+
force: bool = typer.Option(False, "--force", help="Overwrite the target file if it exists."),
|
|
790
|
+
) -> None:
|
|
791
|
+
"""Pull instructions from a Custom GPT into a persona pack."""
|
|
792
|
+
|
|
793
|
+
model_identifier = f"openai-gpt:{gpt_id}"
|
|
794
|
+
target = out if out is not None else _default_gpt_persona_path(gpt_id)
|
|
795
|
+
target = target if target.is_absolute() else Path.cwd() / target
|
|
796
|
+
persona_path = _sync_custom_gpt(
|
|
797
|
+
model_identifier,
|
|
798
|
+
default_persona=PERSONA_DIR / "default.yaml",
|
|
799
|
+
output_path=target,
|
|
800
|
+
force=force,
|
|
801
|
+
silent=False,
|
|
802
|
+
)
|
|
803
|
+
typer.secho(f"✓ Persona synced to {persona_path}", fg=typer.colors.GREEN)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@dataset_app.command("lint")
|
|
807
|
+
def dataset_lint(
|
|
808
|
+
path: Path = typer.Argument(..., help="Dataset JSONL file to validate."),
|
|
809
|
+
persona_dir: Optional[Path] = typer.Option(
|
|
810
|
+
PERSONA_DIR, "--persona-dir", help="Directory containing persona YAML files."
|
|
811
|
+
),
|
|
812
|
+
strict: bool = typer.Option(
|
|
813
|
+
False,
|
|
814
|
+
"--strict",
|
|
815
|
+
help="Enable additional checks (sequencing, role coverage, scenario tags).",
|
|
816
|
+
),
|
|
817
|
+
) -> None:
|
|
818
|
+
"""Validate dataset schema and persona coverage."""
|
|
819
|
+
|
|
820
|
+
from alignmenter.utils.io import read_jsonl
|
|
821
|
+
|
|
822
|
+
records = read_jsonl(path)
|
|
823
|
+
required_fields = {"session_id", "turn_index", "role", "text", "tags", "persona_id"}
|
|
824
|
+
errors: list[str] = []
|
|
825
|
+
persona_ids: set[str] = set()
|
|
826
|
+
sessions: dict[str, list[dict]] = {}
|
|
827
|
+
|
|
828
|
+
for idx, record in enumerate(records):
|
|
829
|
+
missing = required_fields - record.keys()
|
|
830
|
+
if missing:
|
|
831
|
+
errors.append(f"Record {idx} missing fields: {sorted(missing)}")
|
|
832
|
+
if not isinstance(record.get("turn_index"), int):
|
|
833
|
+
errors.append(f"Record {idx} turn_index must be int")
|
|
834
|
+
if not isinstance(record.get("tags"), list):
|
|
835
|
+
errors.append(f"Record {idx} tags must be list")
|
|
836
|
+
if not isinstance(record.get("text"), str) or not record.get("text"):
|
|
837
|
+
errors.append(f"Record {idx} text must be non-empty string")
|
|
838
|
+
persona = record.get("persona_id")
|
|
839
|
+
if persona:
|
|
840
|
+
persona_ids.add(persona)
|
|
841
|
+
session_id = record.get("session_id")
|
|
842
|
+
if session_id:
|
|
843
|
+
sessions.setdefault(session_id, []).append(record)
|
|
844
|
+
|
|
845
|
+
missing_persona_files: set[str] = set()
|
|
846
|
+
if persona_dir:
|
|
847
|
+
persona_dir = persona_dir.resolve()
|
|
848
|
+
available = {p.stem for p in persona_dir.glob("*.yaml")}
|
|
849
|
+
for pid in persona_ids:
|
|
850
|
+
base = pid.split("_")[0]
|
|
851
|
+
if base not in available and pid not in available:
|
|
852
|
+
missing_persona_files.add(pid)
|
|
853
|
+
|
|
854
|
+
if missing_persona_files:
|
|
855
|
+
errors.append(
|
|
856
|
+
"Persona definitions missing for: " + ", ".join(sorted(missing_persona_files))
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
if strict:
|
|
860
|
+
for session_id, turns in sessions.items():
|
|
861
|
+
roles = {t.get("role") for t in turns}
|
|
862
|
+
if "assistant" not in roles or "user" not in roles:
|
|
863
|
+
errors.append(f"Session {session_id} must include user and assistant turns")
|
|
864
|
+
sorted_turns = sorted(turns, key=lambda t: t.get("turn_index", -1))
|
|
865
|
+
base_index = sorted_turns[0].get("turn_index", 0)
|
|
866
|
+
for offset, record in enumerate(sorted_turns):
|
|
867
|
+
expected = base_index + offset
|
|
868
|
+
if record.get("turn_index") != expected:
|
|
869
|
+
errors.append(
|
|
870
|
+
f"Session {session_id} turn_index sequence broken at {record.get('turn_index')}"
|
|
871
|
+
)
|
|
872
|
+
break
|
|
873
|
+
if not any(
|
|
874
|
+
isinstance(tag, str) and tag.startswith("scenario:")
|
|
875
|
+
for turn in turns
|
|
876
|
+
for tag in turn.get("tags", [])
|
|
877
|
+
):
|
|
878
|
+
errors.append(f"Session {session_id} missing scenario:* tag coverage")
|
|
879
|
+
|
|
880
|
+
if errors:
|
|
881
|
+
for err in errors:
|
|
882
|
+
typer.secho(err, fg=typer.colors.RED)
|
|
883
|
+
raise typer.Exit(code=1)
|
|
884
|
+
|
|
885
|
+
typer.echo(
|
|
886
|
+
f"Dataset lint passed ({len(records)} records, personas: {', '.join(sorted(persona_ids)) or 'none'})"
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _load_env(path: Path) -> dict[str, str]:
|
|
891
|
+
if not path.exists():
|
|
892
|
+
return {}
|
|
893
|
+
entries: dict[str, str] = {}
|
|
894
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
895
|
+
stripped = line.strip()
|
|
896
|
+
if not stripped or stripped.startswith("#") or "=" not in line:
|
|
897
|
+
continue
|
|
898
|
+
key, value = line.split("=", 1)
|
|
899
|
+
entries[key.strip()] = value.strip()
|
|
900
|
+
return entries
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _write_env(path: Path, updates: dict[str, Optional[str]], *, existing: dict[str, str]) -> None:
|
|
904
|
+
merged = dict(existing)
|
|
905
|
+
for key, value in updates.items():
|
|
906
|
+
if value is None or value == "":
|
|
907
|
+
merged.pop(key, None)
|
|
908
|
+
else:
|
|
909
|
+
merged[key] = value
|
|
910
|
+
|
|
911
|
+
lines = ["# Alignmenter environment configuration"]
|
|
912
|
+
for key in sorted(merged):
|
|
913
|
+
lines.append(f"{key}={merged[key]}")
|
|
914
|
+
|
|
915
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
916
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _write_run_config(
|
|
920
|
+
path: Path,
|
|
921
|
+
*,
|
|
922
|
+
model: str,
|
|
923
|
+
embedding: str,
|
|
924
|
+
judge_provider: Optional[str],
|
|
925
|
+
judge_budget: Optional[int],
|
|
926
|
+
judge_budget_usd: Optional[float],
|
|
927
|
+
judge_price_in: Optional[float],
|
|
928
|
+
judge_price_out: Optional[float],
|
|
929
|
+
judge_tokens: Optional[int],
|
|
930
|
+
) -> None:
|
|
931
|
+
dataset_default = DATASETS_DIR / "demo_conversations.jsonl"
|
|
932
|
+
persona_default = PERSONA_DIR / "default.yaml"
|
|
933
|
+
keywords_default = SAFETY_KEYWORDS
|
|
934
|
+
|
|
935
|
+
base_dir = path.parent
|
|
936
|
+
reports_dir = (base_dir / ".." / "reports").resolve()
|
|
937
|
+
|
|
938
|
+
dataset_workspace = (base_dir.parent / "datasets" / dataset_default.name).resolve()
|
|
939
|
+
persona_workspace = (base_dir / "persona" / persona_default.name).resolve()
|
|
940
|
+
keywords_workspace = (base_dir / keywords_default.name).resolve()
|
|
941
|
+
|
|
942
|
+
for source, destination in [
|
|
943
|
+
(dataset_default, dataset_workspace),
|
|
944
|
+
(persona_default, persona_workspace),
|
|
945
|
+
(keywords_default, keywords_workspace),
|
|
946
|
+
]:
|
|
947
|
+
if destination.exists():
|
|
948
|
+
continue
|
|
949
|
+
_ensure_parent(destination)
|
|
950
|
+
shutil.copy2(source, destination)
|
|
951
|
+
|
|
952
|
+
safety_section: dict[str, Any] = {"offline_classifier": "auto"}
|
|
953
|
+
if judge_provider:
|
|
954
|
+
judge_cfg: dict[str, Any] = {"provider": judge_provider}
|
|
955
|
+
if judge_budget is not None:
|
|
956
|
+
judge_cfg["budget"] = judge_budget
|
|
957
|
+
if judge_budget_usd is not None:
|
|
958
|
+
judge_cfg["budget_usd"] = float(judge_budget_usd)
|
|
959
|
+
if judge_price_in is not None:
|
|
960
|
+
judge_cfg["price_per_1k_input"] = float(judge_price_in)
|
|
961
|
+
if judge_price_out is not None:
|
|
962
|
+
judge_cfg["price_per_1k_output"] = float(judge_price_out)
|
|
963
|
+
if judge_tokens is not None:
|
|
964
|
+
judge_cfg["estimated_tokens_per_call"] = judge_tokens
|
|
965
|
+
safety_section["judge"] = judge_cfg
|
|
966
|
+
|
|
967
|
+
config = {
|
|
968
|
+
"run_id": "alignmenter_run",
|
|
969
|
+
"model": model,
|
|
970
|
+
"dataset": _relpath_for_config(dataset_workspace, base_dir),
|
|
971
|
+
"persona": _relpath_for_config(persona_workspace, base_dir),
|
|
972
|
+
"keywords": _relpath_for_config(keywords_workspace, base_dir),
|
|
973
|
+
"embedding": embedding,
|
|
974
|
+
"scorers": {"safety": safety_section},
|
|
975
|
+
"report": {"out_dir": _relpath_for_config(reports_dir, base_dir), "include_raw": True},
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
979
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
980
|
+
yaml.safe_dump(config, handle, sort_keys=False)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _relpath_for_config(target: Path, base_dir: Path) -> str:
|
|
984
|
+
target_abs = Path(target)
|
|
985
|
+
if not target_abs.is_absolute():
|
|
986
|
+
target_abs = (Path.cwd() / target_abs).resolve()
|
|
987
|
+
else:
|
|
988
|
+
target_abs = target_abs.resolve()
|
|
989
|
+
|
|
990
|
+
base_abs = Path(base_dir)
|
|
991
|
+
base_abs = base_abs.resolve()
|
|
992
|
+
try:
|
|
993
|
+
return target_abs.relative_to(base_abs).as_posix()
|
|
994
|
+
except ValueError:
|
|
995
|
+
rel = os.path.relpath(target_abs, base_abs)
|
|
996
|
+
return Path(rel).as_posix()
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _format_float(value: Optional[float]) -> Optional[str]:
|
|
1000
|
+
if value is None:
|
|
1001
|
+
return None
|
|
1002
|
+
return (f"{value:.6f}".rstrip("0").rstrip("."))
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _prompt_optional_int(message: str, default: Optional[Any]) -> Optional[int]:
|
|
1006
|
+
default_str = "" if default in (None, "") else str(default)
|
|
1007
|
+
while True:
|
|
1008
|
+
raw = typer.prompt(message, default=default_str)
|
|
1009
|
+
raw = raw.strip()
|
|
1010
|
+
if not raw:
|
|
1011
|
+
return None
|
|
1012
|
+
try:
|
|
1013
|
+
return int(raw)
|
|
1014
|
+
except ValueError:
|
|
1015
|
+
typer.secho("Please enter an integer or leave blank.", fg=typer.colors.RED)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _prompt_optional_float(message: str, default: Optional[Any]) -> Optional[float]:
|
|
1019
|
+
default_str = "" if default in (None, "") else str(default)
|
|
1020
|
+
while True:
|
|
1021
|
+
raw = typer.prompt(message, default=default_str)
|
|
1022
|
+
raw = raw.strip()
|
|
1023
|
+
if not raw:
|
|
1024
|
+
return None
|
|
1025
|
+
try:
|
|
1026
|
+
return float(raw)
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
typer.secho("Please enter a number or leave blank.", fg=typer.colors.RED)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _build_judge_cost_config(options: dict[str, object], settings: Any) -> dict[str, float]:
|
|
1032
|
+
def _coerce_float(value: object) -> Optional[float]:
|
|
1033
|
+
try:
|
|
1034
|
+
if value is None or value == "":
|
|
1035
|
+
return None
|
|
1036
|
+
return float(value)
|
|
1037
|
+
except (TypeError, ValueError):
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
def _coerce_int(value: object) -> Optional[int]:
|
|
1041
|
+
try:
|
|
1042
|
+
if value is None or value == "":
|
|
1043
|
+
return None
|
|
1044
|
+
return int(value)
|
|
1045
|
+
except (TypeError, ValueError):
|
|
1046
|
+
return None
|
|
1047
|
+
|
|
1048
|
+
cost = {
|
|
1049
|
+
"budget_usd": _coerce_float(options.get("judge_budget_usd") or settings.judge_budget_usd),
|
|
1050
|
+
"price_per_1k_input": _coerce_float(
|
|
1051
|
+
options.get("judge_price_per_1k_input") or settings.judge_price_per_1k_input
|
|
1052
|
+
),
|
|
1053
|
+
"price_per_1k_output": _coerce_float(
|
|
1054
|
+
options.get("judge_price_per_1k_output") or settings.judge_price_per_1k_output
|
|
1055
|
+
),
|
|
1056
|
+
"estimated_tokens_per_call": _coerce_int(
|
|
1057
|
+
options.get("judge_estimated_tokens_per_call") or settings.judge_estimated_tokens_per_call
|
|
1058
|
+
),
|
|
1059
|
+
"estimated_prompt_tokens_per_call": _coerce_int(
|
|
1060
|
+
options.get("judge_estimated_prompt_tokens_per_call")
|
|
1061
|
+
or settings.judge_estimated_prompt_tokens_per_call
|
|
1062
|
+
),
|
|
1063
|
+
"estimated_completion_tokens_per_call": _coerce_int(
|
|
1064
|
+
options.get("judge_estimated_completion_tokens_per_call")
|
|
1065
|
+
or settings.judge_estimated_completion_tokens_per_call
|
|
1066
|
+
),
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
cost["cost_per_call_estimate"] = _estimate_cost_per_call(cost)
|
|
1070
|
+
return {key: value for key, value in cost.items() if value is not None}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _estimate_cost_per_call(cost: dict[str, float]) -> Optional[float]:
|
|
1074
|
+
price_in = cost.get("price_per_1k_input")
|
|
1075
|
+
price_out = cost.get("price_per_1k_output")
|
|
1076
|
+
prompt_tokens = cost.get("estimated_prompt_tokens_per_call")
|
|
1077
|
+
completion_tokens = cost.get("estimated_completion_tokens_per_call")
|
|
1078
|
+
total_tokens = cost.get("estimated_tokens_per_call")
|
|
1079
|
+
|
|
1080
|
+
if prompt_tokens is None and completion_tokens is None:
|
|
1081
|
+
prompt_tokens = total_tokens
|
|
1082
|
+
completion_tokens = total_tokens
|
|
1083
|
+
|
|
1084
|
+
cost_total = 0.0
|
|
1085
|
+
has_cost = False
|
|
1086
|
+
if prompt_tokens and price_in:
|
|
1087
|
+
cost_total += (prompt_tokens / 1000.0) * price_in
|
|
1088
|
+
has_cost = True
|
|
1089
|
+
if completion_tokens and price_out:
|
|
1090
|
+
cost_total += (completion_tokens / 1000.0) * price_out
|
|
1091
|
+
has_cost = True
|
|
1092
|
+
return round(cost_total, 6) if has_cost else None
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _count_assistant_turns(path: Path) -> int:
|
|
1096
|
+
from alignmenter.utils.io import read_jsonl
|
|
1097
|
+
|
|
1098
|
+
records = read_jsonl(path)
|
|
1099
|
+
return sum(1 for record in records if record.get("role") == "assistant" and record.get("text"))
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _relative_to_cwd(path: Path) -> str:
|
|
1103
|
+
try:
|
|
1104
|
+
return path.relative_to(Path.cwd()).as_posix()
|
|
1105
|
+
except ValueError:
|
|
1106
|
+
return path.as_posix()
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
class _ProgressReporter:
|
|
1110
|
+
"""Wrap Typer's progress bar to expose a simple callback."""
|
|
1111
|
+
|
|
1112
|
+
def __init__(self, *, total: int, label: str) -> None:
|
|
1113
|
+
self.total = max(0, total)
|
|
1114
|
+
self.label = label
|
|
1115
|
+
self._manager: Optional[Any] = None
|
|
1116
|
+
self._bar: Optional[Any] = None
|
|
1117
|
+
|
|
1118
|
+
def __enter__(self) -> Callable[[int], None]:
|
|
1119
|
+
if self.total > 0:
|
|
1120
|
+
self._manager = typer.progressbar(length=self.total, label=self.label)
|
|
1121
|
+
self._bar = self._manager.__enter__()
|
|
1122
|
+
return self.advance
|
|
1123
|
+
|
|
1124
|
+
def __exit__(self, exc_type, exc, tb) -> None: # noqa: D401 - Typer handles teardown
|
|
1125
|
+
if self._manager is not None:
|
|
1126
|
+
self._manager.__exit__(exc_type, exc, tb)
|
|
1127
|
+
self._manager = None
|
|
1128
|
+
self._bar = None
|
|
1129
|
+
|
|
1130
|
+
def advance(self, step: int = 1) -> None:
|
|
1131
|
+
if self._bar is not None and step:
|
|
1132
|
+
self._bar.update(step)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@dataclass
|
|
1136
|
+
class RunInputs:
|
|
1137
|
+
"""Resolved configuration for `alignmenter run`."""
|
|
1138
|
+
|
|
1139
|
+
model_identifier: str
|
|
1140
|
+
compare_identifier: Optional[str]
|
|
1141
|
+
dataset_path: Path
|
|
1142
|
+
persona_path: Path
|
|
1143
|
+
keywords_path: Path
|
|
1144
|
+
out_dir: Path
|
|
1145
|
+
run_id: str
|
|
1146
|
+
include_raw: bool
|
|
1147
|
+
embedding_identifier: Optional[str]
|
|
1148
|
+
judge_identifier: Optional[str]
|
|
1149
|
+
judge_budget: Optional[int]
|
|
1150
|
+
judge_cost: dict[str, float | int]
|
|
1151
|
+
classifier_identifier: str
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _prepare_run_inputs(
|
|
1155
|
+
*,
|
|
1156
|
+
settings: Any,
|
|
1157
|
+
config_options: dict[str, object],
|
|
1158
|
+
model: Optional[str],
|
|
1159
|
+
dataset: Optional[str],
|
|
1160
|
+
persona: Optional[str],
|
|
1161
|
+
keywords: Optional[str],
|
|
1162
|
+
out: Optional[str],
|
|
1163
|
+
compare: Optional[str],
|
|
1164
|
+
judge: Optional[str],
|
|
1165
|
+
judge_budget: Optional[int],
|
|
1166
|
+
embedding: Optional[str],
|
|
1167
|
+
) -> tuple[RunInputs, RunConfig]:
|
|
1168
|
+
model_identifier = model or config_options.get("model") or settings.default_model
|
|
1169
|
+
try:
|
|
1170
|
+
parse_provider_model(model_identifier)
|
|
1171
|
+
except ValueError as exc:
|
|
1172
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
1173
|
+
|
|
1174
|
+
dataset_candidate = dataset or config_options.get("dataset") or settings.default_dataset
|
|
1175
|
+
persona_candidate = persona or config_options.get("persona") or settings.default_persona
|
|
1176
|
+
keywords_candidate = keywords or config_options.get("keywords") or settings.default_keywords
|
|
1177
|
+
out_candidate = out or config_options.get("report_out_dir") or "reports/"
|
|
1178
|
+
|
|
1179
|
+
dataset_path = _resolve_path(dataset_candidate)
|
|
1180
|
+
persona_path = _resolve_path(persona_candidate)
|
|
1181
|
+
keywords_path = _resolve_path(keywords_candidate)
|
|
1182
|
+
out_dir = Path(out_candidate)
|
|
1183
|
+
|
|
1184
|
+
compare_identifier = compare if compare is not None else config_options.get("compare_model")
|
|
1185
|
+
judge_identifier = judge or config_options.get("judge_provider") or settings.judge_provider
|
|
1186
|
+
resolved_judge_budget = (
|
|
1187
|
+
judge_budget
|
|
1188
|
+
if judge_budget is not None
|
|
1189
|
+
else config_options.get("judge_budget", settings.judge_budget)
|
|
1190
|
+
)
|
|
1191
|
+
judge_cost = _build_judge_cost_config(config_options, settings)
|
|
1192
|
+
run_id = config_options.get("run_id", "alignmenter_run")
|
|
1193
|
+
include_raw = config_options.get("include_raw")
|
|
1194
|
+
embedding_identifier = (
|
|
1195
|
+
embedding or config_options.get("embedding") or settings.embedding_provider
|
|
1196
|
+
)
|
|
1197
|
+
classifier_identifier = (
|
|
1198
|
+
config_options.get("safety_classifier")
|
|
1199
|
+
or settings.safety_classifier
|
|
1200
|
+
or "auto"
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
persona_path = _sync_custom_gpt(model_identifier, persona_path)
|
|
1204
|
+
|
|
1205
|
+
run_config = RunConfig(
|
|
1206
|
+
model=model_identifier,
|
|
1207
|
+
dataset_path=dataset_path,
|
|
1208
|
+
persona_path=persona_path,
|
|
1209
|
+
compare_model=compare_identifier,
|
|
1210
|
+
report_out_dir=out_dir,
|
|
1211
|
+
run_id=run_id,
|
|
1212
|
+
include_raw=bool(include_raw) if include_raw is not None else True,
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
inputs = RunInputs(
|
|
1216
|
+
model_identifier=model_identifier,
|
|
1217
|
+
compare_identifier=compare_identifier,
|
|
1218
|
+
dataset_path=dataset_path,
|
|
1219
|
+
persona_path=persona_path,
|
|
1220
|
+
keywords_path=keywords_path,
|
|
1221
|
+
out_dir=out_dir,
|
|
1222
|
+
run_id=run_id,
|
|
1223
|
+
include_raw=run_config.include_raw,
|
|
1224
|
+
embedding_identifier=embedding_identifier,
|
|
1225
|
+
judge_identifier=judge_identifier,
|
|
1226
|
+
judge_budget=resolved_judge_budget,
|
|
1227
|
+
judge_cost=judge_cost,
|
|
1228
|
+
classifier_identifier=classifier_identifier,
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
return inputs, run_config
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _lazy_assistant_turn_counter(dataset_path: Path) -> Callable[[], int]:
|
|
1235
|
+
cached: Optional[int] = None
|
|
1236
|
+
|
|
1237
|
+
def _inner() -> int:
|
|
1238
|
+
nonlocal cached
|
|
1239
|
+
if cached is None:
|
|
1240
|
+
cached = _count_assistant_turns(dataset_path)
|
|
1241
|
+
return cached
|
|
1242
|
+
|
|
1243
|
+
return _inner
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _maybe_warn_about_cost(inputs: RunInputs, turn_counter: Callable[[], int]) -> None:
|
|
1247
|
+
cost_estimate = inputs.judge_cost.get("cost_per_call_estimate")
|
|
1248
|
+
budget = inputs.judge_cost.get("budget_usd")
|
|
1249
|
+
if not inputs.judge_identifier or cost_estimate is None or budget is None:
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
try:
|
|
1253
|
+
estimate_value = float(cost_estimate)
|
|
1254
|
+
budget_value = float(budget)
|
|
1255
|
+
except (TypeError, ValueError):
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
turns = turn_counter()
|
|
1259
|
+
projected_cost = turns * estimate_value
|
|
1260
|
+
if projected_cost > budget_value:
|
|
1261
|
+
typer.secho(
|
|
1262
|
+
(
|
|
1263
|
+
f"Projected judge spend ${projected_cost:.2f} exceeds budget ${budget_value:.2f}."
|
|
1264
|
+
" Continue?"
|
|
1265
|
+
),
|
|
1266
|
+
fg=typer.colors.YELLOW,
|
|
1267
|
+
)
|
|
1268
|
+
if not typer.confirm("Proceed with potential overage?", default=False):
|
|
1269
|
+
raise typer.Exit(code=1)
|
|
1270
|
+
else:
|
|
1271
|
+
typer.secho(
|
|
1272
|
+
f"Projected judge spend ${projected_cost:.2f} across {turns} calls.",
|
|
1273
|
+
fg=typer.colors.BLUE,
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _initialise_providers(
|
|
1278
|
+
model_identifier: str,
|
|
1279
|
+
compare_identifier: Optional[str],
|
|
1280
|
+
regenerate: bool,
|
|
1281
|
+
) -> tuple[bool, Optional[Any], Optional[Any]]:
|
|
1282
|
+
provider = None
|
|
1283
|
+
compare_provider = None
|
|
1284
|
+
|
|
1285
|
+
if not regenerate:
|
|
1286
|
+
return False, provider, compare_provider
|
|
1287
|
+
|
|
1288
|
+
try:
|
|
1289
|
+
provider = load_chat_provider(model_identifier)
|
|
1290
|
+
except Exception as exc: # noqa: BLE001 - surface friendly guidance
|
|
1291
|
+
typer.secho(
|
|
1292
|
+
f"Unable to initialise provider '{model_identifier}': {exc}",
|
|
1293
|
+
fg=typer.colors.YELLOW,
|
|
1294
|
+
)
|
|
1295
|
+
typer.secho(
|
|
1296
|
+
"Falling back to recorded transcripts. Re-run with --generate after configuring credentials.",
|
|
1297
|
+
fg=typer.colors.YELLOW,
|
|
1298
|
+
)
|
|
1299
|
+
return False, None, None
|
|
1300
|
+
|
|
1301
|
+
if compare_identifier:
|
|
1302
|
+
try:
|
|
1303
|
+
compare_provider = load_chat_provider(str(compare_identifier))
|
|
1304
|
+
except Exception as exc: # noqa: BLE001 - surface friendly guidance
|
|
1305
|
+
typer.secho(
|
|
1306
|
+
f"Unable to initialise compare provider '{compare_identifier}': {exc}",
|
|
1307
|
+
fg=typer.colors.YELLOW,
|
|
1308
|
+
)
|
|
1309
|
+
typer.secho(
|
|
1310
|
+
"Falling back to recorded transcripts for both models.",
|
|
1311
|
+
fg=typer.colors.YELLOW,
|
|
1312
|
+
)
|
|
1313
|
+
return False, provider, None
|
|
1314
|
+
|
|
1315
|
+
return True, provider, compare_provider
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _initialise_judge_provider(judge_identifier: Optional[str]):
|
|
1319
|
+
if not judge_identifier:
|
|
1320
|
+
return None
|
|
1321
|
+
try:
|
|
1322
|
+
return load_judge_provider(judge_identifier)
|
|
1323
|
+
except RuntimeError as exc:
|
|
1324
|
+
typer.secho(str(exc), fg=typer.colors.YELLOW)
|
|
1325
|
+
typer.secho(
|
|
1326
|
+
"Proceeding without the LLM judge. Set OPENAI_API_KEY or disable the judge in your config.",
|
|
1327
|
+
fg=typer.colors.YELLOW,
|
|
1328
|
+
)
|
|
1329
|
+
return None
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _build_scorers_for_run(
|
|
1333
|
+
inputs: RunInputs,
|
|
1334
|
+
*,
|
|
1335
|
+
safety_classifier: Any,
|
|
1336
|
+
judge_provider: Optional[Any],
|
|
1337
|
+
) -> tuple[list[Any], Optional[list[Any]]]:
|
|
1338
|
+
scorer_kwargs = {"embedding": inputs.embedding_identifier}
|
|
1339
|
+
judge_callable = judge_provider.evaluate if judge_provider else None
|
|
1340
|
+
|
|
1341
|
+
def _bundle() -> list[Any]:
|
|
1342
|
+
return [
|
|
1343
|
+
AuthenticityScorer(persona_path=inputs.persona_path, **scorer_kwargs),
|
|
1344
|
+
SafetyScorer(
|
|
1345
|
+
keyword_path=inputs.keywords_path,
|
|
1346
|
+
judge=judge_callable,
|
|
1347
|
+
judge_budget=inputs.judge_budget,
|
|
1348
|
+
cost_config=inputs.judge_cost,
|
|
1349
|
+
classifier=safety_classifier,
|
|
1350
|
+
),
|
|
1351
|
+
StabilityScorer(**scorer_kwargs),
|
|
1352
|
+
]
|
|
1353
|
+
|
|
1354
|
+
scorers = _bundle()
|
|
1355
|
+
compare_scorers = _bundle() if inputs.compare_identifier else None
|
|
1356
|
+
return scorers, compare_scorers
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _build_progress_managers(
|
|
1360
|
+
inputs: RunInputs,
|
|
1361
|
+
regenerate: bool,
|
|
1362
|
+
turn_counter: Callable[[], int],
|
|
1363
|
+
) -> tuple[_ProgressReporter, _ProgressReporter]:
|
|
1364
|
+
primary_total = turn_counter() if regenerate else 0
|
|
1365
|
+
compare_total = (
|
|
1366
|
+
turn_counter() if regenerate and inputs.compare_identifier else 0
|
|
1367
|
+
)
|
|
1368
|
+
primary = _ProgressReporter(
|
|
1369
|
+
total=primary_total,
|
|
1370
|
+
label=f"Generating transcripts ({inputs.model_identifier})",
|
|
1371
|
+
)
|
|
1372
|
+
compare = _ProgressReporter(
|
|
1373
|
+
total=compare_total,
|
|
1374
|
+
label=f"Generating transcripts (compare: {inputs.compare_identifier or 'secondary'})",
|
|
1375
|
+
)
|
|
1376
|
+
return primary, compare
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _default_gpt_persona_path(gpt_id: str) -> Path:
|
|
1380
|
+
slug = _slugify(gpt_id.replace("gpt://", ""))
|
|
1381
|
+
default_dir = Path("configs/persona/_gpt")
|
|
1382
|
+
return (Path.cwd() / default_dir / f"{slug}.yaml").resolve()
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def _sync_custom_gpt(
|
|
1386
|
+
model_identifier: str,
|
|
1387
|
+
default_persona: Path,
|
|
1388
|
+
*,
|
|
1389
|
+
output_path: Optional[Path] = None,
|
|
1390
|
+
force: bool = False,
|
|
1391
|
+
silent: bool = True,
|
|
1392
|
+
) -> Path:
|
|
1393
|
+
if not model_identifier.startswith("openai-gpt:"):
|
|
1394
|
+
return default_persona
|
|
1395
|
+
|
|
1396
|
+
try:
|
|
1397
|
+
_, gpt_id = parse_provider_model(model_identifier)
|
|
1398
|
+
except ValueError:
|
|
1399
|
+
return default_persona
|
|
1400
|
+
|
|
1401
|
+
settings = get_settings()
|
|
1402
|
+
if not settings.openai_api_key:
|
|
1403
|
+
if not silent:
|
|
1404
|
+
typer.secho(
|
|
1405
|
+
"Skipping Custom GPT sync: OPENAI_API_KEY is not configured.",
|
|
1406
|
+
fg=typer.colors.YELLOW,
|
|
1407
|
+
)
|
|
1408
|
+
return default_persona
|
|
1409
|
+
|
|
1410
|
+
target_path = output_path or _default_gpt_persona_path(gpt_id)
|
|
1411
|
+
if target_path.exists() and not force:
|
|
1412
|
+
if not silent:
|
|
1413
|
+
typer.secho(f"Using existing synced persona: {target_path}", fg=typer.colors.BLUE)
|
|
1414
|
+
return target_path
|
|
1415
|
+
|
|
1416
|
+
metadata, reason = _fetch_custom_gpt_metadata(gpt_id, settings.openai_api_key)
|
|
1417
|
+
if metadata:
|
|
1418
|
+
persona_doc = _persona_from_gpt_metadata(metadata)
|
|
1419
|
+
if not silent:
|
|
1420
|
+
typer.secho(f"Synced GPT persona -> {target_path}", fg=typer.colors.GREEN)
|
|
1421
|
+
else:
|
|
1422
|
+
description_doc, description_reason = _describe_gpt_via_conversation(
|
|
1423
|
+
gpt_id,
|
|
1424
|
+
settings.openai_api_key,
|
|
1425
|
+
)
|
|
1426
|
+
if description_doc:
|
|
1427
|
+
persona_doc = _persona_from_gpt_description(description_doc, gpt_id)
|
|
1428
|
+
if not silent:
|
|
1429
|
+
typer.secho(
|
|
1430
|
+
f"Synced GPT persona via conversation -> {target_path}",
|
|
1431
|
+
fg=typer.colors.GREEN,
|
|
1432
|
+
)
|
|
1433
|
+
else:
|
|
1434
|
+
if not silent and reason:
|
|
1435
|
+
typer.secho(reason, fg=typer.colors.YELLOW)
|
|
1436
|
+
if not silent and description_reason:
|
|
1437
|
+
typer.secho(description_reason, fg=typer.colors.YELLOW)
|
|
1438
|
+
persona_doc = _persona_stub_from_gpt(gpt_id)
|
|
1439
|
+
|
|
1440
|
+
_ensure_parent(target_path)
|
|
1441
|
+
with target_path.open("w", encoding="utf-8") as handle:
|
|
1442
|
+
yaml.safe_dump(persona_doc, handle, sort_keys=False)
|
|
1443
|
+
|
|
1444
|
+
return target_path
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _fetch_custom_gpt_metadata(
|
|
1448
|
+
gpt_id: str, api_key: Optional[str]
|
|
1449
|
+
) -> tuple[dict[str, Any], Optional[str]]:
|
|
1450
|
+
if not api_key:
|
|
1451
|
+
return {}, "OPENAI_API_KEY not configured; generating persona stub."
|
|
1452
|
+
try:
|
|
1453
|
+
provider = OpenAICustomGPTProvider(gpt_id)
|
|
1454
|
+
except RuntimeError as exc:
|
|
1455
|
+
return {}, str(exc)
|
|
1456
|
+
|
|
1457
|
+
client = getattr(provider, "_client", None)
|
|
1458
|
+
gpts = getattr(client, "gpts", None)
|
|
1459
|
+
if gpts is None:
|
|
1460
|
+
data, reason = _fetch_custom_gpt_metadata_http(gpt_id, api_key)
|
|
1461
|
+
if data:
|
|
1462
|
+
return data, None
|
|
1463
|
+
return {}, reason or "Custom GPT API not available; update openai package or request API access."
|
|
1464
|
+
|
|
1465
|
+
try:
|
|
1466
|
+
gpt = gpts.retrieve(gpt_id)
|
|
1467
|
+
except Exception as exc: # pragma: no cover - network failure
|
|
1468
|
+
return {}, f"Failed to retrieve GPT metadata via SDK: {exc}"
|
|
1469
|
+
|
|
1470
|
+
return _normalize_gpt_metadata(gpt_id, gpt), None
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _fetch_custom_gpt_metadata_http(
|
|
1474
|
+
gpt_id: str, api_key: str
|
|
1475
|
+
) -> tuple[dict[str, Any], Optional[str]]:
|
|
1476
|
+
url = f"https://api.openai.com/v1/gpts/{gpt_id}"
|
|
1477
|
+
headers = {
|
|
1478
|
+
"Authorization": f"Bearer {api_key}",
|
|
1479
|
+
"OpenAI-Beta": "gpts=2024-11-14",
|
|
1480
|
+
}
|
|
1481
|
+
try:
|
|
1482
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
1483
|
+
except requests.RequestException as exc: # pragma: no cover - network failure
|
|
1484
|
+
return {}, f"Failed to retrieve GPT metadata via HTTP: {exc}"
|
|
1485
|
+
|
|
1486
|
+
if response.status_code != 200:
|
|
1487
|
+
reason = (
|
|
1488
|
+
"Custom GPT API access is required (HTTP 404)."
|
|
1489
|
+
if response.status_code == 404
|
|
1490
|
+
else f"GPT metadata request returned {response.status_code}: {response.text[:120]}"
|
|
1491
|
+
)
|
|
1492
|
+
return {}, reason
|
|
1493
|
+
|
|
1494
|
+
return _normalize_gpt_metadata(gpt_id, response.json()), None
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _persona_from_gpt_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|
1498
|
+
gpt_id = metadata.get("id", "custom_gpt")
|
|
1499
|
+
slug = _slugify(gpt_id.replace("gpt://", ""))
|
|
1500
|
+
instructions = metadata.get("instructions") or ""
|
|
1501
|
+
conversation_starters = metadata.get("conversation_starters") or []
|
|
1502
|
+
if not conversation_starters and instructions:
|
|
1503
|
+
conversation_starters = [line.strip() for line in instructions.splitlines() if line.strip()][:2]
|
|
1504
|
+
|
|
1505
|
+
return {
|
|
1506
|
+
"id": f"{slug}_gpt",
|
|
1507
|
+
"display_name": metadata.get("name", slug.replace("_", " ").title()),
|
|
1508
|
+
"source": {"type": "openai_gpt", "id": gpt_id},
|
|
1509
|
+
"exemplars": conversation_starters,
|
|
1510
|
+
"lexicon": {"preferred": [], "avoid": []},
|
|
1511
|
+
"style_rules": {"instructions": instructions},
|
|
1512
|
+
"brand_notes": instructions,
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _persona_stub_from_gpt(gpt_id: str) -> dict[str, Any]:
|
|
1517
|
+
slug = _slugify(gpt_id.replace("gpt://", ""))
|
|
1518
|
+
display_name = slug.replace("_", " ").title()
|
|
1519
|
+
return {
|
|
1520
|
+
"id": f"{slug}_gpt",
|
|
1521
|
+
"display_name": display_name,
|
|
1522
|
+
"source": {"type": "openai_gpt", "id": gpt_id},
|
|
1523
|
+
"exemplars": ["Describe the brand voice."],
|
|
1524
|
+
"lexicon": {"preferred": [], "avoid": []},
|
|
1525
|
+
"style_rules": {"instructions": ""},
|
|
1526
|
+
"brand_notes": "",
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _persona_from_instructions(name: str, text: str) -> dict[str, Any]:
|
|
1531
|
+
slug = _slugify(name)
|
|
1532
|
+
exemplars = _extract_exemplars(text)
|
|
1533
|
+
lexicon_pref, lexicon_avoid = _extract_lexicon(text)
|
|
1534
|
+
style_rules = _extract_style_rules(text)
|
|
1535
|
+
safety = _extract_safety_rules(text)
|
|
1536
|
+
|
|
1537
|
+
return {
|
|
1538
|
+
"id": f"{slug}_v1",
|
|
1539
|
+
"display_name": name,
|
|
1540
|
+
"source": {"type": "manual"},
|
|
1541
|
+
"exemplars": exemplars,
|
|
1542
|
+
"lexicon": {"preferred": lexicon_pref, "avoid": lexicon_avoid},
|
|
1543
|
+
"style_rules": style_rules,
|
|
1544
|
+
"safety_rules": safety,
|
|
1545
|
+
"brand_notes": text.strip(),
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _extract_exemplars(text: str) -> list[str]:
|
|
1550
|
+
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
|
|
1551
|
+
exemplars = [s for s in sentences if 40 <= len(s) <= 200][:3]
|
|
1552
|
+
if not exemplars:
|
|
1553
|
+
exemplars = sentences[:2]
|
|
1554
|
+
return [s.strip() for s in exemplars if s.strip()]
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def _extract_lexicon(text: str) -> tuple[list[str], list[str]]:
|
|
1558
|
+
stopwords = set(
|
|
1559
|
+
"""
|
|
1560
|
+
a an the and or for with to of in on is are be this that it you your about
|
|
1561
|
+
from into through over under as at by we i their our ours yours its which
|
|
1562
|
+
""".split()
|
|
1563
|
+
)
|
|
1564
|
+
words = re.findall(r"[a-zA-Z][a-zA-Z\-]{2,}", text.lower())
|
|
1565
|
+
frequency: Counter[str] = Counter(w for w in words if w not in stopwords)
|
|
1566
|
+
preferred = [w for w, _ in frequency.most_common(12)]
|
|
1567
|
+
|
|
1568
|
+
avoid: list[str] = []
|
|
1569
|
+
for match in re.finditer(r"(avoid|never|do not|don’t)[:\-]?\s*(.+)", text, flags=re.I):
|
|
1570
|
+
items = re.split(r"[;,/•\n]", match.group(2))
|
|
1571
|
+
avoid.extend([i.strip().lower() for i in items if 2 <= len(i.strip()) <= 24])
|
|
1572
|
+
avoid = list(dict.fromkeys(avoid))[:12]
|
|
1573
|
+
return preferred, avoid
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _extract_style_rules(text: str) -> dict[str, Any]:
|
|
1577
|
+
concise = bool(re.search(r"\b(concise|brief|succinct)\b", text, re.I))
|
|
1578
|
+
formal = bool(re.search(r"\b(formal|objective|professional)\b", text, re.I))
|
|
1579
|
+
emoji_mention = bool(re.search(r"\bemoji|emojis|emoticon\b", text, re.I))
|
|
1580
|
+
allow_emoji = bool(re.search(r"emoji.*(allow|use|ok)\b", text, re.I)) if emoji_mention else False
|
|
1581
|
+
|
|
1582
|
+
return {
|
|
1583
|
+
"sentence_length": {"max_avg": 16 if concise else 20},
|
|
1584
|
+
"contractions": {"allowed": not formal},
|
|
1585
|
+
"emojis": {"allowed": allow_emoji},
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _extract_safety_rules(text: str) -> dict[str, list[str]]:
|
|
1590
|
+
disallowed: list[str] = []
|
|
1591
|
+
for match in re.finditer(r"(disallow|prohibit|no|never)[:\-]?\s*(.+)", text, re.I):
|
|
1592
|
+
disallowed.extend(
|
|
1593
|
+
[
|
|
1594
|
+
item.strip().lower()
|
|
1595
|
+
for item in re.split(r"[;,/•\n]", match.group(2))
|
|
1596
|
+
if 2 <= len(item.strip()) <= 40
|
|
1597
|
+
]
|
|
1598
|
+
)
|
|
1599
|
+
disallowed = list(dict.fromkeys(disallowed))[:12]
|
|
1600
|
+
return {"disallowed_topics": disallowed}
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _coerce_attr(obj: Any, name: str) -> Any:
|
|
1604
|
+
if isinstance(obj, dict):
|
|
1605
|
+
return obj.get(name)
|
|
1606
|
+
return getattr(obj, name, None)
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def _normalize_gpt_metadata(gpt_id: str, payload: Any) -> dict[str, Any]:
|
|
1610
|
+
instructions = _coerce_attr(payload, "instructions") or ""
|
|
1611
|
+
name = _coerce_attr(payload, "name") or gpt_id.split("/")[-1]
|
|
1612
|
+
starters: list[str] = []
|
|
1613
|
+
for starter in _coerce_attr(payload, "conversation_starters") or []:
|
|
1614
|
+
text = starter
|
|
1615
|
+
if isinstance(starter, dict):
|
|
1616
|
+
text = starter.get("message") or starter.get("text") or starter.get("content")
|
|
1617
|
+
if isinstance(text, dict):
|
|
1618
|
+
text = text.get("content") or text.get("text")
|
|
1619
|
+
if text:
|
|
1620
|
+
starters.append(str(text))
|
|
1621
|
+
|
|
1622
|
+
return {
|
|
1623
|
+
"id": gpt_id,
|
|
1624
|
+
"name": name,
|
|
1625
|
+
"instructions": instructions,
|
|
1626
|
+
"conversation_starters": starters,
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def _describe_gpt_via_conversation(
|
|
1631
|
+
gpt_id: str, api_key: Optional[str]
|
|
1632
|
+
) -> tuple[dict[str, Any], Optional[str]]:
|
|
1633
|
+
if not api_key:
|
|
1634
|
+
return {}, "OPENAI_API_KEY not configured; cannot query GPT for self-description."
|
|
1635
|
+
|
|
1636
|
+
if not gpt_id.startswith("gpt://"):
|
|
1637
|
+
return {}, (
|
|
1638
|
+
"Custom GPT runs require the `gpt://` model identifier (e.g. gpt://org/gpt-name). "
|
|
1639
|
+
"The raw g-… object ID cannot be used with the Responses API."
|
|
1640
|
+
)
|
|
1641
|
+
if not api_key:
|
|
1642
|
+
return {}, None
|
|
1643
|
+
|
|
1644
|
+
try:
|
|
1645
|
+
provider = OpenAICustomGPTProvider(gpt_id)
|
|
1646
|
+
except RuntimeError as exc:
|
|
1647
|
+
return {}, str(exc)
|
|
1648
|
+
|
|
1649
|
+
prompt = (
|
|
1650
|
+
"You are being evaluated for alignment. "
|
|
1651
|
+
"Summarize your own system instructions as JSON with keys: "
|
|
1652
|
+
"name (string), description (string), voice_samples (array of 2 short quotes), "
|
|
1653
|
+
"lexicon_preferred (array), lexicon_avoid (array), disallowed_topics (array). "
|
|
1654
|
+
"Return only JSON." # keep it strict
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
try:
|
|
1658
|
+
response = provider.chat([
|
|
1659
|
+
{"role": "user", "content": prompt},
|
|
1660
|
+
])
|
|
1661
|
+
except Exception as exc: # pragma: no cover - network failure
|
|
1662
|
+
return {}, f"Failed to query GPT for self-description: {exc}"
|
|
1663
|
+
|
|
1664
|
+
text = (response.text or "").strip()
|
|
1665
|
+
json_text = _extract_json_block(text)
|
|
1666
|
+
if not json_text:
|
|
1667
|
+
return {}, "GPT did not return JSON description; using stub persona."
|
|
1668
|
+
|
|
1669
|
+
try:
|
|
1670
|
+
description = json.loads(json_text)
|
|
1671
|
+
except json.JSONDecodeError:
|
|
1672
|
+
return {}, "Unable to parse GPT self-description JSON."
|
|
1673
|
+
|
|
1674
|
+
return description, None
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _persona_from_gpt_description(description: dict[str, Any], gpt_id: str) -> dict[str, Any]:
|
|
1678
|
+
slug = _slugify(gpt_id.replace("gpt://", ""))
|
|
1679
|
+
name = description.get("name") or slug.replace("_", " ").title()
|
|
1680
|
+
samples = description.get("voice_samples") or []
|
|
1681
|
+
if isinstance(samples, str):
|
|
1682
|
+
samples = [samples]
|
|
1683
|
+
|
|
1684
|
+
preferred = description.get("lexicon_preferred") or []
|
|
1685
|
+
avoid = description.get("lexicon_avoid") or []
|
|
1686
|
+
disallowed = description.get("disallowed_topics") or []
|
|
1687
|
+
description_text = description.get("description") or ""
|
|
1688
|
+
|
|
1689
|
+
return {
|
|
1690
|
+
"id": f"{slug}_gpt",
|
|
1691
|
+
"display_name": name,
|
|
1692
|
+
"source": {"type": "openai_gpt", "id": gpt_id},
|
|
1693
|
+
"exemplars": [s for s in samples if isinstance(s, str)][:3],
|
|
1694
|
+
"lexicon": {
|
|
1695
|
+
"preferred": [w for w in preferred if isinstance(w, str)],
|
|
1696
|
+
"avoid": [w for w in avoid if isinstance(w, str)],
|
|
1697
|
+
},
|
|
1698
|
+
"style_rules": {"instructions": description_text},
|
|
1699
|
+
"safety_rules": {
|
|
1700
|
+
"disallowed_topics": [t for t in disallowed if isinstance(t, str)],
|
|
1701
|
+
},
|
|
1702
|
+
"brand_notes": description_text,
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def _extract_json_block(text: str) -> Optional[str]:
|
|
1707
|
+
if not text:
|
|
1708
|
+
return None
|
|
1709
|
+
start = text.find("{")
|
|
1710
|
+
end = text.rfind("}")
|
|
1711
|
+
if start == -1 or end == -1 or end <= start:
|
|
1712
|
+
return None
|
|
1713
|
+
block = text[start : end + 1]
|
|
1714
|
+
block = block.replace("```json", "").replace("```", "").strip()
|
|
1715
|
+
return block or None
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def _print_run_summary(run_dir: Path) -> None:
|
|
1719
|
+
run_meta = _safe_read_json(run_dir / "run.json")
|
|
1720
|
+
if run_meta:
|
|
1721
|
+
turns = run_meta.get("turn_count")
|
|
1722
|
+
sessions = run_meta.get("session_count")
|
|
1723
|
+
if isinstance(turns, int) and isinstance(sessions, int):
|
|
1724
|
+
typer.echo(f"Loading dataset: {turns} turns across {sessions} sessions")
|
|
1725
|
+
|
|
1726
|
+
results = _safe_read_json(run_dir / "results.json")
|
|
1727
|
+
if not results:
|
|
1728
|
+
return
|
|
1729
|
+
|
|
1730
|
+
scorecards_raw = results.get("scorecards")
|
|
1731
|
+
scorecards = (
|
|
1732
|
+
[card for card in scorecards_raw if isinstance(card, dict)]
|
|
1733
|
+
if isinstance(scorecards_raw, list)
|
|
1734
|
+
else []
|
|
1735
|
+
)
|
|
1736
|
+
scorecard_index = {
|
|
1737
|
+
card.get("id"): card for card in scorecards if isinstance(card.get("id"), str)
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
scores = results.get("scores")
|
|
1741
|
+
primary_scores = (
|
|
1742
|
+
scores.get("primary")
|
|
1743
|
+
if isinstance(scores, dict) and isinstance(scores.get("primary"), dict)
|
|
1744
|
+
else {}
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
headlines = [
|
|
1748
|
+
("authenticity", "Brand voice score"),
|
|
1749
|
+
("safety", "Safety score"),
|
|
1750
|
+
("stability", "Consistency score"),
|
|
1751
|
+
]
|
|
1752
|
+
|
|
1753
|
+
for scorer_id, label in headlines:
|
|
1754
|
+
value: Optional[float] = None
|
|
1755
|
+
card = scorecard_index.get(scorer_id)
|
|
1756
|
+
if card:
|
|
1757
|
+
primary_value = card.get("primary")
|
|
1758
|
+
if isinstance(primary_value, (int, float)):
|
|
1759
|
+
value = float(primary_value)
|
|
1760
|
+
if value is None and isinstance(primary_scores, dict):
|
|
1761
|
+
metrics = primary_scores.get(scorer_id)
|
|
1762
|
+
if isinstance(metrics, dict):
|
|
1763
|
+
for key in ("mean", "score", "stability"):
|
|
1764
|
+
metric_value = metrics.get(key)
|
|
1765
|
+
if isinstance(metric_value, (int, float)):
|
|
1766
|
+
value = float(metric_value)
|
|
1767
|
+
break
|
|
1768
|
+
if value is None:
|
|
1769
|
+
continue
|
|
1770
|
+
|
|
1771
|
+
line = f"✓ {label}: {_format_score_value(value)}"
|
|
1772
|
+
if scorer_id == "authenticity" and isinstance(primary_scores, dict):
|
|
1773
|
+
metrics = primary_scores.get("authenticity")
|
|
1774
|
+
if isinstance(metrics, dict):
|
|
1775
|
+
low = metrics.get("ci95_low")
|
|
1776
|
+
high = metrics.get("ci95_high")
|
|
1777
|
+
if isinstance(low, (int, float)) and isinstance(high, (int, float)):
|
|
1778
|
+
line += f" (range: {_format_score_value(low)}-{_format_score_value(high)})"
|
|
1779
|
+
|
|
1780
|
+
typer.echo(line)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _humanize_path(path: Path) -> str:
|
|
1784
|
+
try:
|
|
1785
|
+
return str(path.relative_to(Path.cwd()))
|
|
1786
|
+
except ValueError:
|
|
1787
|
+
return str(path)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def _safe_read_json(path: Path) -> dict[str, Any]:
|
|
1791
|
+
try:
|
|
1792
|
+
payload = json.loads(path.read_text())
|
|
1793
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
1794
|
+
return {}
|
|
1795
|
+
return payload if isinstance(payload, dict) else {}
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _format_score_value(value: float) -> str:
|
|
1799
|
+
return f"{value:.2f}"
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def _offer_report_open(run_dir: Path) -> None:
|
|
1803
|
+
report_html = run_dir / "index.html"
|
|
1804
|
+
if not report_html.exists():
|
|
1805
|
+
return
|
|
1806
|
+
if typer.confirm("Open the report in your browser now?", default=False):
|
|
1807
|
+
try:
|
|
1808
|
+
typer.launch(str(report_html))
|
|
1809
|
+
except Exception as exc: # pragma: no cover
|
|
1810
|
+
typer.secho(f"Failed to launch browser automatically: {exc}", fg=typer.colors.YELLOW)
|
|
1811
|
+
typer.echo(f"Run manually: alignmenter report --path {_humanize_path(run_dir)}")
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
if __name__ == "__main__":
|
|
1815
|
+
app()
|