Files
modelTesting/generate_dashboard.py
T
adminandClaude f0281f2878 Initial benchmark suite: 8 graded models + cyberpunk dashboard generator
- prompts/: LFU cache exam + 5-pillar grading rubric
- outputs/: 8 model .py outputs (local + cloud baseline)
- data/benchmark_history.json: graded results (scores, metrics, bugs, patches)
- generate_dashboard.py: builds dashboard.html + pages/*.html from JSON
- Dockerfile + DEPLOY.md: Gitea→Coolify deploy (build-step, nginx static)
- .gitignore: generated HTML excluded (built on deploy)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 14:00:40 -07:00

404 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Generator: builds dashboard.html + pages/<slug>..html from data/benchmark_history.json.
Re-run after each grading batch to regenerate everything.
Cyberpunk-terminal aesthetic. Pure stdlib + Chart.js via CDN.
"""
import json, html, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, "data", "benchmark_history.json")
OUT_DASH = os.path.join(HERE, "dashboard.html")
PAGES_DIR = os.path.join(HERE, "pages")
PILLARS = ["complexity", "concurrency", "isolation", "memory_edge_cases", "test_integrity"]
PILLAR_LABELS = {
"complexity": "Complexity (O(1))",
"concurrency": "Concurrency / Races",
"isolation": "Tx Isolation",
"memory_edge_cases": "Memory & Edges",
"test_integrity": "Test Integrity",
}
# ---- cyberpunk palette ----
NEON_CYAN = "#00ffc8"
NEON_MAG = "#ff2bd6"
NEON_LIME = "#b6ff3a"
NEON_AMBER= "#ffb000"
NEON_RED = "#ff3b5c"
BG = "#0a0a0f"
PANEL = "#101018"
INK = "#d7e0e6" # body text
INK_DIM = "#7a8590" # muted
def verdict_meta(v):
return {
"Production Ready": (NEON_LIME, "PROD"),
"Minor Logic Flaws": (NEON_AMBER, "FLAWS"),
"Critical Bugs": (NEON_RED, "CRIT"),
"Broken / Unusable": (NEON_RED, "DEAD"),
}.get(v, (INK_DIM, "???"))
def esc(s):
return html.escape(str(s)) if s is not None else ""
def speed_str(m):
if m.get("tok_sec") is None:
return "N/A"
return f"{m['tok_sec']:.1f}"
# series colors for charts (cycles through neon accents)
SERIES = [NEON_CYAN, NEON_MAG, NEON_LIME, NEON_AMBER, "#5b8cff", "#c084fc", "#34d399", "#f472b6"]
# shared <head> CSS — embedded so each page is standalone
def shared_css():
return """
:root{
--bg:#0a0a0f; --panel:#101018; --panel2:#14141f; --ink:#d7e0e6; --dim:#7a8590;
--cyan:#00ffc8; --mag:#ff2bd6; --lime:#b6ff3a; --amber:#ffb000; --red:#ff3b5c; --blue:#5b8cff;
--grid: rgba(0,255,200,0.04);
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--bg); color:var(--ink);
font-family:'Fira Sans',system-ui,-apple-system,sans-serif;
line-height:1.6; min-height:100vh;
/* scanline + grid texture overlay */
background-image:
repeating-linear-gradient(0deg, rgba(0,255,200,0.025) 0px, rgba(0,255,200,0.025) 1px, transparent 1px, transparent 3px),
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
radial-gradient(1200px 600px at 70% -10%, rgba(91,139,255,0.10), transparent 60%),
radial-gradient(900px 500px at 10% 110%, rgba(255,43,214,0.08), transparent 60%);
background-size: 3px 3px, 44px 44px, 44px 44px, 100% 100%, 100% 100%;
background-attachment: fixed;
}
mono,.mono,h1,h2,h3,.hud,th,.code,.badge,.stat-num,.lead-model,.verdict-chip{font-family:'Fira Code',ui-monospace,'SF Mono',Menlo,monospace}
a{color:var(--cyan);text-decoration:none}
a:hover{text-shadow:0 0 8px var(--cyan)}
.wrap{max-width:1180px;margin:0 auto;padding:28px 20px 80px}
header.hud-bar{
border:1px solid rgba(0,255,200,0.25);
background:linear-gradient(180deg,rgba(0,255,200,0.06),rgba(0,0,0,0));
box-shadow:0 0 24px rgba(0,255,200,0.08), inset 0 0 30px rgba(0,0,0,0.5);
border-radius:6px; padding:18px 22px; margin-bottom:24px;
position:relative; overflow:hidden;
}
header.hud-bar::before{
content:""; position:absolute; inset:0;
background:repeating-linear-gradient(90deg, transparent 0 7px, rgba(0,255,200,0.03) 7px 8px);
pointer-events:none;
}
.kicker{color:var(--cyan); letter-spacing:.32em; font-size:.72rem; text-transform:uppercase; text-shadow:0 0 10px rgba(0,255,200,0.5)}
h1{font-size:1.5rem; margin:.3em 0 0; letter-spacing:.02em; text-shadow:0 0 16px rgba(0,255,200,0.35)}
.subtitle{color:var(--dim); font-size:.9rem; margin-top:.35em}
.stats{display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:18px 0 26px}
.stat{background:var(--panel); border:1px solid rgba(255,255,255,0.06); border-radius:6px; padding:14px 16px; position:relative; overflow:hidden}
.stat::after{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--cyan);box-shadow:0 0 12px var(--cyan)}
.stat .lbl{color:var(--dim);font-size:.68rem;letter-spacing:.18em;text-transform:uppercase}
.stat .num{font-size:1.6rem;margin-top:4px;color:var(--ink)}
.grid2{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;margin-bottom:26px}
.panel{background:var(--panel);border:1px solid rgba(255,255,255,0.07);border-radius:8px;padding:18px}
.panel h2{font-size:.95rem;letter-spacing:.12em;text-transform:uppercase;color:var(--cyan);margin:0 0 14px;text-shadow:0 0 10px rgba(0,255,200,0.35)}
.chart-box{position:relative;height:340px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}.stats{grid-template-columns:repeat(2,1fr)}}
/* leaderboard */
table{width:100%;border-collapse:collapse;font-size:.86rem}
thead th{text-align:left;color:var(--dim);font-size:.66rem;letter-spacing:.16em;text-transform:uppercase;border-bottom:1px solid rgba(0,255,200,0.2);padding:8px 10px}
tbody td{padding:11px 10px;border-bottom:1px solid rgba(255,255,255,0.05);vertical-align:middle}
tbody tr{transition:background .15s, box-shadow .15s}
tbody tr:hover{background:rgba(0,255,200,0.05);box-shadow:inset 0 0 0 1px rgba(0,255,200,0.25)}
.rank{color:var(--dim);width:34px}
.rank.top{color:var(--lime);text-shadow:0 0 8px var(--lime)}
.model-name{color:var(--ink)}
.quant{color:var(--dim);font-size:.78rem}
.badge{display:inline-block;padding:2px 9px;border-radius:3px;font-size:.68rem;letter-spacing:.1em;border:1px solid currentColor}
.btn{display:inline-block;padding:5px 12px;border:1px solid var(--cyan);color:var(--cyan);border-radius:4px;font-size:.74rem;letter-spacing:.08em;cursor:pointer;transition:all .18s}
.btn:hover{background:rgba(0,255,200,0.12);box-shadow:0 0 14px rgba(0,255,200,0.4)}
.bar-cell{display:flex;align-items:center;gap:10px}
.score-bar{flex:1;height:8px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden;min-width:70px}
.score-bar > i{display:block;height:100%;border-radius:2px;transition:width .4s, box-shadow .2s}
tr:hover .score-bar > i{box-shadow:0 0 12px currentColor}
.caveat{color:var(--amber);font-size:.72rem}
.cloud-tag{color:var(--blue);font-size:.7rem;border:1px solid rgba(91,139,255,.4);padding:1px 6px;border-radius:3px;margin-left:6px}
footer{color:var(--dim);font-size:.74rem;margin-top:40px;border-top:1px solid rgba(255,255,255,0.06);padding-top:14px;text-align:center}
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
"""
def head_html(title):
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{esc(title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>{shared_css()}</style>
</head>
<body><div class="wrap">
"""
FOOT = """</div>
<script>if(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches&&window.Chart){Chart.defaults.animation=false;Chart.defaults.animations.colors=false;Chart.defaults.animations.numbers=false;}</script>
</body></html>"""
def render_dashboard(data):
models = sorted(data["models"], key=lambda m: -m["total_score"])
n = len(models)
avg = sum(m["total_score"] for m in models) / n if n else 0
top = models[0] if models else None
prod = sum(1 for m in models if m["verdict"] == "Production Ready")
# chart datasets (exclude cloud model from speed bars — has no tok/sec)
local = [m for m in models if m.get("tok_sec") is not None]
local_sorted = sorted(local, key=lambda m: -m["tok_sec"])
radar_models = [m for m in models if m.get("tok_sec") is not None or True][:3] # top 3 by score already
rows = []
for i, m in enumerate(models, 1):
col, chip = verdict_meta(m["verdict"])
caveat = ""
if m.get("speed_caveat"):
caveat = '<div class="caveat">⚠ speed suspect</div>'
cloud = '<span class="cloud-tag">CLOUD</span>' if m.get("format") == "cloud" else ""
bar_color = col
rows.append(f"""<tr>
<td class="rank {'top' if i<=3 else ''}">#{i}</td>
<td><div class="model-name">{esc(m['model_name'])}{cloud}</div><div class="quant">{esc(m['quant'])}</div>{caveat}</td>
<td class="mono">{speed_str(m)} <span style="color:var(--dim);font-size:.72rem">t/s</span></td>
<td><div class="bar-cell"><span class="mono" style="width:34px;color:{col}">{m['total_score']}</span>
<span class="score-bar"><i style="width:{m['total_score']}%;background:{bar_color};color:{bar_color}"></i></span></div></td>
<td><span class="badge verdict-chip" style="color:{col}">{chip}</span></td>
<td style="color:var(--dim);font-size:.8rem">{esc(m['best_for'])[:70]}…</td>
<td><a class="btn" href="pages/{esc(m['id'])}.html">DECODE ▸</a></td>
</tr>""")
# JSON for charts
bar_labels = json.dumps([m["model_name"].split("(")[0].strip()[:18] for m in local_sorted])
bar_speed = json.dumps([m["tok_sec"] for m in local_sorted])
bar_score = json.dumps([m["total_score"] for m in local_sorted])
radar_labels = json.dumps([PILLAR_LABELS[p] for p in PILLARS])
radar_sets = []
for idx, m in enumerate(radar_models):
col = SERIES[idx % len(SERIES)]
radar_sets.append({
"label": m["model_name"][:24],
"data": [m["breakdown"][p] for p in PILLARS],
"borderColor": col,
"backgroundColor": col + "22",
})
radar_json = json.dumps(radar_sets)
stats = f"""
<div class="stat"><div class="lbl">Models Tested</div><div class="num mono">{n}</div></div>
<div class="stat"><div class="lbl">Top Score</div><div class="num mono" style="color:var(--lime)">{top['total_score'] if top else '—'}</div></div>
<div class="stat"><div class="lbl">Average</div><div class="num mono" style="color:var(--amber)">{avg:.1f}</div></div>
<div class="stat"><div class="lbl">Prod-Ready</div><div class="num mono" style="color:var(--cyan)">{prod}/{n}</div></div>
"""
top_name = esc(top["model_name"]) if top else "—"
body = f"""
{head_html("LLM Benchmark Suite")}
<header class="hud-bar">
<div class="kicker">▚ LOCAL LLM BENCHMARK SUITE // LFU CACHE &amp; ACID AUDIT</div>
<h1>// BENCHMARK_RESULTS <span style="color:var(--mag)">.json</span></h1>
<div class="subtitle">{n} models graded on a strict 5-pillar / 100-pt rubric · O(1) LFU + ACID transactions · M3 Max · LM Studio
&nbsp;·&nbsp; <span style="color:var(--cyan)">TOP: {top_name}</span></div>
</header>
{stats}
<div class="grid2">
<div class="panel">
<h2>▮ Score vs Throughput (tok/sec)</h2>
<div class="chart-box"><canvas id="bar"></canvas></div>
<div style="color:var(--dim);font-size:.72rem;margin-top:8px">Local models only — cloud baseline (DeepSeek) excluded from speed axis. Gemma 4 bars flagged ⚠ (GPU-offload suspect).</div>
</div>
<div class="panel">
<h2>▮ 5-Pillar Radar — Top 3</h2>
<div class="chart-box"><canvas id="radar"></canvas></div>
<div style="color:var(--dim);font-size:.72rem;margin-top:8px">Each pillar scored 020. Outer = stronger.</div>
</div>
</div>
<div class="panel" style="margin-bottom:26px">
<h2>▮ LEADERBOARD</h2>
<div style="overflow-x:auto">
<table>
<thead><tr><th>#</th><th>Model</th><th>Speed</th><th>Score</th><th>Verdict</th><th>Best For</th><th></th></tr></thead>
<tbody>{''.join(rows)}</tbody>
</table></div>
</div>
<footer>Generated from <span class="mono">data/benchmark_history.json</span> · re-run <span class="mono">generate_dashboard.py</span> to refresh · cyberpunk-terminal UI</footer>
<script>
const NEON={{cyan:'#00ffc8',mag:'#ff2bd6',lime:'#b6ff3a',amber:'#ffb000',red:'#ff3b5c',blue:'#5b8cff'}};
new Chart(document.getElementById('bar'),{{
type:'bar',
data:{{labels:{bar_labels},
datasets:[
{{label:'Score /100',data:{bar_score},backgroundColor:'rgba(0,255,200,0.85)',borderColor:NEON.cyan,borderRadius:3,yAxisID:'y'}},
{{label:'tok/sec',data:{bar_speed},backgroundColor:'rgba(255,43,214,0.7)',borderColor:NEON.mag,borderRadius:3,yAxisID:'y1'}}
]}},
options:{{maintainAspectRatio:false,responsive:true,
plugins:{{legend:{{labels:{{color:'#d7e0e6',font:{{family:'Fira Code'}}}}}}}},
scales:{{
x:{{ticks:{{color:'#7a8590',font:{{family:'Fira Code',size:10}}}},grid:{{color:'rgba(255,255,255,0.05)'}}}},
y:{{position:'left',max:100,title:{{display:true,text:'Score',color:'#00ffc8'}},ticks:{{color:'#7a8590'}},grid:{{color:'rgba(255,255,255,0.05)'}}}},
y1:{{position:'right',title:{{display:true,text:'tok/sec',color:'#ff2bd6'}},grid:{{drawOnChartArea:false}},ticks:{{color:'#7a8590'}}}}
}}}}
}});
new Chart(document.getElementById('radar'),{{
type:'radar',
data:{{labels:{radar_labels},datasets:{radar_json}}},
options:{{maintainAspectRatio:false,responsive:true,
plugins:{{legend:{{labels:{{color:'#d7e0e6',font:{{family:'Fira Code',size:11}}}}}}}},
scales:{{r:{{min:0,max:20,
angleLines:{{color:'rgba(0,255,200,0.15)'}},
grid:{{color:'rgba(0,255,200,0.12)'}},
pointLabels:{{color:'#d7e0e6',font:{{family:'Fira Code',size:10}}}},
ticks:{{color:'#7a8590',backdropColor:'transparent',stepSize:5}}
}}}}
}}
}});
</script>
{FOOT}"""
return body
def render_detail(m, data):
col, chip = verdict_meta(m["verdict"])
# derive "what went right" from high pillars, "wrong" from low + critical_bugs
bd = m["breakdown"]
ranked = sorted(PILLARS, key=lambda p: -bd[p])
rights = [f"{PILLAR_LABELS[p]} ({bd[p]}/20)" for p in ranked if bd[p] >= 16]
wrongs_pillars = [f"{PILLAR_LABELS[p]} ({bd[p]}/20)" for p in ranked if bd[p] <= 13]
pillar_bars = ""
for p in PILLARS:
v = bd[p]
c = NEON_LIME if v >= 17 else (NEON_AMBER if v >= 13 else NEON_RED)
pillar_bars += f"""
<div class="prow">
<div class="plabel">{PILLAR_LABELS[p]}</div>
<div class="pbar"><i style="width:{v/20*100}%;background:{c};color:{c}"></i></div>
<div class="pval mono" style="color:{c}">{v}<span style="color:var(--dim)">/20</span></div>
</div>"""
bugs_html = "".join(f"<li>{esc(b)}</li>" for b in m.get("critical_bugs", []))
patch = m.get("patch_code", "")
metrics_block = ""
if m.get("tok_sec") is not None:
metrics_block = f"""
<div class="mini"><span class="mlbl">tok/sec</span><span class="mval mono" style="color:var(--mag)">{m['tok_sec']:.2f}</span></div>
<div class="mini"><span class="mlbl">tokens</span><span class="mval mono">{m.get('total_tokens') or '—'}</span></div>
<div class="mini"><span class="mlbl">TTFT</span><span class="mval mono">{m.get('ttft_sec'):.2f}s</span> </div>"""
else:
metrics_block = '<div class="mini" style="grid-column:1/-1"><span class="mlbl">runtime</span><span class="mval mono" style="color:var(--blue)">CLOUD — not measured</span></div>'
caveat_html = f'<div class="caveat-box">⚠ SPEED CAVEAT: {esc(m["speed_caveat"])}</div>' if m.get("speed_caveat") else ""
tests_html = '<span class="ok">PASS</span>' if m.get("tests_pass") else '<span class="bad">CRASH</span>'
cloud_tag = '<span class="cloud-tag">CLOUD BASELINE</span>' if m.get("format") == "cloud" else ""
rights_html = "".join(f"<li>{esc(r)}</li>" for r in rights) or "<li class='dim'>No pillar reached 16+ — no standout strengths.</li>"
wrongs_html = "".join(f"<li>{esc(w)}</li>" for w in wrongs_pillars) or "<li class='dim'>No pillar fell below 14 — solid across the board.</li>"
extra_css = """
.mini-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:14px 0}
.mini{background:var(--panel2);border:1px solid rgba(255,255,255,0.06);border-radius:5px;padding:10px 12px}
.mlbl{display:block;color:var(--dim);font-size:.62rem;letter-spacing:.14em;text-transform:uppercase}
.mval{display:block;font-size:1.15rem;margin-top:3px}
.caveat-box{background:rgba(255,176,0,0.08);border:1px solid rgba(255,176,0,0.4);color:var(--amber);padding:10px 14px;border-radius:5px;font-size:.82rem;margin:12px 0}
.pillar-list{margin-top:8px}
.prow{display:grid;grid-template-columns:170px 1fr 56px;align-items:center;gap:12px;margin:9px 0}
.plabel{color:var(--ink);font-size:.84rem}
.pbar{height:9px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden}
.pbar > i{display:block;height:100%;border-radius:2px}
.pval{text-align:right;font-size:.92rem}
.ok{color:var(--lime);border:1px solid var(--lime);padding:1px 8px;border-radius:3px;font-size:.7rem}
.bad{color:var(--red);border:1px solid var(--red);padding:1px 8px;border-radius:3px;font-size:.7rem}
.section{background:var(--panel);border:1px solid rgba(255,255,255,0.07);border-radius:8px;padding:18px;margin-top:18px}
.section h2{font-size:.9rem;letter-spacing:.12em;text-transform:uppercase;color:var(--cyan);margin:0 0 12px;text-shadow:0 0 10px rgba(0,255,200,0.35)}
ul.clean{list-style:none;padding:0;margin:0}
ul.clean li{padding:7px 0 7px 18px;border-bottom:1px solid rgba(255,255,255,0.05);position:relative;font-size:.88rem}
ul.clean li::before{content:"▸";position:absolute;left:0;color:var(--cyan)}
ul.clean li.dim{color:var(--dim)}
ul.clean li.dim::before{color:var(--dim)}
.bugs li::before{content:"✗";color:var(--red)}
.rights li::before{content:"✓";color:var(--lime)}
pre.code{background:#06060a;border:1px solid rgba(0,255,200,0.2);border-radius:6px;padding:14px;overflow-x:auto;color:#cfe;font-size:.78rem;line-height:1.5;box-shadow:inset 0 0 30px rgba(0,0,0,0.6)}
pre.code::before{content:"// patch.py";display:block;color:var(--dim);font-size:.68rem;margin-bottom:8px}
.back{display:inline-block;margin-bottom:18px;font-size:.8rem}
.verdict-chip-lg{display:inline-block;padding:4px 14px;border-radius:3px;border:1px solid currentColor;font-size:.78rem;letter-spacing:.1em}
.best-box{background:linear-gradient(135deg,rgba(0,255,200,0.08),rgba(91,139,255,0.05));border:1px solid rgba(0,255,200,0.25);border-radius:8px;padding:16px 18px;font-size:.95rem;line-height:1.6}
@media(max-width:640px){.prow{grid-template-columns:1fr auto}.mini-grid{grid-template-columns:repeat(2,1fr)}}
"""
head = head_html(m["model_name"])
# inject extra css before </style>
head = head.replace("</style>", extra_css + "</style>")
return f"""
{head}
<a class="back" href="../dashboard.html">◂ BACK TO LEADERBOARD</a>
<header class="hud-bar">
<div class="kicker">▚ MODEL AUDIT // {esc(m['quant'])}</div>
<h1>// {esc(m['model_name'])} {cloud_tag}</h1>
<div style="margin-top:8px;display:flex;gap:14px;align-items:center;flex-wrap:wrap">
<span class="verdict-chip-lg" style="color:{col}">{chip}{esc(m['verdict'])}</span>
<span class="mono" style="font-size:1.5rem;color:{col}">{m['total_score']}<span style="color:var(--dim);font-size:.8rem">/100</span></span>
<span>tests: {tests_html}</span>
</div>
</header>
{caveat_html}
<div class="mini-grid">{metrics_block}</div>
<div class="section">
<h2>▮ PILLAR BREAKDOWN</h2>
<div class="pillar-list">{pillar_bars}</div>
</div>
<div class="grid2">
<div class="section" style="margin-top:0">
<h2 style="color:var(--lime);text-shadow:0 0 10px rgba(182,255,58,0.4)">✓ WENT RIGHT</h2>
<ul class="clean rights">{rights_html}</ul>
</div>
<div class="section" style="margin-top:0">
<h2 style="color:var(--red);text-shadow:0 0 10px rgba(255,59,92,0.4)">✗ WENT WRONG</h2>
<ul class="clean wrongs">{wrongs_html}</ul>
</div>
</div>
<div class="section">
<h2>▮ CRITICAL BUGS</h2>
<ul class="clean bugs">{bugs_html or '<li class="dim">None flagged.</li>'}</ul>
</div>
<div class="section">
<h2>▮ RECOMMENDED USE</h2>
<div class="best-box">{esc(m['best_for'])}</div>
</div>
<div class="section">
<h2>▮ REFACTORED PATCH</h2>
<pre class="code">{esc(patch)}</pre>
</div>
<footer>Source: <span class="mono">{esc(m['filename'])}</span> · graded {esc(m.get('timestamp','')[:10])}</footer>
</div>
{FOOT}"""
def main():
with open(DATA) as f:
data = json.load(f)
os.makedirs(PAGES_DIR, exist_ok=True)
with open(OUT_DASH, "w") as f:
f.write(render_dashboard(data))
for m in data["models"]:
with open(os.path.join(PAGES_DIR, m["id"] + ".html"), "w") as f:
f.write(render_detail(m, data))
print(f"OK: dashboard.html + {len(data['models'])} pages in pages/")
if __name__ == "__main__":
main()