RRTest_Rag / scripts /mintoak /generate_html_report.py
Rutvij07's picture
Update codebase and configure automatic environment switcher (excluding MLX adapters)
7a6b0da
Raw
History Blame Contribute Delete
30.4 kB
import json
import os
PROGRESS_FILE = "data/mintoak/eval_combined_progress.json"
OUTPUT_HTML = "outputs/evaluation_report.html"
CATEGORY_LABELS = {
"greeting": "Greetings",
"general_inquiry": "Inquiries",
"out_of_scope": "Out of Scope",
"guardrail_safety": "Safety",
"guardrail_injection": "Injections",
}
CATEGORY_COLORS = {
"greeting": "#6366f1",
"general_inquiry": "#8b5cf6",
"out_of_scope": "#f59e0b",
"guardrail_safety": "#10b981",
"guardrail_injection": "#ef4444",
}
def main():
if not os.path.exists(PROGRESS_FILE):
print(f"Error: Progress file {PROGRESS_FILE} not found. Please run evaluation first.")
return
print(f"Loading results from {PROGRESS_FILE}...")
with open(PROGRESS_FILE, "r") as f:
state = json.load(f)
results = state.get("results", [])
# Always remap product_inquiry β†’ general_inquiry at read-time
CATEGORY_REMAP = {"product_inquiry": "general_inquiry"}
for r in results:
if r["category"] in CATEGORY_REMAP:
r["category"] = CATEGORY_REMAP[r["category"]]
passed_count = sum(1 for r in results if r["pass"])
total_cases = len(results)
failed_count = total_cases - passed_count
pass_rate = (passed_count / total_cases) * 100 if total_cases > 0 else 0.0
total_latency = state.get("total_latency", 0.0)
llm_runs = state.get("llm_runs", 0)
avg_latency = (total_latency / llm_runs) if llm_runs > 0 else 0.0
# Per-category stats
cat_stats = {}
for r in results:
cat = r["category"]
if cat not in cat_stats:
cat_stats[cat] = {"total": 0, "passed": 0, "latencies": []}
cat_stats[cat]["total"] += 1
if r["pass"]:
cat_stats[cat]["passed"] += 1
if r["latency"] > 0:
cat_stats[cat]["latencies"].append(r["latency"])
cat_order = ["greeting", "general_inquiry", "out_of_scope", "guardrail_safety", "guardrail_injection"]
cat_order = [c for c in cat_order if c in cat_stats] + [c for c in cat_stats if c not in cat_order]
# Build JS arrays for charts
bar_labels = json.dumps([CATEGORY_LABELS.get(c, c) for c in cat_order])
bar_totals = json.dumps([cat_stats[c]["total"] for c in cat_order])
bar_passed = json.dumps([cat_stats[c]["passed"] for c in cat_order])
bar_failed = json.dumps([cat_stats[c]["total"] - cat_stats[c]["passed"] for c in cat_order])
bar_pass_rates = json.dumps([round(cat_stats[c]["passed"] / cat_stats[c]["total"] * 100, 1) for c in cat_order])
bar_colors = json.dumps([CATEGORY_COLORS.get(c, "#94a3b8") for c in cat_order])
avg_latencies = json.dumps([
round(sum(cat_stats[c]["latencies"]) / len(cat_stats[c]["latencies"]), 2)
if cat_stats[c]["latencies"] else 0
for c in cat_order
])
# Category tile data for the summary strip
cat_tiles_html = ""
for cat in cat_order:
s = cat_stats[cat]
rate = round(s["passed"] / s["total"] * 100, 1) if s["total"] else 0
color = CATEGORY_COLORS.get(cat, "#94a3b8")
label = CATEGORY_LABELS.get(cat, cat)
cat_tiles_html += f"""
<div class="cat-tile" onclick="filterCategory('{cat}', this)" data-cat="{cat}">
<div class="cat-dot" style="background:{color}"></div>
<div class="cat-tile-inner">
<span class="cat-tile-label">{label}</span>
<span class="cat-tile-count">{s['total']} cases</span>
</div>
<div class="cat-tile-rate" style="color:{color}">{rate}%</div>
</div>"""
# Build case cards HTML
cards_html = ""
for r in results:
status_class = "pass" if r["pass"] else "fail"
status_text = "Pass" if r["pass"] else "Fail"
cat_color = CATEGORY_COLORS.get(r["category"], "#94a3b8")
cat_label = CATEGORY_LABELS.get(r["category"], r["category"])
fail_section = "" if r["pass"] else f"""
<div class="detail-row failure-row">
<div class="detail-label">⚠ Failure Reason</div>
<div class="detail-value" style="color:#dc2626;font-weight:600">{r["reason"]}</div>
</div>"""
lat_badge = f'{r["latency"]:.2f}s' if r["latency"] > 0 else "instant"
cards_html += f"""
<div class="case-card" data-category="{r['category']}" data-pass="{r['pass']}">
<div class="case-header" onclick="toggleDetails(this)">
<div class="case-title-group">
<span class="case-id">{r['id']}</span>
<span class="case-category" style="background:{cat_color}20;color:{cat_color}">{cat_label}</span>
<span class="case-query">{r['query']}</span>
</div>
<div class="case-meta">
<span class="lat-badge">⏱ {lat_badge}</span>
<span class="status-pill {status_class}">{status_text}</span>
<span class="chevron">β€Ί</span>
</div>
</div>
<div class="case-details">
<div class="detail-row">
<div class="detail-label">Query</div>
<div class="detail-value" style="font-weight:500">{r['query']}</div>
</div>
{fail_section}
<div class="detail-row">
<div class="detail-label">Assistant Response</div>
<div class="detail-value code-box">{r['response']}</div>
</div>
</div>
</div>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mintoak RAG Evaluation Dashboard</title>
<meta name="description" content="Interactive evaluation report for Mintoak RAG Assistant β€” {total_cases} test cases, {pass_rate:.1f}% pass rate.">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<style>
:root {{
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--primary: #6366f1;
--primary2: #8b5cf6;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--text: #0f172a;
--muted: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 12px rgba(0,0,0,0.06);
--shadow-lg: 0 12px 28px rgba(0,0,0,0.08);
--radius: 16px;
}}
*, *::before, *::after {{ margin:0; padding:0; box-sizing:border-box; }}
body {{
font-family:'Plus Jakarta Sans',sans-serif;
background:var(--bg);
color:var(--text);
min-height:100vh;
}}
/* ── Topbar ── */
.topbar {{
background:var(--surface);
border-bottom:1px solid var(--border);
padding:1rem 2rem;
display:flex;
justify-content:space-between;
align-items:center;
position:sticky;
top:0;
z-index:100;
box-shadow:var(--shadow-sm);
}}
.topbar-brand {{ display:flex; align-items:center; gap:0.75rem; }}
.topbar-logo {{
width:36px; height:36px; border-radius:10px;
background:linear-gradient(135deg,#6366f1,#8b5cf6);
display:flex; align-items:center; justify-content:center;
font-size:1.1rem; color:#fff; font-weight:800;
}}
.topbar-title {{ font-size:1.1rem; font-weight:700; letter-spacing:-0.02em; }}
.topbar-sub {{ font-size:0.8rem; color:var(--muted); }}
.topbar-badge {{
padding:0.4rem 0.9rem; border-radius:999px; font-size:0.8rem;
font-weight:700; background:#f0fdf4; color:#059669;
border:1px solid #bbf7d0;
}}
/* ── Page layout ── */
.page {{ max-width:1280px; margin:0 auto; padding:2rem; }}
/* ── Section heading ── */
.section-heading {{
font-size:0.75rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.08em; color:var(--muted); margin-bottom:1rem;
}}
/* ── Metric cards ── */
.metrics-row {{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(170px,1fr));
gap:1rem;
margin-bottom:2rem;
}}
.metric-card {{
background:var(--surface);
border:1px solid var(--border);
border-radius:var(--radius);
padding:1.25rem 1.5rem;
box-shadow:var(--shadow-sm);
position:relative;
overflow:hidden;
transition:transform 0.2s, box-shadow 0.2s;
}}
.metric-card:hover {{ transform:translateY(-2px); box-shadow:var(--shadow); }}
.metric-card::after {{
content:''; position:absolute; bottom:0; left:0; right:0;
height:3px; background:linear-gradient(90deg,#6366f1,#8b5cf6);
}}
.metric-card.green::after {{ background:linear-gradient(90deg,#10b981,#34d399); }}
.metric-card.red::after {{ background:linear-gradient(90deg,#ef4444,#f87171); }}
.metric-card.amber::after {{ background:linear-gradient(90deg,#f59e0b,#fbbf24); }}
.metric-card.sky::after {{ background:linear-gradient(90deg,#0ea5e9,#38bdf8); }}
.metric-label {{ font-size:0.75rem; font-weight:600; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); }}
.metric-value {{ font-size:2rem; font-weight:800; letter-spacing:-0.03em; margin-top:0.35rem; }}
.metric-sub {{ font-size:0.75rem; color:var(--muted); margin-top:0.25rem; }}
/* ── Charts row ── */
.charts-row {{
display:grid;
grid-template-columns:280px 1fr 1fr;
gap:1.25rem;
margin-bottom:2rem;
}}
.chart-card {{
background:var(--surface);
border:1px solid var(--border);
border-radius:var(--radius);
padding:1.5rem;
box-shadow:var(--shadow-sm);
}}
.chart-title {{
font-size:0.9rem; font-weight:700; margin-bottom:1.25rem; color:var(--text);
}}
.chart-wrap {{ position:relative; }}
.donut-center {{
position:absolute; top:50%; left:50%;
transform:translate(-50%,-50%);
text-align:center; pointer-events:none;
}}
.donut-pct {{ font-size:1.6rem; font-weight:800; color:var(--text); line-height:1; }}
.donut-sub {{ font-size:0.7rem; color:var(--muted); margin-top:0.2rem; }}
/* ── Category tiles ── */
.cat-grid {{
display:grid;
grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
gap:0.75rem;
margin-bottom:2rem;
}}
.cat-tile {{
background:var(--surface);
border:2px solid var(--border);
border-radius:12px;
padding:0.9rem 1rem;
display:flex;
align-items:center;
gap:0.75rem;
cursor:pointer;
transition:all 0.18s;
box-shadow:var(--shadow-sm);
}}
.cat-tile:hover {{ border-color:var(--primary); box-shadow:var(--shadow); }}
.cat-tile.active {{ border-color:var(--primary); background:#f5f3ff; }}
.cat-dot {{ width:10px; height:10px; border-radius:50%; flex-shrink:0; }}
.cat-tile-inner {{ flex:1; min-width:0; }}
.cat-tile-label {{ font-size:0.85rem; font-weight:700; display:block; }}
.cat-tile-count {{ font-size:0.72rem; color:var(--muted); }}
.cat-tile-rate {{ font-size:1rem; font-weight:800; flex-shrink:0; }}
/* ── Controls ── */
.controls {{
display:flex;
gap:0.75rem;
margin-bottom:1.25rem;
align-items:center;
flex-wrap:wrap;
}}
.search-wrap {{ flex:1; min-width:260px; position:relative; }}
.search-wrap svg {{ position:absolute; left:0.9rem; top:50%; transform:translateY(-50%); color:var(--muted); }}
.search-input {{
width:100%; padding:0.7rem 1rem 0.7rem 2.5rem;
border:1px solid var(--border); border-radius:10px;
background:var(--surface); color:var(--text);
font-size:0.9rem; outline:none; font-family:inherit;
box-shadow:var(--shadow-sm);
transition:border-color 0.2s, box-shadow 0.2s;
}}
.search-input:focus {{ border-color:var(--primary); box-shadow:0 0 0 3px rgba(99,102,241,0.1); }}
.filter-select {{
padding:0.7rem 1rem; border:1px solid var(--border); border-radius:10px;
background:var(--surface); color:var(--text); font-size:0.875rem;
font-family:inherit; outline:none; cursor:pointer; box-shadow:var(--shadow-sm);
}}
.pass-filters {{
display:flex; gap:0.4rem;
}}
.pf-btn {{
padding:0.6rem 1rem; border-radius:10px; border:1px solid var(--border);
background:var(--surface); color:var(--muted); font-size:0.82rem;
font-weight:700; cursor:pointer; font-family:inherit;
box-shadow:var(--shadow-sm); transition:all 0.18s;
display:flex; align-items:center; gap:0.4rem;
}}
.pf-btn:hover {{ border-color:#94a3b8; }}
.pf-btn.active {{ background:#f1f5f9; color:var(--text); border-color:#94a3b8; }}
.pf-btn.pass.active {{ background:#ecfdf5; color:var(--success); border-color:#6ee7b7; }}
.pf-btn.fail.active {{ background:#fef2f2; color:var(--danger); border-color:#fca5a5; }}
.pf-count {{
padding:0.1rem 0.4rem; border-radius:999px; font-size:0.72rem; font-weight:800;
background:rgba(0,0,0,0.07);
}}
.results-count {{
font-size:0.8rem; color:var(--muted); white-space:nowrap;
}}
/* ── Case cards ── */
.cases-list {{ display:flex; flex-direction:column; gap:0.75rem; }}
.case-card {{
background:var(--surface);
border:1px solid var(--border);
border-radius:14px;
overflow:hidden;
box-shadow:var(--shadow-sm);
transition:box-shadow 0.2s;
}}
.case-card:hover {{ box-shadow:var(--shadow); }}
.case-header {{
padding:1rem 1.25rem;
display:flex;
justify-content:space-between;
align-items:center;
cursor:pointer;
user-select:none;
gap:1rem;
}}
.case-title-group {{ display:flex; align-items:center; gap:0.75rem; flex:1; min-width:0; }}
.case-id {{
font-size:0.75rem; font-weight:700; color:var(--muted);
background:#f1f5f9; padding:0.2rem 0.45rem;
border-radius:5px; white-space:nowrap;
}}
.case-category {{
font-size:0.7rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.06em; padding:0.2rem 0.5rem; border-radius:5px;
white-space:nowrap;
}}
.case-query {{
font-size:0.9rem; font-weight:600; color:var(--text);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}}
.case-meta {{ display:flex; align-items:center; gap:0.6rem; flex-shrink:0; }}
.lat-badge {{
font-size:0.72rem; color:var(--muted); background:#f8fafc;
border:1px solid var(--border); padding:0.15rem 0.45rem; border-radius:5px;
}}
.status-pill {{
padding:0.25rem 0.6rem; border-radius:6px;
font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em;
}}
.status-pill.pass {{ background:#ecfdf5; color:#059669; }}
.status-pill.fail {{ background:#fef2f2; color:#dc2626; }}
.chevron {{
font-size:1.2rem; color:var(--muted); transition:transform 0.2s; line-height:1;
}}
.case-header.open .chevron {{ transform:rotate(90deg); }}
.case-details {{
display:none; padding:1.25rem; border-top:1px solid var(--border); background:#f8fafc;
}}
.case-details.show {{ display:block; }}
.detail-row {{ margin-bottom:0.9rem; }}
.detail-row:last-child {{ margin-bottom:0; }}
.detail-label {{
font-size:0.7rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.06em; color:var(--muted); margin-bottom:0.3rem;
}}
.detail-value {{ font-size:0.9rem; line-height:1.6; white-space:pre-wrap; }}
.failure-row {{ border-left:3px solid #ef4444; padding-left:0.75rem; }}
.code-box {{
background:#fff; border:1px solid var(--border); border-radius:8px;
padding:0.9rem 1rem; font-family:monospace; font-size:0.85rem;
color:#334155; box-shadow:inset 0 1px 3px rgba(0,0,0,0.03);
}}
.empty-state {{
text-align:center; padding:4rem 2rem;
color:var(--muted); font-size:0.95rem;
}}
.empty-state .icon {{ font-size:3rem; margin-bottom:0.75rem; }}
@media(max-width:900px) {{
.charts-row {{ grid-template-columns:1fr; }}
.page {{ padding:1rem; }}
}}
</style>
</head>
<body>
<div class="topbar">
<div class="topbar-brand">
<div class="topbar-logo">M</div>
<div>
<div class="topbar-title">Mintoak RAG Β· Evaluation Dashboard</div>
<div class="topbar-sub">{total_cases} test cases Β· generated {__import__('datetime').datetime.now().strftime('%d %b %Y, %H:%M')}</div>
</div>
</div>
<div class="topbar-badge">βœ“ {pass_rate:.1f}% Pass Rate</div>
</div>
<div class="page">
<!-- ── Metric Cards ── -->
<div class="metrics-row">
<div class="metric-card">
<div class="metric-label">Total Cases</div>
<div class="metric-value" id="metric-total">{total_cases}</div>
<div class="metric-sub">Across all categories</div>
</div>
<div class="metric-card green">
<div class="metric-label">Passed</div>
<div class="metric-value" id="metric-passed" style="color:var(--success)">{passed_count}</div>
<div class="metric-sub" id="metric-passed-sub">of {total_cases} cases</div>
</div>
<div class="metric-card red">
<div class="metric-label">Failed</div>
<div class="metric-value" id="metric-failed" style="color:var(--danger)">{failed_count}</div>
<div class="metric-sub" id="metric-failed-sub">of {total_cases} cases</div>
</div>
<div class="metric-card amber">
<div class="metric-label">Pass Rate</div>
<div class="metric-value" id="metric-rate">{pass_rate:.1f}%</div>
<div class="metric-sub">Overall alignment</div>
</div>
<div class="metric-card sky">
<div class="metric-label">Avg Latency</div>
<div class="metric-value">{avg_latency:.2f}s</div>
<div class="metric-sub">LLM inference only</div>
</div>
</div>
<!-- ── Charts ── -->
<p class="section-heading">Analytics Overview</p>
<div class="charts-row">
<!-- Donut: Pass vs Fail -->
<div class="chart-card">
<div class="chart-title">Pass / Fail Ratio</div>
<div class="chart-wrap">
<canvas id="donutChart" height="220"></canvas>
<div class="donut-center">
<div class="donut-pct" id="donut-label">{pass_rate:.1f}%</div>
<div class="donut-sub">pass rate</div>
</div>
</div>
</div>
<!-- Bar: Cases per category -->
<div class="chart-card">
<div class="chart-title">Cases by Category</div>
<canvas id="barChart" height="220"></canvas>
</div>
<!-- Bar: Avg Latency per category -->
<div class="chart-card">
<div class="chart-title">Avg Latency per Category (s)</div>
<canvas id="latencyChart" height="220"></canvas>
</div>
</div>
<!-- ── Category tiles ── -->
<p class="section-heading">Filter by Category</p>
<div class="cat-grid">
<div class="cat-tile active" onclick="filterCategory('all', this)" data-cat="all">
<div class="cat-dot" style="background:linear-gradient(135deg,#6366f1,#8b5cf6)"></div>
<div class="cat-tile-inner">
<span class="cat-tile-label">All Categories</span>
<span class="cat-tile-count">{total_cases} cases</span>
</div>
<div class="cat-tile-rate" style="color:var(--primary)">{pass_rate:.1f}%</div>
</div>
{cat_tiles_html}
</div>
<!-- ── Controls ── -->
<div class="controls">
<div class="search-wrap">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input class="search-input" id="searchInput" type="text"
placeholder="Search queries, responses, test IDs…" oninput="filterCases()">
</div>
<div class="pass-filters">
<button class="pf-btn active" id="pf-all" onclick="setPassFilter('all', this)">All</button>
<button class="pf-btn pass" id="pf-pass" onclick="setPassFilter('pass', this)">βœ“ Passed <span class="pf-count" id="pf-pass-count">{passed_count}</span></button>
<button class="pf-btn fail" id="pf-fail" onclick="setPassFilter('fail', this)">βœ• Failed <span class="pf-count" id="pf-fail-count">{failed_count}</span></button>
</div>
<span class="results-count" id="resultsCount">Showing {total_cases} cases</span>
</div>
<!-- ── Case Cards ── -->
<div class="cases-list" id="casesList">
{cards_html}
</div>
<div class="empty-state" id="emptyState" style="display:none">
<div class="icon">πŸ”</div>
No cases match your filters.
</div>
</div>
<script>
// ── Data from Python ──
const barLabels = {bar_labels};
const barPassed = {bar_passed};
const barFailed = {bar_failed};
const barPassRates = {bar_pass_rates};
const barColors = {bar_colors};
const avgLatencies = {avg_latencies};
const totalPassed = {passed_count};
const totalFailed = {failed_count};
const passRate = {pass_rate:.2f};
// ── Chart defaults ──
Chart.defaults.font.family = "'Plus Jakarta Sans', sans-serif";
Chart.defaults.color = "#64748b";
// ── Donut ──
const donutCtx = document.getElementById('donutChart').getContext('2d');
const donutChart = new Chart(donutCtx, {{
type: 'doughnut',
data: {{
labels: ['Passed', 'Failed'],
datasets: [{{
data: [totalPassed, totalFailed],
backgroundColor: ['#10b981', '#ef4444'],
borderWidth: 3,
borderColor: '#ffffff',
hoverOffset: 6,
}}]
}},
options: {{
cutout: '72%',
plugins: {{
legend: {{
position: 'bottom',
labels: {{ padding: 16, boxWidth: 12, boxHeight: 12, borderRadius: 4 }}
}},
tooltip: {{
callbacks: {{
label: ctx => ` ${{ctx.label}}: ${{ctx.raw}} cases (${{(ctx.raw/(totalPassed+totalFailed)*100).toFixed(1)}}%)`
}}
}}
}},
animation: {{ animateRotate: true, duration: 900 }}
}}
}});
// ── Bar: category breakdown ──
const barCtx = document.getElementById('barChart').getContext('2d');
new Chart(barCtx, {{
type: 'bar',
data: {{
labels: barLabels,
datasets: [
{{
label: 'Passed',
data: barPassed,
backgroundColor: barColors.map(c => c + 'cc'),
borderRadius: 6,
borderSkipped: false,
}},
{{
label: 'Failed',
data: barFailed,
backgroundColor: '#ef444466',
borderRadius: 6,
borderSkipped: false,
}}
]
}},
options: {{
responsive: true,
plugins: {{
legend: {{ position: 'bottom', labels: {{ padding: 16, boxWidth: 12, boxHeight: 12 }} }},
tooltip: {{
callbacks: {{
afterLabel: (ctx) => {{
const idx = ctx.dataIndex;
return `Pass Rate: ${{barPassRates[idx]}}%`;
}}
}}
}}
}},
scales: {{
x: {{ grid: {{ display: false }}, stacked: false }},
y: {{ grid: {{ color: '#f1f5f9' }}, beginAtZero: true, ticks: {{ precision: 0 }} }}
}},
animation: {{ duration: 900 }}
}}
}});
// ── Bar: avg latency ──
const latCtx = document.getElementById('latencyChart').getContext('2d');
new Chart(latCtx, {{
type: 'bar',
data: {{
labels: barLabels,
datasets: [{{
label: 'Avg Latency (s)',
data: avgLatencies,
backgroundColor: barColors.map(c => c + 'aa'),
borderRadius: 6,
borderSkipped: false,
}}]
}},
options: {{
responsive: true,
plugins: {{
legend: {{ display: false }},
tooltip: {{
callbacks: {{
label: ctx => ` ${{ctx.raw}}s average`
}}
}}
}},
scales: {{
x: {{ grid: {{ display: false }} }},
y: {{ grid: {{ color: '#f1f5f9' }}, beginAtZero: true, ticks: {{ callback: v => v + 's' }} }}
}},
animation: {{ duration: 900 }}
}}
}});
// ── Filtering logic ──
let currentCat = 'all';
let currentPass = 'all';
function filterCategory(cat, el) {{
currentCat = cat;
document.querySelectorAll('.cat-tile').forEach(t => t.classList.remove('active'));
el.classList.add('active');
filterCases();
}}
function setPassFilter(val, btn) {{
currentPass = val;
document.querySelectorAll('.pf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterCases();
}}
function filterCases() {{
const query = document.getElementById('searchInput').value.toLowerCase();
const cards = document.querySelectorAll('.case-card');
let total = 0, passed = 0, failed = 0, visible = 0;
cards.forEach(card => {{
const cat = card.getAttribute('data-category');
const isPass = card.getAttribute('data-pass') === 'True';
const text = card.textContent.toLowerCase();
const matchCat = currentCat === 'all' || cat === currentCat;
const matchSearch = text.includes(query);
const matchPass = currentPass === 'all' || (currentPass === 'pass' && isPass) || (currentPass === 'fail' && !isPass);
if (matchCat && matchSearch && matchPass) {{
card.style.display = '';
total++;
if (isPass) passed++; else failed++;
visible++;
}} else {{
card.style.display = 'none';
}}
}});
// Update metric cards
document.getElementById('metric-total').textContent = total;
document.getElementById('metric-passed').textContent = passed;
document.getElementById('metric-failed').textContent = failed;
document.getElementById('metric-passed-sub').textContent = `of ${{total}} cases`;
document.getElementById('metric-failed-sub').textContent = `of ${{total}} cases`;
const rate = total > 0 ? ((passed / total) * 100).toFixed(1) : '0.0';
document.getElementById('metric-rate').textContent = rate + '%';
// Update pill button counts
document.getElementById('pf-pass-count').textContent = passed;
document.getElementById('pf-fail-count').textContent = failed;
// Update donut
donutChart.data.datasets[0].data = [passed, failed];
document.getElementById('donut-label').textContent = rate + '%';
donutChart.update();
document.getElementById('resultsCount').textContent = `Showing ${{visible}} case${{visible !== 1 ? 's' : ''}}`;
document.getElementById('emptyState').style.display = visible === 0 ? 'block' : 'none';
}}
function toggleDetails(header) {{
const details = header.nextElementSibling;
details.classList.toggle('show');
header.classList.toggle('open');
}}
</script>
</body>
</html>
"""
os.makedirs("outputs", exist_ok=True)
with open(OUTPUT_HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"Successfully generated HTML report at '{OUTPUT_HTML}'!")
if __name__ == "__main__":
main()