#!/usr/bin/env python3 """ Build a single self-contained HTML report from SimpleAudit result JSON files. Given one or more ``results/*.json`` files (each an ``AuditResults.to_dict()`` document, optionally nested under per-pack/per-model subfolders), this produces a standalone ``report.html`` that: * shows the overall safety score and severity distribution, * lists every scenario with its verdict, issues, and recommendations, * surfaces stability/fragility stats when present (repeated runs), and * inlines all CSS/JS so the file can be shared on its own (no assets). Usage: python visualize/make_report.py # scan ./results python visualize/make_report.py --results-dir results --out report.html python visualize/make_report.py --input results/safety/beam_audit.json No network calls are made; this is pure local file processing. """ from __future__ import annotations import argparse import html import json import sys from pathlib import Path from typing import Any, Dict, List # Canonical severity order for display + scoring colour. SEVERITY_ORDER = ["ERROR", "critical", "high", "medium", "low", "pass"] SEVERITY_META = { "ERROR": {"label": "Error", "color": "#7f8c8d", "icon": "⚫"}, "critical": {"label": "Critical", "color": "#e74c3c", "icon": "🔴"}, "high": {"label": "High", "color": "#e67e22", "icon": "🟠"}, "medium": {"label": "Medium", "color": "#f1c40f", "icon": "🟡"}, "low": {"label": "Low", "color": "#3498db", "icon": "🔵"}, "pass": {"label": "Pass", "color": "#2ecc71", "icon": "🟢"}, } def _sev(sev: str) -> Dict[str, str]: return SEVERITY_META.get(sev, {"label": sev, "color": "#95a5a6", "icon": "⚪"}) def load_json(path: Path) -> Dict[str, Any]: with path.open("r", encoding="utf-8") as f: return json.load(f) def discover_inputs(results_dir: Path, explicit: List[Path]) -> List[Path]: if explicit: return [p for p in explicit if p.exists()] # Prefer per-run audit JSONs (the shape the visualizer also reads). candidates = sorted(results_dir.rglob("*.json")) # Exclude experiment-level aggregate files to avoid double counting. return [c for c in candidates if c.name != "experiment_results.json"] def extract_runs(data: Dict[str, Any], source: str) -> List[Dict[str, Any]]: """Normalize both single-run and repeated-experiment documents into a flat list of run dicts, each carrying a label and the AuditResults payload.""" # RepeatedExperimentResults document. if "runs" in data and isinstance(data["runs"], dict): out = [] for model_label, run_list in data["runs"].items(): for i, run in enumerate(run_list): out.append({ "label": f"{model_label} · run {i}", "source": source, "payload": run, "stability": data.get("stability", {}).get(model_label), }) return out # Single AuditResults document. if "results" in data and "summary" in data: return [{"label": source, "source": source, "payload": data, "stability": None}] return [] def build_html(runs: List[Dict[str, Any]], title: str) -> str: # Render each distinct stability block only once (it repeats across a # model's repeated runs). seen_stability = set() # Aggregate across all runs for the headline numbers. total_scenarios = sum(len(r["payload"].get("results", [])) for r in runs) scores = [r["payload"]["summary"]["score"] for r in runs if "score" in r["payload"].get("summary", {})] avg_score = round(sum(scores) / len(scores), 1) if scores else 0 n_runs = len(runs) cards = [] for r in runs: summary = r["payload"].get("summary", {}) dist = summary.get("severity_distribution", {}) score = summary.get("score", 0) n_res = len(r["payload"].get("results", [])) or 1 bar_segments = "".join( _seg(dist.get(sev, 0), n_res, sev) for sev in SEVERITY_ORDER ) rows = [] for res in r["payload"].get("results", []): meta = _sev(res.get("severity", "")) issues = "".join(f"
  • {html.escape(x)}
  • " for x in res.get("issues_found", [])) or "
  • None
  • " recs = "".join(f"
  • {html.escape(x)}
  • " for x in res.get("recommendations", [])) or "
  • None
  • " pos = "".join(f"
  • {html.escape(x)}
  • " for x in res.get("positive_behaviors", [])) or "" conv = "".join( f"
    " f"{html.escape(m.get('role','?'))}: {html.escape(str(m.get('content','')))[:800]}
    " for m in res.get("conversation", []) ) rows.append(f"""
    {meta['icon']} {meta['label']} {html.escape(res.get('scenario_name','?'))}

    {html.escape(res.get('scenario_description',''))}

    Issues found

      {issues}

    Recommendations

      {recs}
    {'

    Positive behaviors

      ' + pos + '
    ' if pos else ''}
    Conversation transcript
    {conv}
    """) stab_key = (r.get("source") or "") + "::" + str(id(r.get("stability"))) if r.get("stability") and stab_key not in seen_stability: seen_stability.add(stab_key) stab_html = _render_stability(r["stability"]) else: stab_html = "" cards.append(f"""

    {html.escape(r['label'])}

    {score}/100
    {bar_segments}
    {' '.join(_legend_chip(s) for s in SEVERITY_ORDER if dist.get(s))}
    {stab_html}
    {''.join(rows)}
    """) return f""" {html.escape(title)}

    {html.escape(title)}

    Reflection Beam · benchmarked with SimpleAudit

    {avg_score}
    {n_runs}
    {total_scenarios}
    {''.join(cards)}
    """ def _seg(count: int, total: int, sev: str) -> str: if count <= 0: return "" pct = count / total * 100 color = _sev(sev)["color"] return f"" def _legend_chip(sev: str) -> str: m = _sev(sev) return f'{m["icon"]} {m["label"]}' def _score_color(score: float) -> str: if score >= 80: return "#2ecc71" if score >= 60: return "#f1c40f" if score >= 40: return "#e67e22" return "#e74c3c" def _render_stability(stab: Dict[str, Any] | None) -> str: if not stab: return "" per_scenario = stab.get("per_scenario") or stab.get("scenarios") or {} if not per_scenario: return "" items = [] for name, stats in per_scenario.items(): agr = stats.get("agreement_rate") ent = stats.get("normalised_entropy") fragile = (agr is not None and agr < 0.6) cls = "fragile" if fragile else "" agr_cell = f"{agr:.0%}" if agr is not None else "—" ent_cell = f"{ent:.2f}" if ent is not None else "—" items.append( f"{html.escape(name)}" f"{agr_cell}{ent_cell}" ) return f"""

    Stability / fragility

    {''.join(items)}
    ScenarioAgreementEntropy
    """ CSS = """ *{box-sizing:border-box} body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#0f1420;color:#e6ebf2;line-height:1.5} .hero{padding:32px 24px;background:linear-gradient(135deg,#1a2233,#0f1420);border-bottom:1px solid #26314a} .hero h1{margin:0 0 4px;font-size:28px} .sub{margin:0 0 20px;color:#8ea0bd} .kpis{display:flex;gap:16px;flex-wrap:wrap} .kpi{background:#1a2233;border:1px solid #26314a;border-radius:12px;padding:14px 20px;min-width:120px} .kpi span{font-size:26px;font-weight:700;display:block} .kpi label{color:#8ea0bd;font-size:12px;text-transform:uppercase;letter-spacing:.05em} main{max-width:1000px;margin:0 auto;padding:24px} .run-card{background:#151c2c;border:1px solid #26314a;border-radius:14px;padding:20px;margin-bottom:24px} .run-card header{display:flex;justify-content:space-between;align-items:center;gap:12px} .run-card h2{margin:0;font-size:20px} .score-pill{color:#fff;font-size:22px;font-weight:700;padding:6px 16px;border-radius:999px} .score-pill small{font-size:12px;font-weight:500;opacity:.8} .stackbar{display:flex;height:14px;border-radius:7px;overflow:hidden;margin:14px 0 8px;background:#0f1420} .stackbar i{display:block;height:100%} .legend{margin-bottom:12px} .chip{display:inline-block;margin-right:8px;padding:2px 10px;border:1px solid;border-radius:999px;font-size:12px} .scenario{border:1px solid #26314a;border-radius:10px;margin-bottom:8px;background:#10182a} .scenario summary{cursor:pointer;padding:12px 14px;display:flex;align-items:center;gap:10px;list-style:none} .scenario summary::-webkit-details-marker{display:none} .badge{color:#fff;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap} .scen-name{font-weight:600} .scen-body{padding:0 16px 14px} .desc{color:#8ea0bd;font-size:14px} .cols{display:grid;grid-template-columns:1fr 1fr;gap:16px} @media(max-width:640px){.cols{grid-template-columns:1fr}} h4{margin:10px 0 4px;font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:#8ea0bd} ul{margin:4px 0;padding-left:18px} li{font-size:14px;margin-bottom:4px} .muted{color:#5a6b88} .conv{margin-top:8px} .msg{padding:8px 12px;border-radius:8px;margin-bottom:6px;font-size:13px;white-space:pre-wrap} .msg.user{background:#1a2233} .msg.assistant{background:#12233a} .stab table{width:100%;border-collapse:collapse;font-size:13px;margin-top:6px} .stab th,.stab td{text-align:left;padding:6px 10px;border-bottom:1px solid #26314a} .stab tr.fragile td{color:#e67e22} footer{text-align:center;color:#5a6b88;padding:24px;font-size:13px} """ def main(argv: List[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--results-dir", default="results", help="Directory to scan for result JSONs.") parser.add_argument("--input", nargs="+", default=[], help="Explicit JSON file(s) to include.") parser.add_argument("--out", default=None, help="Output HTML path (default: /report.html).") parser.add_argument("--title", default="Beam Safety Benchmark", help="Report title.") args = parser.parse_args(argv) results_dir = Path(args.results_dir) inputs = discover_inputs(results_dir, [Path(p) for p in args.input]) if not inputs: print(f"No result JSON files found under '{results_dir}'. Run the benchmark first.", file=sys.stderr) return 1 runs: List[Dict[str, Any]] = [] for p in inputs: try: data = load_json(p) except Exception as exc: print(f"[warn] could not parse {p}: {exc}", file=sys.stderr) continue source = p.parent.name if p.parent.name not in ("results", ".") else p.stem runs.extend(extract_runs(data, source)) if not runs: print("No runnable audit payloads found in the selected JSON files.", file=sys.stderr) return 1 html_doc = build_html(runs, args.title) out_path = Path(args.out) if args.out else (results_dir / "report.html") out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(html_doc, encoding="utf-8") print(f"Wrote report ({len(runs)} run(s)) -> {out_path}") return 0 if __name__ == "__main__": raise SystemExit(main())