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.
Files changed (64) hide show
  1. {alignmenter-0.0.4/src/alignmenter.egg-info → alignmenter-0.0.8}/PKG-INFO +5 -2
  2. {alignmenter-0.0.4 → alignmenter-0.0.8}/README.md +4 -1
  3. {alignmenter-0.0.4 → alignmenter-0.0.8}/pyproject.toml +2 -2
  4. alignmenter-0.0.8/src/alignmenter/__init__.py +30 -0
  5. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/cli.py +120 -8
  6. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/reporting/html.py +159 -39
  7. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/run_config.py +25 -0
  8. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/runner.py +172 -7
  9. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/authenticity.py +12 -5
  10. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/sanitize_dataset.py +76 -51
  11. {alignmenter-0.0.4 → alignmenter-0.0.8/src/alignmenter.egg-info}/PKG-INFO +5 -2
  12. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_helpers.py +66 -0
  13. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_runner.py +31 -0
  14. alignmenter-0.0.4/src/alignmenter/__init__.py +0 -14
  15. {alignmenter-0.0.4 → alignmenter-0.0.8}/LICENSE +0 -0
  16. {alignmenter-0.0.4 → alignmenter-0.0.8}/setup.cfg +0 -0
  17. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/config.py +0 -0
  18. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/demo_config.yaml +0 -0
  19. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/judges/safety_prompt.txt +0 -0
  20. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/persona/default.yaml +0 -0
  21. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/run.yaml +0 -0
  22. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/configs/safety_keywords.yaml +0 -0
  23. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/data/datasets/demo_conversations.jsonl +0 -0
  24. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/__init__.py +0 -0
  25. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/anthropic.py +0 -0
  26. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/base.py +0 -0
  27. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/classifiers.py +0 -0
  28. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/embeddings.py +0 -0
  29. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/judges.py +0 -0
  30. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/local.py +0 -0
  31. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/providers/openai.py +0 -0
  32. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/reporting/__init__.py +0 -0
  33. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/reporting/json_out.py +0 -0
  34. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/__init__.py +0 -0
  35. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/safety.py +0 -0
  36. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scorers/stability.py +0 -0
  37. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/__init__.py +0 -0
  38. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/bootstrap_dataset.py +0 -0
  39. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/calibrate_persona.py +0 -0
  40. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/scripts/run_openai_demo.py +0 -0
  41. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/__init__.py +0 -0
  42. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/io.py +0 -0
  43. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/tokens.py +0 -0
  44. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter/utils/yaml.py +0 -0
  45. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/SOURCES.txt +0 -0
  46. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/dependency_links.txt +0 -0
  47. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/entry_points.txt +0 -0
  48. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/requires.txt +0 -0
  49. {alignmenter-0.0.4 → alignmenter-0.0.8}/src/alignmenter.egg-info/top_level.txt +0 -0
  50. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_calibrate_persona.py +0 -0
  51. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_errors.py +0 -0
  52. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_import.py +0 -0
  53. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_init.py +0 -0
  54. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_cli_run_config.py +0 -0
  55. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_config.py +0 -0
  56. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_offline_safety.py +0 -0
  57. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_persona_gpt.py +0 -0
  58. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_provider_local.py +0 -0
  59. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_provider_openai.py +0 -0
  60. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_providers.py +0 -0
  61. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_run_config_loader.py +0 -0
  62. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_run_openai_demo.py +0 -0
  63. {alignmenter-0.0.4 → alignmenter-0.0.8}/tests/test_scorers.py +0 -0
  64. {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.4
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="../assets/alignmenter-banner.png" alt="Alignmenter" width="800">
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="../assets/alignmenter-banner.png" alt="Alignmenter" width="800">
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.4"
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", 'content-type' = "text/markdown"}
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
- _print_run_summary(run_dir)
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 line:
946
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
897
947
  continue
898
- key, value = line.split("=", 1)
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(run_dir: Path) -> None:
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
- line = f"✓ {label}: {_format_score_value(value)}"
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
- typer.echo(line)
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-Level Explorer</h2>
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
- turn_preview = _render_turn_preview(sessions)
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
- grade_class = _get_grade_class(primary_val)
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 &lt; {warn_threshold}")
395
+ if fail_threshold is not None:
396
+ details.append(f"Fail &lt; {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></td>'
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 _render_turn_preview(sessions: list) -> str:
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 session in sessions[:3]:
462
- turns = getattr(session, "turns", None)
463
- if turns is None and hasattr(session, "get"):
464
- turns = session.get("turns", [])
465
- if turns is None:
466
- turns = []
467
- for turn in turns[:4]:
468
- text = turn.get("text", "")
469
- if len(text) > 160:
470
- text = text[:157] + "…"
471
- if hasattr(session, "session_id"):
472
- session_id = getattr(session, "session_id")
473
- elif hasattr(session, "get"):
474
- session_id = session.get("session_id", "")
475
- else:
476
- session_id = ""
477
- rows.append(
478
- """
479
- <tr>
480
- <td><code>{session_id}</code></td>
481
- <td>{role}</td>
482
- <td>{text}</td>
483
- </tr>
484
- """.format(
485
- session_id=session_id,
486
- role=turn.get("role", ""),
487
- text=text or "<span class='muted'>(empty)</span>",
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
- if not rows:
491
- return "<p class='muted'>No turn data available.</p>"
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>Role</th><th>Text</th></tr></thead>"
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