alignmenter 0.0.4__tar.gz → 0.0.8__tar.gz
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-0.0.4/src/alignmenter.egg-info → alignmenter-0.0.8}/PKG-INFO +5 -2
- {alignmenter-0.0.4 → alignmenter-0.0.8}/README.md +4 -1
- {alignmenter-0.0.4 → alignmenter-0.0.8}/pyproject.toml +2 -2
- alignmenter-0.0.8/src/alignmenter/__init__.py +30 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/cli.py +120 -8
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/reporting/html.py +159 -39
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/run_config.py +25 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/runner.py +172 -7
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/authenticity.py +12 -5
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/sanitize_dataset.py +76 -51
- {alignmenter-0.0.4 → alignmenter-0.0.8/src/alignmenter.egg-info}/PKG-INFO +5 -2
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_helpers.py +66 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_runner.py +31 -0
- alignmenter-0.0.4/src/alignmenter/__init__.py +0 -14
- {alignmenter-0.0.4 → alignmenter-0.0.8}/LICENSE +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/setup.cfg +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/config.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/demo_config.yaml +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/persona/default.yaml +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/run.yaml +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/__init__.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/anthropic.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/base.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/classifiers.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/embeddings.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/judges.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/local.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/openai.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/reporting/__init__.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/reporting/json_out.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/__init__.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/safety.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/stability.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/__init__.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/calibrate_persona.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/run_openai_demo.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/__init__.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/io.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/tokens.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/yaml.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/SOURCES.txt +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/dependency_links.txt +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/entry_points.txt +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/requires.txt +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/top_level.txt +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_calibrate_persona.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_errors.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_import.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_init.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_run_config.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_config.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_offline_safety.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_persona_gpt.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_provider_local.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_provider_openai.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_providers.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_run_config_loader.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_run_openai_demo.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_scorers.py +0 -0
- {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alignmenter
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: Persona-aligned evaluation toolkit for auditing conversational AI authenticity, safety, and stability.
|
|
5
5
|
Author: Alignmenter
|
|
6
6
|
License: Apache License
|
|
@@ -241,7 +241,7 @@ Requires-Dist: transformers>=4.30.0; extra == "safety"
|
|
|
241
241
|
Dynamic: license-file
|
|
242
242
|
|
|
243
243
|
<p align="center">
|
|
244
|
-
<img src="
|
|
244
|
+
<img src="https://alignmenter-branding.s3.us-west-2.amazonaws.com/alignmenter-banner.png" alt="Alignmenter" width="800">
|
|
245
245
|
</p>
|
|
246
246
|
|
|
247
247
|
<p align="center">
|
|
@@ -315,6 +315,9 @@ alignmenter run --config configs/run.yaml --no-generate
|
|
|
315
315
|
|
|
316
316
|
# View interactive HTML report
|
|
317
317
|
alignmenter report --last
|
|
318
|
+
|
|
319
|
+
# Sanitize a dataset in-place or to a new file
|
|
320
|
+
alignmenter dataset sanitize datasets/demo_conversations.jsonl --out datasets/demo_sanitized.jsonl
|
|
318
321
|
```
|
|
319
322
|
|
|
320
323
|
**Output:**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="
|
|
2
|
+
<img src="https://alignmenter-branding.s3.us-west-2.amazonaws.com/alignmenter-banner.png" alt="Alignmenter" width="800">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
@@ -73,6 +73,9 @@ alignmenter run --config configs/run.yaml --no-generate
|
|
|
73
73
|
|
|
74
74
|
# View interactive HTML report
|
|
75
75
|
alignmenter report --last
|
|
76
|
+
|
|
77
|
+
# Sanitize a dataset in-place or to a new file
|
|
78
|
+
alignmenter dataset sanitize datasets/demo_conversations.jsonl --out datasets/demo_sanitized.jsonl
|
|
76
79
|
```
|
|
77
80
|
|
|
78
81
|
**Output:**
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "alignmenter"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.8"
|
|
8
8
|
description = "Persona-aligned evaluation toolkit for auditing conversational AI authenticity, safety, and stability."
|
|
9
9
|
authors = [{name = "Alignmenter"}]
|
|
10
|
-
readme = {file = "README.md",
|
|
10
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
12
|
license = {file = "LICENSE"}
|
|
13
13
|
keywords = ["llm", "evaluation", "alignment", "persona", "safety"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Alignmenter package scaffold."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# Hugging Face tokenizers will warn loudly when forked after parallelism is enabled.
|
|
6
|
+
# Default to disabling parallel threads unless the user explicitly overrides it.
|
|
7
|
+
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
|
|
8
|
+
|
|
9
|
+
from .config import get_settings
|
|
10
|
+
|
|
11
|
+
__version__ = "0.0.4"
|
|
12
|
+
__all__ = ["get_settings", "__version__", "app"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _AppProxy:
|
|
16
|
+
"""Lazily import the Typer app when first accessed."""
|
|
17
|
+
|
|
18
|
+
def _resolve(self):
|
|
19
|
+
from .cli import app as cli_app
|
|
20
|
+
|
|
21
|
+
return cli_app
|
|
22
|
+
|
|
23
|
+
def __call__(self, *args, **kwargs):
|
|
24
|
+
return self._resolve()(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
def __getattr__(self, item):
|
|
27
|
+
return getattr(self._resolve(), item)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = _AppProxy()
|
|
@@ -23,6 +23,7 @@ from alignmenter.providers.classifiers import load_safety_classifier
|
|
|
23
23
|
from alignmenter.providers.judges import load_judge_provider
|
|
24
24
|
from alignmenter.providers.openai import OpenAICustomGPTProvider
|
|
25
25
|
from alignmenter.run_config import load_run_options
|
|
26
|
+
from alignmenter.scripts.sanitize_dataset import sanitize_dataset_file
|
|
26
27
|
from alignmenter.runner import RunConfig, Runner
|
|
27
28
|
from alignmenter.scorers.authenticity import AuthenticityScorer
|
|
28
29
|
from alignmenter.scorers.safety import SafetyScorer
|
|
@@ -570,6 +571,7 @@ def run(
|
|
|
570
571
|
compare_generate=regenerate,
|
|
571
572
|
progress_callback=primary_cb,
|
|
572
573
|
compare_progress_callback=compare_cb,
|
|
574
|
+
thresholds=inputs.thresholds,
|
|
573
575
|
)
|
|
574
576
|
|
|
575
577
|
try:
|
|
@@ -578,7 +580,8 @@ def run(
|
|
|
578
580
|
typer.secho(f"Run failed: {exc}", fg=typer.colors.RED)
|
|
579
581
|
raise typer.Exit(code=1) from exc
|
|
580
582
|
|
|
581
|
-
|
|
583
|
+
threshold_eval = getattr(runner, "threshold_results", {})
|
|
584
|
+
_print_run_summary(run_dir, thresholds=threshold_eval)
|
|
582
585
|
report_path = run_dir / "index.html"
|
|
583
586
|
target = report_path if report_path.exists() else run_dir
|
|
584
587
|
typer.echo(f"Report written to: {_humanize_path(target)}")
|
|
@@ -587,6 +590,9 @@ def run(
|
|
|
587
590
|
else:
|
|
588
591
|
typer.echo(f"Open in browser: alignmenter report --path {_humanize_path(run_dir)}")
|
|
589
592
|
|
|
593
|
+
if threshold_eval and any(info.get("status") == "fail" for info in threshold_eval.values()):
|
|
594
|
+
raise typer.Exit(code=2)
|
|
595
|
+
|
|
590
596
|
|
|
591
597
|
@app.command()
|
|
592
598
|
def demo(
|
|
@@ -887,15 +893,59 @@ def dataset_lint(
|
|
|
887
893
|
)
|
|
888
894
|
|
|
889
895
|
|
|
896
|
+
@dataset_app.command("sanitize")
|
|
897
|
+
def dataset_sanitize(
|
|
898
|
+
path: Path = typer.Argument(..., help="Path to input dataset (JSONL)."),
|
|
899
|
+
out: Optional[Path] = typer.Option(None, "--out", help="Output path (default: <input>_sanitized.jsonl)."),
|
|
900
|
+
in_place: bool = typer.Option(False, "--in-place", help="Overwrite the input file."),
|
|
901
|
+
use_hashing: bool = typer.Option(True, help="Use stable hashes for replacements instead of generic placeholders."),
|
|
902
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show sanitization results without writing output."),
|
|
903
|
+
) -> None:
|
|
904
|
+
input_path = path if path.is_absolute() else (Path.cwd() / path)
|
|
905
|
+
|
|
906
|
+
output_override: Optional[Path] = None
|
|
907
|
+
if out:
|
|
908
|
+
output_override = out if out.is_absolute() else (Path.cwd() / out)
|
|
909
|
+
|
|
910
|
+
try:
|
|
911
|
+
stats = sanitize_dataset_file(
|
|
912
|
+
path=input_path,
|
|
913
|
+
out=output_override,
|
|
914
|
+
in_place=in_place,
|
|
915
|
+
use_hashing=use_hashing,
|
|
916
|
+
dry_run=dry_run,
|
|
917
|
+
)
|
|
918
|
+
except FileNotFoundError:
|
|
919
|
+
raise typer.BadParameter(f"Dataset not found: {input_path}")
|
|
920
|
+
|
|
921
|
+
typer.secho("✓ Sanitization complete", fg=typer.colors.GREEN)
|
|
922
|
+
typer.echo(f" Records processed: {stats['records']}")
|
|
923
|
+
typer.echo(f" Total PII instances: {stats['total_pii']}")
|
|
924
|
+
for pii_type, count in stats["pii"].items():
|
|
925
|
+
if count:
|
|
926
|
+
typer.echo(f" {pii_type}: {count}")
|
|
927
|
+
|
|
928
|
+
output_path = stats["output_path"]
|
|
929
|
+
if stats["dry_run"]:
|
|
930
|
+
typer.secho("\n[DRY RUN] Would write to:", fg=typer.colors.YELLOW)
|
|
931
|
+
typer.echo(f" {output_path}")
|
|
932
|
+
if stats["sample"]:
|
|
933
|
+
typer.echo("\nSample sanitized records (first 3):")
|
|
934
|
+
for record in stats["sample"]:
|
|
935
|
+
typer.echo(json.dumps(record, indent=2))
|
|
936
|
+
else:
|
|
937
|
+
typer.echo(f" Output: {output_path}")
|
|
938
|
+
|
|
939
|
+
|
|
890
940
|
def _load_env(path: Path) -> dict[str, str]:
|
|
891
941
|
if not path.exists():
|
|
892
942
|
return {}
|
|
893
943
|
entries: dict[str, str] = {}
|
|
894
944
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
895
945
|
stripped = line.strip()
|
|
896
|
-
if not stripped or stripped.startswith("#") or "=" not in
|
|
946
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
897
947
|
continue
|
|
898
|
-
key, value =
|
|
948
|
+
key, value = stripped.split("=", 1)
|
|
899
949
|
entries[key.strip()] = value.strip()
|
|
900
950
|
return entries
|
|
901
951
|
|
|
@@ -1002,6 +1052,15 @@ def _format_float(value: Optional[float]) -> Optional[str]:
|
|
|
1002
1052
|
return (f"{value:.6f}".rstrip("0").rstrip("."))
|
|
1003
1053
|
|
|
1004
1054
|
|
|
1055
|
+
def _safe_float(value: object) -> Optional[float]:
|
|
1056
|
+
try:
|
|
1057
|
+
if value is None or value == "":
|
|
1058
|
+
return None
|
|
1059
|
+
return float(value)
|
|
1060
|
+
except (TypeError, ValueError):
|
|
1061
|
+
return None
|
|
1062
|
+
|
|
1063
|
+
|
|
1005
1064
|
def _prompt_optional_int(message: str, default: Optional[Any]) -> Optional[int]:
|
|
1006
1065
|
default_str = "" if default in (None, "") else str(default)
|
|
1007
1066
|
while True:
|
|
@@ -1149,6 +1208,7 @@ class RunInputs:
|
|
|
1149
1208
|
judge_budget: Optional[int]
|
|
1150
1209
|
judge_cost: dict[str, float | int]
|
|
1151
1210
|
classifier_identifier: str
|
|
1211
|
+
thresholds: dict[str, dict[str, float]]
|
|
1152
1212
|
|
|
1153
1213
|
|
|
1154
1214
|
def _prepare_run_inputs(
|
|
@@ -1200,6 +1260,22 @@ def _prepare_run_inputs(
|
|
|
1200
1260
|
or "auto"
|
|
1201
1261
|
)
|
|
1202
1262
|
|
|
1263
|
+
raw_thresholds = config_options.get("thresholds") or {}
|
|
1264
|
+
thresholds: dict[str, dict[str, float]] = {}
|
|
1265
|
+
if isinstance(raw_thresholds, dict):
|
|
1266
|
+
for scorer, cfg in raw_thresholds.items():
|
|
1267
|
+
if not isinstance(cfg, dict):
|
|
1268
|
+
continue
|
|
1269
|
+
warn = _safe_float(cfg.get("warn") or cfg.get("threshold_warn"))
|
|
1270
|
+
fail = _safe_float(cfg.get("fail") or cfg.get("threshold_fail"))
|
|
1271
|
+
scoped: dict[str, float] = {}
|
|
1272
|
+
if warn is not None:
|
|
1273
|
+
scoped["warn"] = warn
|
|
1274
|
+
if fail is not None:
|
|
1275
|
+
scoped["fail"] = fail
|
|
1276
|
+
if scoped:
|
|
1277
|
+
thresholds[scorer] = scoped
|
|
1278
|
+
|
|
1203
1279
|
persona_path = _sync_custom_gpt(model_identifier, persona_path)
|
|
1204
1280
|
|
|
1205
1281
|
run_config = RunConfig(
|
|
@@ -1226,6 +1302,7 @@ def _prepare_run_inputs(
|
|
|
1226
1302
|
judge_budget=resolved_judge_budget,
|
|
1227
1303
|
judge_cost=judge_cost,
|
|
1228
1304
|
classifier_identifier=classifier_identifier,
|
|
1305
|
+
thresholds=thresholds,
|
|
1229
1306
|
)
|
|
1230
1307
|
|
|
1231
1308
|
return inputs, run_config
|
|
@@ -1715,17 +1792,21 @@ def _extract_json_block(text: str) -> Optional[str]:
|
|
|
1715
1792
|
return block or None
|
|
1716
1793
|
|
|
1717
1794
|
|
|
1718
|
-
def _print_run_summary(
|
|
1795
|
+
def _print_run_summary(
|
|
1796
|
+
run_dir: Path, *, thresholds: Optional[dict[str, dict[str, Any]]] = None
|
|
1797
|
+
) -> dict[str, dict[str, Any]]:
|
|
1719
1798
|
run_meta = _safe_read_json(run_dir / "run.json")
|
|
1720
1799
|
if run_meta:
|
|
1721
1800
|
turns = run_meta.get("turn_count")
|
|
1722
1801
|
sessions = run_meta.get("session_count")
|
|
1723
1802
|
if isinstance(turns, int) and isinstance(sessions, int):
|
|
1724
1803
|
typer.echo(f"Loading dataset: {turns} turns across {sessions} sessions")
|
|
1804
|
+
if thresholds is None and isinstance(run_meta.get("thresholds"), dict):
|
|
1805
|
+
thresholds = run_meta.get("thresholds")
|
|
1725
1806
|
|
|
1726
1807
|
results = _safe_read_json(run_dir / "results.json")
|
|
1727
1808
|
if not results:
|
|
1728
|
-
return
|
|
1809
|
+
return thresholds or {}
|
|
1729
1810
|
|
|
1730
1811
|
scorecards_raw = results.get("scorecards")
|
|
1731
1812
|
scorecards = (
|
|
@@ -1750,6 +1831,16 @@ def _print_run_summary(run_dir: Path) -> None:
|
|
|
1750
1831
|
("stability", "Consistency score"),
|
|
1751
1832
|
]
|
|
1752
1833
|
|
|
1834
|
+
threshold_info = thresholds or {}
|
|
1835
|
+
for card in scorecards:
|
|
1836
|
+
status = card.get("status")
|
|
1837
|
+
if status and card.get("id") not in threshold_info:
|
|
1838
|
+
threshold_info[card.get("id")] = {
|
|
1839
|
+
"status": status,
|
|
1840
|
+
"warn": card.get("warn"),
|
|
1841
|
+
"fail": card.get("fail"),
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1753
1844
|
for scorer_id, label in headlines:
|
|
1754
1845
|
value: Optional[float] = None
|
|
1755
1846
|
card = scorecard_index.get(scorer_id)
|
|
@@ -1768,7 +1859,16 @@ def _print_run_summary(run_dir: Path) -> None:
|
|
|
1768
1859
|
if value is None:
|
|
1769
1860
|
continue
|
|
1770
1861
|
|
|
1771
|
-
|
|
1862
|
+
info = threshold_info.get(scorer_id, {}) if isinstance(threshold_info, dict) else {}
|
|
1863
|
+
status = info.get("status", "pass")
|
|
1864
|
+
symbol = {"pass": "✓", "warn": "⚠", "fail": "✗"}.get(status, "✓")
|
|
1865
|
+
color = {
|
|
1866
|
+
"pass": typer.colors.GREEN,
|
|
1867
|
+
"warn": typer.colors.YELLOW,
|
|
1868
|
+
"fail": typer.colors.RED,
|
|
1869
|
+
}.get(status, typer.colors.GREEN)
|
|
1870
|
+
|
|
1871
|
+
line = f"{label}: {_format_score_value(value)}"
|
|
1772
1872
|
if scorer_id == "authenticity" and isinstance(primary_scores, dict):
|
|
1773
1873
|
metrics = primary_scores.get("authenticity")
|
|
1774
1874
|
if isinstance(metrics, dict):
|
|
@@ -1776,8 +1876,20 @@ def _print_run_summary(run_dir: Path) -> None:
|
|
|
1776
1876
|
high = metrics.get("ci95_high")
|
|
1777
1877
|
if isinstance(low, (int, float)) and isinstance(high, (int, float)):
|
|
1778
1878
|
line += f" (range: {_format_score_value(low)}-{_format_score_value(high)})"
|
|
1779
|
-
|
|
1780
|
-
|
|
1879
|
+
warn_threshold = info.get("warn")
|
|
1880
|
+
fail_threshold = info.get("fail")
|
|
1881
|
+
if warn_threshold is not None or fail_threshold is not None:
|
|
1882
|
+
details = []
|
|
1883
|
+
if warn_threshold is not None:
|
|
1884
|
+
details.append(f"warn<{warn_threshold}")
|
|
1885
|
+
if fail_threshold is not None:
|
|
1886
|
+
details.append(f"fail<{fail_threshold}")
|
|
1887
|
+
if details:
|
|
1888
|
+
line += f" [{', '.join(details)}]"
|
|
1889
|
+
|
|
1890
|
+
typer.secho(f"{symbol} {line}", fg=color)
|
|
1891
|
+
|
|
1892
|
+
return threshold_info if isinstance(threshold_info, dict) else {}
|
|
1781
1893
|
|
|
1782
1894
|
|
|
1783
1895
|
def _humanize_path(path: Path) -> str:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any, Optional
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
@@ -47,6 +47,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
47
47
|
.grade-table tbody td:last-child {{ border-right: 1px solid #334155; border-radius: 0 8px 8px 0; }}
|
|
48
48
|
.metric-name {{ font-weight: 600; color: #e2e8f0; font-size: 1.1rem; }}
|
|
49
49
|
.grade-cell {{ text-align: center; }}
|
|
50
|
+
.threshold-note {{ font-size: 0.75rem; margin-top: 6px; color: #94a3b8; display: block; }}
|
|
50
51
|
.grade-badge {{ display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 8px; font-weight: 700; font-size: 1.1rem; }}
|
|
51
52
|
.grade-badge.pass {{ background: rgba(74, 222, 128, 0.15); color: #4ade80; border: 2px solid rgba(74, 222, 128, 0.3); }}
|
|
52
53
|
.grade-badge.warn {{ background: rgba(251, 191, 36, 0.15); color: #fbbf24; border: 2px solid rgba(251, 191, 36, 0.3); }}
|
|
@@ -118,11 +119,13 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
|
|
118
119
|
<button class="export-btn" onclick="downloadCSV(window.scoresDataCSV, 'scores.csv')">Download CSV</button>
|
|
119
120
|
</div>
|
|
120
121
|
{score_tables}
|
|
121
|
-
{charts_section}
|
|
122
122
|
</section>
|
|
123
|
+
{scenario_breakdown}
|
|
124
|
+
{persona_breakdown}
|
|
125
|
+
{charts_section}
|
|
123
126
|
{reproducibility_section}
|
|
124
127
|
<section>
|
|
125
|
-
<h2>Turn
|
|
128
|
+
<h2>Turn Explorer</h2>
|
|
126
129
|
{turn_preview}
|
|
127
130
|
</section>
|
|
128
131
|
<script>
|
|
@@ -195,7 +198,10 @@ class HTMLReporter:
|
|
|
195
198
|
table += safety_details
|
|
196
199
|
score_blocks.append(table)
|
|
197
200
|
|
|
198
|
-
|
|
201
|
+
analytics = extras.get("analytics") if isinstance(extras, dict) else None
|
|
202
|
+
|
|
203
|
+
scenario_section, persona_section = _render_breakdown_sections(analytics)
|
|
204
|
+
turn_preview = _render_turn_preview(sessions, analytics)
|
|
199
205
|
calibration_section = _render_calibration_section(primary)
|
|
200
206
|
reproducibility_section = _render_reproducibility_section(summary)
|
|
201
207
|
charts_section = _render_charts(primary)
|
|
@@ -212,6 +218,8 @@ class HTMLReporter:
|
|
|
212
218
|
scorecard_block=scorecard_block,
|
|
213
219
|
score_tables="".join(score_blocks) or "<p>No scores computed.</p>",
|
|
214
220
|
turn_preview=turn_preview,
|
|
221
|
+
scenario_breakdown=scenario_section,
|
|
222
|
+
persona_breakdown=persona_section,
|
|
215
223
|
calibration_section=calibration_section,
|
|
216
224
|
reproducibility_section=reproducibility_section,
|
|
217
225
|
charts_section=charts_section,
|
|
@@ -371,15 +379,28 @@ def _render_scorecards(scorecards: list[dict], summary: dict[str, Any]) -> str:
|
|
|
371
379
|
diff_val = card.get("diff")
|
|
372
380
|
|
|
373
381
|
# Determine grade class
|
|
374
|
-
|
|
382
|
+
status = card.get("status")
|
|
383
|
+
grade_class = _get_grade_class(primary_val, status)
|
|
375
384
|
letter = _get_grade_letter(primary_val)
|
|
376
385
|
|
|
377
386
|
row = '<tr>'
|
|
378
387
|
row += f'<td><span class="metric-name">{card.get("label", card.get("id", "Metric").title())}</span></td>'
|
|
388
|
+
warn_threshold = card.get("warn")
|
|
389
|
+
fail_threshold = card.get("fail")
|
|
390
|
+
threshold_note = ""
|
|
391
|
+
if warn_threshold is not None or fail_threshold is not None:
|
|
392
|
+
details = []
|
|
393
|
+
if warn_threshold is not None:
|
|
394
|
+
details.append(f"Warn < {warn_threshold}")
|
|
395
|
+
if fail_threshold is not None:
|
|
396
|
+
details.append(f"Fail < {fail_threshold}")
|
|
397
|
+
if details:
|
|
398
|
+
threshold_note = f"<div class='muted threshold-note'>{' | '.join(details)}</div>"
|
|
399
|
+
|
|
379
400
|
row += f'<td class="grade-cell"><div class="grade-badge {grade_class}">'
|
|
380
401
|
row += f'<span class="grade-letter">{letter}</span>'
|
|
381
402
|
row += f'<span class="grade-score">{_format_scorecard_value(primary_val)}</span>'
|
|
382
|
-
row += '</div
|
|
403
|
+
row += f'</div>{threshold_note}</td>'
|
|
383
404
|
|
|
384
405
|
if has_compare:
|
|
385
406
|
if compare_val is not None:
|
|
@@ -422,8 +443,10 @@ def _render_scorecards(scorecards: list[dict], summary: dict[str, Any]) -> str:
|
|
|
422
443
|
"""
|
|
423
444
|
|
|
424
445
|
|
|
425
|
-
def _get_grade_class(score: Any) -> str:
|
|
446
|
+
def _get_grade_class(score: Any, status: Optional[str] = None) -> str:
|
|
426
447
|
"""Get CSS class for grade styling."""
|
|
448
|
+
if status in {"pass", "warn", "fail"}:
|
|
449
|
+
return status
|
|
427
450
|
if not isinstance(score, (int, float)):
|
|
428
451
|
return ""
|
|
429
452
|
if score >= 0.8:
|
|
@@ -456,45 +479,142 @@ def _format_scorecard_value(value: Any) -> str:
|
|
|
456
479
|
return str(value)
|
|
457
480
|
|
|
458
481
|
|
|
459
|
-
def
|
|
482
|
+
def _render_breakdown_sections(analytics: Any) -> tuple[str, str]:
|
|
483
|
+
scenarios_html = _render_breakdown_table("Scenario Breakdown", analytics.get("scenarios") if isinstance(analytics, dict) else {})
|
|
484
|
+
personas_html = _render_breakdown_table("Persona Breakdown", analytics.get("personas") if isinstance(analytics, dict) else {})
|
|
485
|
+
return scenarios_html, personas_html
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _render_breakdown_table(title: str, items: Any) -> str:
|
|
489
|
+
if not isinstance(items, dict) or not items:
|
|
490
|
+
return ""
|
|
491
|
+
|
|
460
492
|
rows = []
|
|
461
|
-
for
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
""
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
493
|
+
for name, payload in items.items():
|
|
494
|
+
if not isinstance(payload, dict):
|
|
495
|
+
continue
|
|
496
|
+
scores = payload.get("scores", {}) if isinstance(payload.get("scores"), dict) else {}
|
|
497
|
+
auth = _breakdown_score(scores, "authenticity", "mean")
|
|
498
|
+
safety = _breakdown_score(scores, "safety", "score")
|
|
499
|
+
stability = _breakdown_score(scores, "stability", "stability")
|
|
500
|
+
risk_candidates = [v for v in (auth, safety, stability) if v is not None]
|
|
501
|
+
risk = min(risk_candidates) if risk_candidates else None
|
|
502
|
+
css_class = _get_score_class(risk) if risk is not None else ""
|
|
503
|
+
rows.append(
|
|
504
|
+
{
|
|
505
|
+
"name": name,
|
|
506
|
+
"sessions": payload.get("sessions", 0),
|
|
507
|
+
"turns": payload.get("turns", 0),
|
|
508
|
+
"auth": auth,
|
|
509
|
+
"safety": safety,
|
|
510
|
+
"stability": stability,
|
|
511
|
+
"risk": risk if risk is not None else 1.0,
|
|
512
|
+
"class": css_class,
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
rows.sort(key=lambda item: item["risk"])
|
|
517
|
+
|
|
518
|
+
body = []
|
|
519
|
+
for row in rows:
|
|
520
|
+
body.append(
|
|
521
|
+
"<tr>"
|
|
522
|
+
f"<td>{row['name']}</td>"
|
|
523
|
+
f"<td>{row['sessions']}</td>"
|
|
524
|
+
f"<td>{row['turns']}</td>"
|
|
525
|
+
f"<td class='{row['class'] or ''}'>{_format_score_value(row['auth'])}</td>"
|
|
526
|
+
f"<td class='{row['class'] or ''}'>{_format_score_value(row['safety'])}</td>"
|
|
527
|
+
f"<td class='{row['class'] or ''}'>{_format_score_value(row['stability'])}</td>"
|
|
528
|
+
"</tr>"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
f"<section><h2>{title}</h2>"
|
|
533
|
+
"<table>"
|
|
534
|
+
"<thead><tr><th>Name</th><th>Sessions</th><th>Turns</th><th>Authenticity</th><th>Safety</th><th>Consistency</th></tr></thead>"
|
|
535
|
+
f"<tbody>{''.join(body)}</tbody></table></section>"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _breakdown_score(scores: dict[str, Any], scorer_id: str, metric: str) -> Optional[float]:
|
|
540
|
+
metrics = scores.get(scorer_id)
|
|
541
|
+
if isinstance(metrics, dict):
|
|
542
|
+
value = metrics.get(metric)
|
|
543
|
+
if isinstance(value, (int, float)):
|
|
544
|
+
return round(float(value), 3)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _render_turn_preview(sessions: list, analytics: Any) -> str:
|
|
549
|
+
turns: list[dict[str, Any]] = []
|
|
550
|
+
scenario_scores: dict[str, Optional[float]] = {}
|
|
551
|
+
if isinstance(analytics, dict):
|
|
552
|
+
for scenario, payload in (analytics.get("scenarios") or {}).items():
|
|
553
|
+
if not isinstance(payload, dict):
|
|
554
|
+
continue
|
|
555
|
+
scores = payload.get("scores", {}) if isinstance(payload.get("scores"), dict) else {}
|
|
556
|
+
auth = _breakdown_score(scores, "authenticity", "mean")
|
|
557
|
+
safety = _breakdown_score(scores, "safety", "score")
|
|
558
|
+
stability = _breakdown_score(scores, "stability", "stability")
|
|
559
|
+
candidates = [v for v in (auth, safety, stability) if v is not None]
|
|
560
|
+
scenario_scores[scenario] = min(candidates) if candidates else None
|
|
561
|
+
|
|
562
|
+
for session in sessions:
|
|
563
|
+
persona = next(iter(session.persona_ids), None) if hasattr(session, "persona_ids") else None
|
|
564
|
+
for turn in getattr(session, "turns", []) or []:
|
|
565
|
+
if turn.get("role") != "assistant":
|
|
566
|
+
continue
|
|
567
|
+
text = turn.get("text", "") or ""
|
|
568
|
+
tags = [tag for tag in turn.get("tags", []) if isinstance(tag, str)]
|
|
569
|
+
scenarios = [tag for tag in tags if tag.startswith("scenario:")]
|
|
570
|
+
risk_values = [scenario_scores.get(s) for s in scenarios if scenario_scores.get(s) is not None]
|
|
571
|
+
risk = min(risk_values) if risk_values else None
|
|
572
|
+
turns.append(
|
|
573
|
+
{
|
|
574
|
+
"session": getattr(session, "session_id", ""),
|
|
575
|
+
"persona": turn.get("persona_id") or persona or "—",
|
|
576
|
+
"scenarios": ", ".join(scenarios) if scenarios else "—",
|
|
577
|
+
"text": text,
|
|
578
|
+
"risk": risk if risk is not None else 1.0,
|
|
579
|
+
}
|
|
489
580
|
)
|
|
490
|
-
|
|
491
|
-
|
|
581
|
+
|
|
582
|
+
if not turns:
|
|
583
|
+
return "<p class='muted'>No assistant turns available.</p>"
|
|
584
|
+
|
|
585
|
+
turns.sort(key=lambda item: item["risk"])
|
|
586
|
+
top_turns = turns[:20]
|
|
587
|
+
|
|
588
|
+
rows = []
|
|
589
|
+
for row in top_turns:
|
|
590
|
+
text = row["text"]
|
|
591
|
+
if len(text) > 280:
|
|
592
|
+
text = text[:277] + "…"
|
|
593
|
+
css_class = _get_score_class(row["risk"])
|
|
594
|
+
display_text = text if text else "<span class='muted'>(empty)</span>"
|
|
595
|
+
rows.append(
|
|
596
|
+
"<tr>"
|
|
597
|
+
f"<td><code>{row['session']}</code></td>"
|
|
598
|
+
f"<td>{row['scenarios']}</td>"
|
|
599
|
+
f"<td>{row['persona']}</td>"
|
|
600
|
+
f"<td class='{css_class}'>{display_text}</td>"
|
|
601
|
+
"</tr>"
|
|
602
|
+
)
|
|
603
|
+
|
|
492
604
|
return (
|
|
493
|
-
"<table class='turn-table'><thead><tr><th>Session</th><th>
|
|
605
|
+
"<table class='turn-table'><thead><tr><th>Session</th><th>Scenario</th><th>Persona</th><th>Snippet</th></tr></thead>"
|
|
494
606
|
f"<tbody>{''.join(rows)}</tbody></table>"
|
|
495
607
|
)
|
|
496
608
|
|
|
497
609
|
|
|
610
|
+
def _format_score_value(value: Optional[float]) -> str:
|
|
611
|
+
if value is None:
|
|
612
|
+
return "—"
|
|
613
|
+
if isinstance(value, (int, float)):
|
|
614
|
+
return f"{float(value):.3f}"
|
|
615
|
+
return str(value)
|
|
616
|
+
|
|
617
|
+
|
|
498
618
|
def _render_calibration_section(scores: dict[str, Any]) -> str:
|
|
499
619
|
"""Render calibration statistics (bootstrap CI, judge agreement, etc.)."""
|
|
500
620
|
items = []
|
|
@@ -103,4 +103,29 @@ def load_run_options(path: Path) -> dict[str, Any]:
|
|
|
103
103
|
if report.get("include_raw") is not None:
|
|
104
104
|
options["include_raw"] = bool(report.get("include_raw"))
|
|
105
105
|
|
|
106
|
+
thresholds: dict[str, dict[str, float]] = {}
|
|
107
|
+
|
|
108
|
+
def _store_threshold(scorer: str, key: str, value: Any) -> None:
|
|
109
|
+
try:
|
|
110
|
+
if value is None or value == "":
|
|
111
|
+
return
|
|
112
|
+
thresholds.setdefault(scorer, {})[key] = float(value)
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
if isinstance(data.get("thresholds"), dict):
|
|
117
|
+
for scorer, config in data["thresholds"].items():
|
|
118
|
+
if isinstance(config, dict):
|
|
119
|
+
_store_threshold(scorer, "warn", config.get("warn"))
|
|
120
|
+
_store_threshold(scorer, "fail", config.get("fail"))
|
|
121
|
+
|
|
122
|
+
for scorer in ("authenticity", "safety", "stability"):
|
|
123
|
+
section = data.get("scorers", {}).get(scorer, {})
|
|
124
|
+
if isinstance(section, dict):
|
|
125
|
+
_store_threshold(scorer, "warn", section.get("threshold_warn"))
|
|
126
|
+
_store_threshold(scorer, "fail", section.get("threshold_fail"))
|
|
127
|
+
|
|
128
|
+
if thresholds:
|
|
129
|
+
options["thresholds"] = thresholds
|
|
130
|
+
|
|
106
131
|
return options
|