feat: simplify assurance reports with progressive disclosure

This commit is contained in:
thanhnv
2026-07-29 11:55:49 +07:00
parent a7b1c604d5
commit 1d6384d23c
14 changed files with 431 additions and 123 deletions
@@ -137,6 +137,28 @@ main { max-width: 1500px; margin: 0 auto; padding: 28px clamp(20px,4vw,48px) 64p
.panel h2 { margin: 6px 0 0; font-size: 19px; letter-spacing: -.025em; }
.panel-description { max-width: 720px; margin: 7px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.panel-body { border-top: 1px solid #f2f4f7; padding: 22px 24px 24px; }
.disclosure-panel {
overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: white;
box-shadow: 0 10px 28px rgba(16,24,40,.04);
}
.disclosure-panel > summary {
display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 18px;
padding: 15px 20px; list-style: none; cursor: pointer; transition: background .16s ease;
}
.disclosure-panel > summary::-webkit-details-marker { display: none; }
.disclosure-panel > summary:hover { background: #f9fafb; }
.disclosure-panel > summary strong, .disclosure-panel > summary small { display: block; }
.disclosure-panel > summary strong { color: #344054; font-size: 13px; }
.disclosure-panel > summary small { margin-top: 5px; color: var(--muted); font-size: 11px; line-height: 1.45; }
.disclosure-side { display: flex; flex-shrink: 0; align-items: center; gap: 10px; }
.disclosure-side > b { color: var(--faint); font-size: 20px; font-weight: 400; transition: transform .16s ease; }
.disclosure-panel[open] .disclosure-side > b { transform: rotate(45deg); }
.disclosure-body { border-top: 1px solid #f2f4f7; padding: 20px; }
.disclosure-body > dl { margin: -20px; }
.disclosure-panel .manifest-row { border-bottom: 1px solid #f2f4f7; }
.disclosure-panel .manifest-row:last-child { border-bottom: 0; }
.disclosure-panel .manifest-row dt { color: var(--faint); font: 750 9px ui-monospace,monospace; letter-spacing: .07em; text-transform: uppercase; }
.disclosure-panel .manifest-row dd { margin: 0; overflow-wrap: anywhere; color: #344054; font: 11px/1.5 ui-monospace,monospace; }
.two-col { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr); gap: 18px; }
.rail-wrap { overflow-x: auto; padding-bottom: 4px; }
.evidence-rail { display: grid; grid-template-columns: repeat(7,minmax(116px,1fr)); min-width: 840px; gap: 7px; }
@@ -203,6 +225,20 @@ tbody tr:hover { background: #f0fdfa; }
.source h3 { margin: 0; font-size: 12px; text-transform: capitalize; }
.source-meta, .source-path { color: var(--faint); font: 9px/1.5 ui-monospace,monospace; }
.source-meta { margin-top: 5px; }.source-path { overflow-wrap:anywhere; border-top: 1px solid #f2f4f7; margin-top: 12px; padding-top: 10px; }
.distribution { display: grid; gap: 17px; margin: 0; padding: 0; list-style: none; }
.distribution-head { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
.distribution-head span strong, .distribution-head span small { display: block; }
.distribution-head span strong { color: #344054; font-size: 12px; text-transform: capitalize; }
.distribution-head span small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.distribution-head > b { color: var(--ink); font: 700 11px ui-monospace,monospace; }
.distribution-track { overflow: hidden; height: 8px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
.distribution-track i { display: block; height: 100%; border-radius: inherit; background: var(--cyan); }
.distribution-track i.success { background: #12b76a; }
.distribution-track i.warning { background: #f79009; }
.distribution-track i.danger { background: #f04438; }
.metric-grid.compact .metric-card { min-height: 116px; }
.technical-section { margin-top: 24px; border-top: 1px solid #f2f4f7; padding-top: 22px; }
.technical-section h3 { margin: 6px 0 14px; font-size: 16px; }
.coverage { display: grid; gap: 18px; }
.coverage-head { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; }
.coverage-track { overflow: hidden; height: 7px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
@@ -241,7 +277,7 @@ tbody tr:hover { background: #f0fdfa; }
.filters { grid-template-columns: 1fr 1fr; }
.filter-actions { grid-column: 1/-1; }
}
@media (max-width: 820px) {
@media (max-width: 960px) {
.sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(16,24,40,.28); }
.sidebar.open { transform: translateX(0); }
.shell { margin-left: 0; }
@@ -69,6 +69,21 @@
<div class="panel-body">${body}</div></article>`;
}
function disclosure(summary, description, body, badge = "") {
return `<details class="disclosure-panel"><summary><span><strong>${esc(summary)}</strong><small>${esc(description)}</small></span>
<span class="disclosure-side">${badge}<b aria-hidden="true">+</b></span></summary><div class="disclosure-body">${body}</div></details>`;
}
function distribution(label, rows) {
const maximum = Math.max(...rows.map((row) => Number(row.value) || 0), 1);
return `<ol class="distribution" role="img" aria-label="${esc(label)}">${rows.map((row) => {
const value = Math.max(Number(row.value) || 0, 0);
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
return `<li><div class="distribution-head"><span><strong>${esc(row.label)}</strong><small>${esc(row.detail || "")}</small></span><b>${esc(number(value))}</b></div>
<div class="distribution-track" aria-hidden="true"><i class="${slug(row.tone || "info")}" style="width:${width}%"></i></div></li>`;
}).join("")}</ol>`;
}
function empty(title, detail) {
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
}
@@ -130,10 +145,9 @@
${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)}
${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")}
</section>` +
panel(
"Product boundary",
"Core is visual by default",
"The local viewer covers the complete single-project review workflow; centralized operations remain a Platform capability.",
disclosure(
"About this local report",
"Edition boundaries and implementation notes are hidden until needed.",
`<div class="two-col">
<div class="notice success"><strong>Included in Core</strong><br>Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.</div>
<div class="notice warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
@@ -200,9 +214,9 @@
<div class="dossier">
<div class="decision-card"><div><p class="eyebrow">Selected control</p><h2>${esc(selected.title)}</h2><p class="panel-description">${esc(selected.description)}</p></div>
<div class="inset"><span class="inset-label">Latest decision · ${esc(time(selected.updated_at))}</span><p>${esc(selected.reason)}</p></div>
<div class="inset"><span class="inset-label">Event history</span>${timeline}</div>
${disclosure("Event history", `${events.length} recent lifecycle events · hidden by default`, timeline)}
</div>
<div class="manifest"><div class="manifest-head"><div><p class="eyebrow">Safe evidence manifest</p><div class="metric-detail">Sanitized fields persisted by the harness</div></div>${pill(`${entries.length} fields`)}</div>${manifest}</div>
${disclosure("Safe evidence manifest", "Sanitized fields persisted by the harness · hidden by default", manifest, pill(`${entries.length} fields`))}
</div>`,
actions,
);
@@ -225,6 +239,12 @@
}).join("");
const breakdown = (rows, subject) => `<div class="table-scroll"><table><thead><tr><th>${esc(subject)}</th><th class="right">Runs</th><th class="right">Failures</th><th class="right">Avg latency</th><th class="right">Tokens</th><th class="right">Cost</th></tr></thead>
<tbody>${rows.length ? rows.map((row) => `<tr><td><strong>${esc(row.key)}</strong></td><td class="right mono">${row.runs}</td><td class="right mono">${row.failures}</td><td class="right mono">${number(row.latency_avg_ms)} ms</td><td class="right mono">${number(row.tokens)}</td><td class="right mono">${money(row.cost_usd)}</td></tr>`).join("") : `<tr><td colspan="6">${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}</td></tr>`}</tbody></table></div>`;
const outcomes = distribution("Governed run outcome distribution", data.details.by_status.map((row) => ({
label: String(row.status).replaceAll("_", " "),
value: row.count,
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
tone: row.status === "failed" ? "danger" : row.status === "degraded" ? "warning" : "success",
})));
$("#h6-view").innerHTML =
hero({
eyebrow: "H6 · AgentOps assurance",
@@ -234,7 +254,7 @@
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
actions,
}) +
panel("Scope", "Evidence boundary", "Filters apply to the screen and both export formats.", h6Filters(data)) +
disclosure("Filter the evidence boundary", "Project, date and trace filters stay out of the executive view until needed.", h6Filters(data)) +
`<section class="metric-grid">
${metric("Governed runs", number(data.summary.runs), `${data.summary.success} successful · ${data.summary.degraded} degraded`)}
${metric("Failure rate", `${data.summary.failure_rate_pct}%`, `${data.summary.failed} failed · ${data.summary.alerts} alerts`)}
@@ -243,17 +263,29 @@
</section>` +
`<div class="two-col">
${panel("Decision brief", "What requires attention", "Threshold findings are review signals, not maturity claims.", findings, pill(data.verdict))}
${panel("Source integrity", "Evidence provenance", "Freshness is evaluated independently for each canonical source.", `<div class="source-grid">${sources}</div>`, pill(data.freshness.status))}
</div>` +
`<div class="breakdown-grid">
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
${panel("Outcome mix", "Run distribution", "Canonical runtime status values, normalized against the largest observed group.", outcomes, `<span class="mono metric-detail">${number(data.summary.runs)} total</span>`)}
</div>` +
panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `<div class="two-col">
<div class="coverage">${coverage("Token attribution", data.summary.coverage.token_pct, `${data.summary.coverage.token_records}/${data.summary.coverage.runtime_records} records`)}
${coverage("Cost attribution", data.summary.coverage.cost_pct, `${data.summary.coverage.cost_records}/${data.summary.coverage.runtime_records} records`)}</div>
<div class="inset"><span class="inset-label">Disclosure notes</span>${data.data_quality.warnings.length ? `<ul>${data.data_quality.warnings.map((warning) => `<li class="metric-detail">${esc(warning)}</li>`).join("")}</ul>` : `<p class="success-copy">All required sources are present with no estimation warning.</p>`}</div>
</div>`, pill(data.data_quality.status));
</div>`, pill(data.data_quality.status)) +
disclosure(
"Technical evidence and breakdowns",
"Provider counters, canonical paths and raw tables are preserved for audit without crowding the decision view.",
`<section class="metric-grid compact">
${metric("Provider tokens", data.summary.coverage.token_records > 0 ? number(data.summary.tokens.provider_total ?? data.summary.tokens.total) : "Unavailable", `${data.summary.coverage.token_pct}% record coverage`)}
${metric("Actual cost", money(data.summary.cost_usd.provider_actual), `${data.summary.coverage.cost_pct}% record coverage`)}
${metric("Provider calls", number(data.summary.provider_calls), "Provider-attributed requests")}
${metric("Retries", number(data.summary.retries), "Observed reroute attempts")}
</section>
<div class="technical-section"><p class="eyebrow">Source integrity</p><h3>Evidence provenance</h3><div class="source-grid">${sources}</div></div>
<div class="breakdown-grid technical-section">
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
</div>`,
pill(data.freshness.status),
);
bindH6Filters();
}
@@ -656,6 +656,31 @@ def report_html(report: dict[str, Any], kind: str) -> str:
("Data quality", report.get("data_quality", {}).get("status")),
]
findings = report.get("findings", [])
statuses = report.get("details", {}).get("by_status", [])
maximum = max(
[float(item.get("count", 0) or 0) for item in statuses] or [1]
)
total_runs = float(summary.get("runs", 0) or 0)
outcome_rows = "".join(
'<li><div><span><strong>%s</strong><small>%s%% of selected runs</small></span><b>%s</b></div>'
'<i><em class="%s" style="width:%s%%"></em></i></li>' % (
escape(_text(item.get("status"), "unknown").replace("_", " ")),
round((float(item.get("count", 0) or 0) / total_runs) * 100)
if total_runs else 0,
escape(str(int(float(item.get("count", 0) or 0)))),
"fail" if item.get("status") == "failed" else
"attention" if item.get("status") == "degraded" else "pass",
max((float(item.get("count", 0) or 0) / maximum) * 100, 3)
if float(item.get("count", 0) or 0) else 0,
)
for item in statuses
)
outcomes = (
'<section class="panel"><h2>Run distribution</h2>'
'<p class="sub">Canonical runtime status values, normalized against the largest observed group.</p>'
f'<ol class="distribution">{outcome_rows}</ol></section>'
if outcome_rows else ""
)
else:
summary = report.get("summary", {})
metrics = [
@@ -668,6 +693,7 @@ def report_html(report: dict[str, Any], kind: str) -> str:
{"code": gate.get("title"), "severity": gate.get("status"), "message": gate.get("reason")}
for gate in report.get("gates", [])
]
outcomes = ""
cards = "".join(
f'<div class="metric"><span>{escape(str(label))}</span><strong>{escape(str(value if value is not None else "Unavailable"))}</strong></div>'
for label, value in metrics
@@ -692,10 +718,11 @@ main{{max-width:1060px;margin:auto;padding:44px 24px 72px}}header{{border-radius
.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}}.metric,.panel{{border:1px solid var(--line);border-radius:18px;background:white;padding:20px;box-shadow:0 10px 30px #10182808}}
.metric span{{display:block;color:var(--muted)}}.metric strong{{display:block;margin-top:16px;font-size:24px;letter-spacing:-.03em}}.panel{{margin-top:18px;padding:26px}}h2{{margin:0 0 6px;font-size:20px}}.sub{{margin:0 0 20px;color:var(--muted);font-size:14px}}
.finding{{border-left:4px solid var(--cyan);border-radius:10px;background:#f8fafc;padding:14px 16px;margin-top:10px}}.finding div{{display:flex;justify-content:space-between;gap:16px}}code{{font-weight:800}}em{{color:var(--muted);font-size:11px;text-transform:uppercase}}.finding p{{margin:8px 0 0;color:#344054;font-size:14px;line-height:1.55}}.clear{{border-radius:12px;background:#ecfdf3;color:var(--green);padding:16px}}
.distribution{{display:grid;gap:16px;margin:20px 0 0;padding:0;list-style:none}}.distribution li>div{{display:flex;align-items:end;justify-content:space-between;gap:16px}}.distribution span strong,.distribution span small{{display:block}}.distribution span strong{{font-size:13px;text-transform:capitalize}}.distribution span small{{margin-top:4px;color:var(--muted);font-size:11px}}.distribution b{{font:700 12px ui-monospace,monospace}}.distribution i{{display:block;overflow:hidden;height:8px;margin-top:8px;border-radius:99px;background:#eaecf0}}.distribution em{{display:block;height:100%;border-radius:inherit;background:var(--cyan)}}.distribution em.pass{{background:#12b76a}}.distribution em.attention{{background:#f79009}}.distribution em.fail{{background:#f04438}}
details{{margin-top:18px}}summary{{cursor:pointer;font-weight:700}}pre{{overflow:auto;max-height:720px;border-radius:14px;background:#101828;color:#d0d5dd;padding:20px;font:11px/1.6 ui-monospace,monospace}}
footer{{margin-top:22px;color:var(--muted);font-size:11px}}@media(max-width:760px){{h1{{font-size:32px}}.grid{{grid-template-columns:1fr 1fr}}header{{padding:28px}}}}@media print{{body{{background:white}}main{{max-width:none;padding:0}}header,.panel,.metric{{box-shadow:none}}details{{break-before:page}}}}
</style></head><body><main><header><div class="eyebrow">CASAN · Independent assurance dossier</div><h1>{escape(str(title))}</h1>
<div class="meta"><span>{escape(str(report.get("report_id", "")))}</span><span>Generated {escape(str(report.get("generated_at", "")))}</span></div><div class="badge">Verdict · {escape(verdict)}</div></header>
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>{outcomes}
<section class="panel"><h2>Machine-auditable contract</h2><p class="sub">The complete sanitized report payload used to render this dossier.</p><details><summary>Open JSON evidence</summary><pre>{contract}</pre></details></section>
<footer>Generated on demand by CASAN Core Local Assurance Viewer · read-only · offline-capable</footer></main></body></html>"""
@@ -139,6 +139,15 @@ class LocalReportTests(unittest.TestCase):
self.assertIn("&lt;script&gt;alert(1)&lt;/script&gt;", html)
self.assertIn("Machine-auditable contract", html)
def test_h6_html_visualizes_outcomes_and_hides_raw_contract(self):
self.write_run()
report = REPORT.h6_report(self.root, run="trace-1")
html = REPORT.report_html(report, "h6")
self.assertIn("Run distribution", html)
self.assertIn('class="distribution"', html)
self.assertIn("<details><summary>Open JSON evidence</summary>", html)
self.assertNotIn("None</strong>", html)
def test_unsafe_trace_id_never_reads_outside_project(self):
report = REPORT.run_report(self.root, "../../etc/passwd")
self.assertEqual(report["verdict"], "not_found")