Spaces:
Sleeping
Sleeping
Flamehaven Initiative commited on
Commit ·
e3afeec
1
Parent(s): 96b172f
feat: refresh html report explainer surface
Browse files- stem_ai/render_html.py +380 -162
- stem_ai/render_html_styles.py +222 -93
stem_ai/render_html.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
Sections: Executive Summary |
|
| 4 |
Self-contained: inline CSS + SVG + JS, zero external dependencies.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
|
@@ -8,10 +8,18 @@ from __future__ import annotations
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from .render_html_components import (
|
| 11 |
-
_C,
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
from .render_html_styles import build_css, JS
|
| 17 |
|
|
@@ -29,8 +37,10 @@ def _calibration_effect_note(calibration: dict[str, Any]) -> str | None:
|
|
| 29 |
|
| 30 |
def _nav() -> str:
|
| 31 |
links = [
|
| 32 |
-
("#s1", "1. Summary"),
|
| 33 |
-
("#
|
|
|
|
|
|
|
| 34 |
("#s5", "5. Evidence"),
|
| 35 |
]
|
| 36 |
items = "".join(f'<a href="{h}" class="nav-link">{l}</a>' for h, l in links)
|
|
@@ -42,30 +52,108 @@ def _hero(score: dict, final: int, tier: str, tc: str, target: str, date: str) -
|
|
| 42 |
use_scope = xt(str(score.get("use_scope", "")))
|
| 43 |
return (
|
| 44 |
f'<header class="hero">'
|
| 45 |
-
f'<div>{gauge}</div>'
|
| 46 |
-
f'<div>'
|
| 47 |
-
f'<div class="
|
| 48 |
f'<h1>{target}</h1>'
|
| 49 |
-
f'<div class="
|
| 50 |
-
f'<
|
| 51 |
-
f'
|
|
|
|
|
|
|
|
|
|
| 52 |
f'</div></header>'
|
| 53 |
)
|
| 54 |
|
| 55 |
|
| 56 |
-
def
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
stats = "".join(
|
| 66 |
-
f'<div class="
|
| 67 |
-
f'<div class="
|
| 68 |
-
f'<div class="
|
|
|
|
| 69 |
for v, l, c in [
|
| 70 |
(final, "Final Score", tc),
|
| 71 |
(s1, "S1 Intent", _C["teal"]),
|
|
@@ -74,90 +162,165 @@ def _section1(result: dict, final: int, tc: str,
|
|
| 74 |
(s4, "S4 Replication", _C["green"]),
|
| 75 |
]
|
| 76 |
)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
)
|
| 81 |
-
|
| 82 |
-
f'<div
|
| 83 |
-
f'
|
| 84 |
-
f'
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
calibration = result.get("calibration_profile", {})
|
| 88 |
-
freshness = result.get("audit_freshness", {})
|
| 89 |
-
policy_name = xt(str(calibration.get("profile_name", "unknown")))
|
| 90 |
-
policy_status = xt(str(calibration.get("profile_status", "unknown")))
|
| 91 |
-
policy_mode = xt(str(calibration.get("profile_read_mode", "unknown")))
|
| 92 |
-
calibration_note = _calibration_effect_note(calibration)
|
| 93 |
-
policy_block = (
|
| 94 |
-
f'<div class="panel" style="margin-top:20px;padding:14px 16px">'
|
| 95 |
-
f'<div style="font-size:12px;font-weight:700;color:{_C["navy"]};margin-bottom:6px">'
|
| 96 |
-
f'Policy Surface: {policy_name} ({policy_status}, {policy_mode})</div>'
|
| 97 |
-
f'<div style="font-size:12px;line-height:1.55;color:{_C["dgray"]}">'
|
| 98 |
-
f'{xt(calibration_note)}</div></div>'
|
| 99 |
-
if calibration_note
|
| 100 |
-
else ""
|
| 101 |
-
)
|
| 102 |
-
freshness_block = (
|
| 103 |
-
f'<div class="panel" style="margin-top:14px;padding:14px 16px">'
|
| 104 |
-
f'<div style="font-size:12px;font-weight:700;color:{_C["navy"]};margin-bottom:6px">'
|
| 105 |
-
f'Audit Freshness</div>'
|
| 106 |
-
f'<div style="font-size:12px;line-height:1.55;color:{_C["dgray"]}">'
|
| 107 |
-
f'Review after: {xt(str(freshness.get("review_after_days", "n/a")))} days | '
|
| 108 |
-
f'Expires on: {xt(str(freshness.get("expires_on", "unknown")))}<br>'
|
| 109 |
-
f'Change-triggered re-audit now: '
|
| 110 |
-
f'{xt(str(freshness.get("change_triggered_reaudit_recommended_now", False)))}'
|
| 111 |
-
f'</div></div>'
|
| 112 |
-
if freshness
|
| 113 |
-
else ""
|
| 114 |
)
|
|
|
|
|
|
|
| 115 |
return (
|
| 116 |
f'<section id="s1">'
|
| 117 |
f'<h2 class="s-title">Executive Summary</h2>'
|
| 118 |
f'{alert}'
|
| 119 |
-
f'<div class="
|
| 120 |
-
f'
|
| 121 |
-
f'
|
| 122 |
-
f'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
f'</section>'
|
| 124 |
)
|
| 125 |
|
| 126 |
|
| 127 |
-
def
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return (
|
| 153 |
f'<section id="s2">'
|
| 154 |
-
f'<h2 class="s-title">
|
| 155 |
-
f'<div class="panel">'
|
| 156 |
-
f'
|
| 157 |
-
f'
|
| 158 |
-
f'
|
| 159 |
-
f'
|
| 160 |
-
f'</div></div></section>'
|
| 161 |
)
|
| 162 |
|
| 163 |
|
|
@@ -169,30 +332,50 @@ def _section3(integrity: dict, cc: dict) -> str:
|
|
| 169 |
"status": v.get("status", "PASS"),
|
| 170 |
"evidence": [f"count={v.get('count', 0)}"],
|
| 171 |
}
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
hint = tip_icon(
|
| 174 |
-
"C1-C6: static code and governance checks
|
| 175 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
)
|
| 177 |
return (
|
| 178 |
f'<section id="s3">'
|
| 179 |
f'<h2 class="s-title">Code Integrity & Contract {hint}</h2>'
|
| 180 |
-
f'<div class="
|
| 181 |
-
f'<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
)
|
| 183 |
|
| 184 |
|
| 185 |
def _section4(airi: dict) -> str:
|
| 186 |
if not airi or "covered_risks" not in airi:
|
| 187 |
return ""
|
| 188 |
-
pct
|
| 189 |
covered_n = int(airi.get("covered_count", 0))
|
| 190 |
-
total_n
|
| 191 |
-
gaps
|
| 192 |
all_risks = airi.get("covered_risks", [])
|
| 193 |
-
donut
|
| 194 |
|
| 195 |
-
# Count covered risks per AIRI domain (first digit of subdomain_id)
|
| 196 |
domain_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
|
| 197 |
for r in all_risks:
|
| 198 |
try:
|
|
@@ -206,55 +389,88 @@ def _section4(airi: dict) -> str:
|
|
| 206 |
c_rows = "".join(airi_row(r, "covered") for r in all_risks[:24])
|
| 207 |
g_rows = "".join(airi_row(g, "gap") for g in gaps)
|
| 208 |
toggle = (
|
| 209 |
-
f'<div
|
| 210 |
f'<div class="toggle-group">'
|
| 211 |
-
f'<button class="toggle-btn" data-view="covered"'
|
| 212 |
-
f' onclick="airiToggle(\'
|
| 213 |
-
f'<button class="toggle-btn" data-view="gaps"'
|
| 214 |
-
f' onclick="airiToggle(\'gaps\')">Gaps ({len(gaps)})</button>'
|
| 215 |
f'</div>'
|
| 216 |
-
f'<span
|
| 217 |
f'</div>'
|
| 218 |
)
|
| 219 |
table = (
|
| 220 |
f'<div style="overflow-x:auto"><table class="airi-table">'
|
| 221 |
-
f'<thead><tr><th>ID</th><th>Risk</th><th>Domain</th>'
|
| 222 |
-
f'<th>Covered by / Note</th></tr></thead>'
|
| 223 |
f'<tbody>{c_rows}{g_rows}</tbody></table></div>'
|
| 224 |
)
|
| 225 |
-
src
|
| 226 |
bundle_scope = xt(airi.get("airi_bundle_scope", ""))
|
| 227 |
snapshot = xt(airi.get("airi_upstream_snapshot_date", ""))
|
| 228 |
license_name = xt(airi.get("airi_upstream_license", ""))
|
| 229 |
attribution = xt(airi.get("airi_attribution_note", ""))
|
| 230 |
hint = tip_icon(
|
| 231 |
-
"Coverage
|
| 232 |
-
"
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
)
|
| 235 |
return (
|
| 236 |
f'<section id="s4">'
|
| 237 |
-
f'<h2 class="s-title">MIT AI Risk Repository Coverage {hint}'
|
| 238 |
-
f'<
|
| 239 |
-
f'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
f'<div class="panel">'
|
| 241 |
-
f'<div
|
| 242 |
-
f'
|
| 243 |
-
f'<div style="text-align:center;flex-shrink:0">{donut}'
|
| 244 |
-
f'<div style="font-size:13px;font-weight:600;color:{_C["navy"]};margin-top:8px">'
|
| 245 |
-
f'{covered_n} / {total_n}</div>'
|
| 246 |
-
f'<div style="font-size:11px;color:{_C["dgray"]}">risks in scope</div></div>'
|
| 247 |
-
f'<div style="flex:1;min-width:240px">'
|
| 248 |
-
f'<p style="font-size:12px;color:{_C["dgray"]};margin-bottom:14px;line-height:1.5">'
|
| 249 |
-
f'Risks addressed by STEM-BIO-AI detectors across the AIRI V4 '
|
| 250 |
-
f'curated runtime bundle. Click a domain card to filter. Toggle covered/gaps.'
|
| 251 |
-
f'</p>'
|
| 252 |
-
f'<p style="font-size:11px;color:{_C["dgray"]};margin-bottom:0;line-height:1.5">'
|
| 253 |
-
f'Bundle scope: {bundle_scope} | snapshot: {snapshot} | license: {license_name}<br>'
|
| 254 |
-
f'{attribution}'
|
| 255 |
-
f'</p></div></div>'
|
| 256 |
f'<div class="domain-grid">{domain_boxes}</div>'
|
| 257 |
f'{toggle}{table}'
|
|
|
|
| 258 |
f'</div></section>'
|
| 259 |
)
|
| 260 |
|
|
@@ -268,57 +484,59 @@ def _section5(evidence_ledger: list) -> str:
|
|
| 268 |
)
|
| 269 |
n = len(evidence_ledger)
|
| 270 |
chips = " ".join(
|
| 271 |
-
f'<span class="filter-chip{" active" if i == 0 else ""}"'
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
)
|
| 278 |
rows = "".join(evidence_row(ev) for ev in evidence_ledger[:200])
|
| 279 |
hint = tip_icon(
|
| 280 |
-
"Full evidence ledger from all detectors. "
|
| 281 |
-
"Filter by severity. Capped at 200 entries in HTML view."
|
| 282 |
)
|
| 283 |
th = f'style="padding:8px 5px;font-size:11px;text-align:left;color:{_C["dgray"]}"'
|
| 284 |
return (
|
| 285 |
f'<section id="s5">'
|
| 286 |
f'<h2 class="s-title">Evidence Detail {hint}</h2>'
|
| 287 |
f'<div class="panel">'
|
| 288 |
-
f'<div style="margin-bottom:14px">{chips}</div>'
|
| 289 |
f'<div style="overflow-x:auto">'
|
| 290 |
f'<table style="width:100%;border-collapse:collapse">'
|
| 291 |
f'<thead><tr style="background:{_C["lgray"]}">'
|
| 292 |
f'<th style="padding:8px 5px;width:16px"></th>'
|
| 293 |
-
f'<th {th}>SEV</th><th {th}>Detector</th>'
|
| 294 |
-
f'<th {th}>Finding</th><th {th}>File</th>'
|
| 295 |
f'</tr></thead>'
|
| 296 |
f'<tbody>{rows}</tbody></table></div></div></section>'
|
| 297 |
)
|
| 298 |
|
| 299 |
|
| 300 |
def render_html(result: dict[str, Any]) -> str:
|
| 301 |
-
score
|
| 302 |
-
final
|
| 303 |
-
tier
|
| 304 |
-
tc
|
| 305 |
target = xt(str(result["target"]["name"]))
|
| 306 |
-
date
|
| 307 |
s1 = int(score.get("stage_1_readme_intent", 0))
|
| 308 |
s2 = int(score.get("stage_2_repo_local_consistency", 0))
|
| 309 |
s3 = int(score.get("stage_3_code_bio", 0))
|
| 310 |
s4 = int(result.get("replication_score", 0))
|
| 311 |
t0 = result.get("classification", {}).get("t0_hard_floor", False)
|
| 312 |
|
| 313 |
-
css
|
| 314 |
-
nav
|
| 315 |
-
hero
|
| 316 |
-
sec1
|
| 317 |
-
sec2
|
| 318 |
-
sec3
|
| 319 |
-
sec4
|
| 320 |
-
sec5
|
| 321 |
-
ver
|
| 322 |
|
| 323 |
return f"""<!DOCTYPE html>
|
| 324 |
<html lang="en">
|
|
|
|
| 1 |
+
"""Interactive HTML report renderer for STEM BIO-AI.
|
| 2 |
|
| 3 |
+
Sections: Executive Summary | Decision Path | Code Integrity | AIRI Coverage | Evidence Detail
|
| 4 |
Self-contained: inline CSS + SVG + JS, zero external dependencies.
|
| 5 |
"""
|
| 6 |
from __future__ import annotations
|
|
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from .render_html_components import (
|
| 11 |
+
_C,
|
| 12 |
+
_STAGE_TIPS,
|
| 13 |
+
xt,
|
| 14 |
+
tier_color,
|
| 15 |
+
tip_icon,
|
| 16 |
+
svg_gauge,
|
| 17 |
+
svg_donut,
|
| 18 |
+
svg_hbar,
|
| 19 |
+
integrity_card,
|
| 20 |
+
domain_card,
|
| 21 |
+
airi_row,
|
| 22 |
+
evidence_row,
|
| 23 |
)
|
| 24 |
from .render_html_styles import build_css, JS
|
| 25 |
|
|
|
|
| 37 |
|
| 38 |
def _nav() -> str:
|
| 39 |
links = [
|
| 40 |
+
("#s1", "1. Summary"),
|
| 41 |
+
("#s2", "2. Decision Path"),
|
| 42 |
+
("#s3", "3. Code Integrity"),
|
| 43 |
+
("#s4", "4. AIRI Coverage"),
|
| 44 |
("#s5", "5. Evidence"),
|
| 45 |
]
|
| 46 |
items = "".join(f'<a href="{h}" class="nav-link">{l}</a>' for h, l in links)
|
|
|
|
| 52 |
use_scope = xt(str(score.get("use_scope", "")))
|
| 53 |
return (
|
| 54 |
f'<header class="hero">'
|
| 55 |
+
f'<div class="hero-left">{gauge}</div>'
|
| 56 |
+
f'<div class="hero-right">'
|
| 57 |
+
f'<div class="eyebrow">STEM BIO-AI Local Audit | {date}</div>'
|
| 58 |
f'<h1>{target}</h1>'
|
| 59 |
+
f'<div class="hero-meta">'
|
| 60 |
+
f'<span class="tier">{xt(tier)}</span>'
|
| 61 |
+
f'<span class="hero-chip">Deterministic local scan</span>'
|
| 62 |
+
f'<span class="hero-chip">No LLM / no network / no runtime execution</span>'
|
| 63 |
+
f'</div>'
|
| 64 |
+
f'<p class="lede">{use_scope}</p>'
|
| 65 |
f'</div></header>'
|
| 66 |
)
|
| 67 |
|
| 68 |
|
| 69 |
+
def _iter_rubric_rows(rubric: dict[str, Any]) -> list[dict[str, Any]]:
|
| 70 |
+
rows: list[dict[str, Any]] = []
|
| 71 |
+
for key, info in rubric.items():
|
| 72 |
+
if key.endswith("raw_total") or key == "baseline":
|
| 73 |
+
continue
|
| 74 |
+
if not isinstance(info, dict):
|
| 75 |
+
continue
|
| 76 |
+
score = info.get("score")
|
| 77 |
+
max_score = info.get("max")
|
| 78 |
+
if score is None:
|
| 79 |
+
continue
|
| 80 |
+
rows.append(
|
| 81 |
+
{
|
| 82 |
+
"key": key,
|
| 83 |
+
"score": int(score),
|
| 84 |
+
"max": int(max_score) if isinstance(max_score, (int, float)) else None,
|
| 85 |
+
"evidence": xt(str(info.get("evidence", ""))),
|
| 86 |
+
"detector_id": xt(str(info.get("detector_id", ""))) if info.get("detector_id") else "",
|
| 87 |
+
"decision_basis": xt(str(info.get("decision_basis", ""))) if info.get("decision_basis") else "",
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
return rows
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _select_focus_rows(
|
| 94 |
+
rubric: dict[str, Any],
|
| 95 |
+
*,
|
| 96 |
+
negatives_first: bool = True,
|
| 97 |
+
limit: int = 4,
|
| 98 |
+
) -> list[dict[str, Any]]:
|
| 99 |
+
rows = [r for r in _iter_rubric_rows(rubric) if r["score"] != 0]
|
| 100 |
+
if negatives_first:
|
| 101 |
+
negatives = sorted([r for r in rows if r["score"] < 0], key=lambda r: abs(r["score"]), reverse=True)
|
| 102 |
+
positives = sorted([r for r in rows if r["score"] > 0], key=lambda r: abs(r["score"]), reverse=True)
|
| 103 |
+
return (negatives + positives)[:limit]
|
| 104 |
+
return sorted(rows, key=lambda r: abs(r["score"]), reverse=True)[:limit]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _rubric_focus_list(rows: list[dict[str, Any]]) -> str:
|
| 108 |
+
if not rows:
|
| 109 |
+
return '<li class="focus-line muted">No scored rubric movement surfaced.</li>'
|
| 110 |
+
items = ""
|
| 111 |
+
for row in rows:
|
| 112 |
+
sc = row["score"]
|
| 113 |
+
sign = "+" if sc > 0 else ""
|
| 114 |
+
detector = (
|
| 115 |
+
f'<span class="focus-detector">{row["detector_id"]}</span>'
|
| 116 |
+
if row["detector_id"] else ""
|
| 117 |
+
)
|
| 118 |
+
detail = row["decision_basis"] or row["evidence"]
|
| 119 |
+
items += (
|
| 120 |
+
f'<li class="focus-line">'
|
| 121 |
+
f'<div class="focus-top"><span class="focus-key">{xt(row["key"])}</span>'
|
| 122 |
+
f'<span class="focus-score">{sign}{sc}</span></div>'
|
| 123 |
+
f'<div class="focus-detail">{detector}{detail}</div>'
|
| 124 |
+
f'</li>'
|
| 125 |
)
|
| 126 |
+
return items
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _code_integrity_summary(integrity: dict[str, Any]) -> tuple[list[tuple[str, dict[str, Any]]], list[tuple[str, dict[str, Any]]]]:
|
| 130 |
+
warnings: list[tuple[str, dict[str, Any]]] = []
|
| 131 |
+
clear: list[tuple[str, dict[str, Any]]] = []
|
| 132 |
+
for key, info in integrity.items():
|
| 133 |
+
if not isinstance(info, dict):
|
| 134 |
+
continue
|
| 135 |
+
if str(info.get("status", "PASS")).upper() == "PASS":
|
| 136 |
+
clear.append((key, info))
|
| 137 |
+
else:
|
| 138 |
+
warnings.append((key, info))
|
| 139 |
+
return warnings, clear
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _section1(result: dict[str, Any], final: int, tc: str,
|
| 143 |
+
s1: int, s2: int, s3: int, s4: int, t0: bool) -> str:
|
| 144 |
+
score = result["score"]
|
| 145 |
+
calibration = result.get("calibration_profile", {})
|
| 146 |
+
freshness = result.get("audit_freshness", {})
|
| 147 |
+
policy_name = xt(str(calibration.get("profile_name", "unknown")))
|
| 148 |
+
policy_status = xt(str(calibration.get("profile_status", "unknown")))
|
| 149 |
+
policy_mode = xt(str(calibration.get("profile_read_mode", "unknown")))
|
| 150 |
+
calibration_note = _calibration_effect_note(calibration)
|
| 151 |
+
|
| 152 |
stats = "".join(
|
| 153 |
+
f'<div class="metric-card">'
|
| 154 |
+
f'<div class="metric-value" style="color:{c}">{v}</div>'
|
| 155 |
+
f'<div class="metric-label">{l}</div>'
|
| 156 |
+
f'</div>'
|
| 157 |
for v, l, c in [
|
| 158 |
(final, "Final Score", tc),
|
| 159 |
(s1, "S1 Intent", _C["teal"]),
|
|
|
|
| 162 |
(s4, "S4 Replication", _C["green"]),
|
| 163 |
]
|
| 164 |
)
|
| 165 |
+
risks = result.get("notable_risks", [])[:5]
|
| 166 |
+
positives = result.get("notable_positive_evidence", [])[:4]
|
| 167 |
+
stage2_focus = _select_focus_rows(result.get("stage_2r_rubric", {}), negatives_first=True, limit=3)
|
| 168 |
+
stage3_focus = _select_focus_rows(result.get("stage_3_rubric", {}), negatives_first=False, limit=3)
|
| 169 |
+
alert = (
|
| 170 |
+
f'<div class="alert-t0">'
|
| 171 |
+
f'T0 hard floor triggered: direct clinical framing without an explicit boundary declaration. '
|
| 172 |
+
f'Score capped at {final}/100.'
|
| 173 |
+
f'</div>'
|
| 174 |
+
if t0 else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
+
risk_list = "".join(f'<li>{xt(str(r))}</li>' for r in risks) or "<li>No notable risks surfaced.</li>"
|
| 177 |
+
positive_list = "".join(f'<li>{xt(str(p))}</li>' for p in positives) or "<li>No notable positive evidence surfaced.</li>"
|
| 178 |
return (
|
| 179 |
f'<section id="s1">'
|
| 180 |
f'<h2 class="s-title">Executive Summary</h2>'
|
| 181 |
f'{alert}'
|
| 182 |
+
f'<div class="metric-grid">{stats}</div>'
|
| 183 |
+
f'<div class="memo-grid">'
|
| 184 |
+
f'<article class="memo-card memo-primary">'
|
| 185 |
+
f'<div class="eyebrow">TL;DR</div>'
|
| 186 |
+
f'<h3>Decision memo</h3>'
|
| 187 |
+
f'<p class="memo-text">This repository lands at <strong>{xt(str(score.get("formal_tier", "")))}</strong> '
|
| 188 |
+
f'with a final score of <strong>{final}/100</strong>. The result is driven more by '
|
| 189 |
+
f'boundary, workflow-support, and governance weaknesses than by classic code-pattern failures.</p>'
|
| 190 |
+
f'<div class="pill-row"><span class="pill">Policy: {policy_name}</span>'
|
| 191 |
+
f'<span class="pill">Status: {policy_status}</span>'
|
| 192 |
+
f'<span class="pill">Mode: {policy_mode}</span></div>'
|
| 193 |
+
f'</article>'
|
| 194 |
+
f'<article class="memo-card">'
|
| 195 |
+
f'<div class="eyebrow">Primary Risks</div>'
|
| 196 |
+
f'<h3>What pushed the review down</h3>'
|
| 197 |
+
f'<ul class="memo-list">{risk_list}</ul>'
|
| 198 |
+
f'</article>'
|
| 199 |
+
f'<article class="memo-card">'
|
| 200 |
+
f'<div class="eyebrow">Positive Evidence</div>'
|
| 201 |
+
f'<h3>What still supports reviewability</h3>'
|
| 202 |
+
f'<ul class="memo-list">{positive_list}</ul>'
|
| 203 |
+
f'</article>'
|
| 204 |
+
f'<article class="memo-card">'
|
| 205 |
+
f'<div class="eyebrow">Freshness</div>'
|
| 206 |
+
f'<h3>When to re-check</h3>'
|
| 207 |
+
f'<p class="memo-text">Review after <strong>{xt(str(freshness.get("review_after_days", "n/a")))}</strong> days. '
|
| 208 |
+
f'Expires on <strong>{xt(str(freshness.get("expires_on", "unknown")))}</strong>.</p>'
|
| 209 |
+
f'<p class="memo-note">Change-triggered re-audit now: '
|
| 210 |
+
f'{xt(str(freshness.get("change_triggered_reaudit_recommended_now", False)))}</p>'
|
| 211 |
+
f'</article>'
|
| 212 |
+
f'</div>'
|
| 213 |
+
f'<div class="memo-grid" style="margin-top:18px">'
|
| 214 |
+
f'<article class="memo-card">'
|
| 215 |
+
f'<div class="eyebrow">Stage 2R Focus</div>'
|
| 216 |
+
f'<h3>Repo-local contradictions</h3>'
|
| 217 |
+
f'<ul class="focus-list">{_rubric_focus_list(stage2_focus)}</ul>'
|
| 218 |
+
f'</article>'
|
| 219 |
+
f'<article class="memo-card">'
|
| 220 |
+
f'<div class="eyebrow">Stage 3 Focus</div>'
|
| 221 |
+
f'<h3>Accountability surfaces</h3>'
|
| 222 |
+
f'<ul class="focus-list">{_rubric_focus_list(stage3_focus)}</ul>'
|
| 223 |
+
f'</article>'
|
| 224 |
+
f'<article class="memo-card">'
|
| 225 |
+
f'<div class="eyebrow">Policy Boundary</div>'
|
| 226 |
+
f'<h3>How to read this artifact</h3>'
|
| 227 |
+
f'<p class="memo-text">{xt(calibration_note) if calibration_note else "Authoritative scoring and surfaced policy metadata are aligned in this release line."}</p>'
|
| 228 |
+
f'</article>'
|
| 229 |
+
f'</div>'
|
| 230 |
f'</section>'
|
| 231 |
)
|
| 232 |
|
| 233 |
|
| 234 |
+
def _stage_card(title: str, value: int, color: str, tip: str, focus_rows: list[dict[str, Any]], summary: str) -> str:
|
| 235 |
+
bar_color = _C["red"] if value < 40 else (_C["amber"] if value < 65 else color)
|
| 236 |
+
return (
|
| 237 |
+
f'<article class="stage-card">'
|
| 238 |
+
f'<div class="stage-card-top">'
|
| 239 |
+
f'<div class="stage-name">{title} {tip_icon(tip)}</div>'
|
| 240 |
+
f'<div class="stage-value" style="color:{bar_color}">{value}</div>'
|
| 241 |
+
f'</div>'
|
| 242 |
+
f'{svg_hbar(value, 100, bar_color)}'
|
| 243 |
+
f'<p class="stage-summary">{xt(summary)}</p>'
|
| 244 |
+
f'<ul class="focus-list">{_rubric_focus_list(focus_rows)}</ul>'
|
| 245 |
+
f'</article>'
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _config_pattern_card() -> str:
|
| 250 |
+
return (
|
| 251 |
+
f'<div class="config-pattern">'
|
| 252 |
+
f'<div class="config-copy">'
|
| 253 |
+
f'<div class="eyebrow">Configured, Not Rewritten</div>'
|
| 254 |
+
f'<h3 class="subhead">Changing review posture does not require touching the score core</h3>'
|
| 255 |
+
f'<p class="memo-text">Use <code>stem policy simulate</code> with a governed profile file when you want to preview a different review posture. '
|
| 256 |
+
f'The authoritative score path stays deterministic; the profile is surfaced as metadata and preview-only interpretation.</p>'
|
| 257 |
+
f'<p class="memo-note">If you only need the default posture, you do not need a profile file at all.</p>'
|
| 258 |
+
f'</div>'
|
| 259 |
+
f'<div class="config-grid">'
|
| 260 |
+
f'<article class="config-card">'
|
| 261 |
+
f'<div class="config-label">profile.json</div>'
|
| 262 |
+
f'<pre class="config-code">{{\n "profile_name": "strict_clinical_adjacency",\n "profile_read_mode": "mirror_only"\n}}</pre>'
|
| 263 |
+
f'</article>'
|
| 264 |
+
f'<article class="config-card">'
|
| 265 |
+
f'<div class="config-label">command</div>'
|
| 266 |
+
f'<pre class="config-code">stem policy simulate /path/to/repo --profile-file profile.json</pre>'
|
| 267 |
+
f'</article>'
|
| 268 |
+
f'<article class="config-card">'
|
| 269 |
+
f'<div class="config-label">artifact note</div>'
|
| 270 |
+
f'<pre class="config-code">Calibration Effect: mirror-only\nPolicy metadata surfaced\nFormal score unchanged</pre>'
|
| 271 |
+
f'</article>'
|
| 272 |
+
f'</div>'
|
| 273 |
+
f'</div>'
|
| 274 |
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _section2(result: dict[str, Any], s1: int, s2: int, s3: int, s4: int) -> str:
|
| 278 |
+
stage1_focus = _select_focus_rows(result.get("stage_1_rubric", {}), negatives_first=True, limit=4)
|
| 279 |
+
stage2_focus = _select_focus_rows(result.get("stage_2r_rubric", {}), negatives_first=True, limit=4)
|
| 280 |
+
stage3_focus = _select_focus_rows(result.get("stage_3_rubric", {}), negatives_first=False, limit=4)
|
| 281 |
+
stage4_focus = _select_focus_rows(result.get("stage_4_rubric", {}), negatives_first=False, limit=4)
|
| 282 |
+
cards = "".join([
|
| 283 |
+
_stage_card(
|
| 284 |
+
"Stage 1 — README Intent",
|
| 285 |
+
s1,
|
| 286 |
+
_C["teal"],
|
| 287 |
+
_STAGE_TIPS[0],
|
| 288 |
+
stage1_focus,
|
| 289 |
+
"Claim language, limitation posture, and clinical boundary wording.",
|
| 290 |
+
),
|
| 291 |
+
_stage_card(
|
| 292 |
+
"Stage 2R — Repo Consistency",
|
| 293 |
+
s2,
|
| 294 |
+
_C["purple"],
|
| 295 |
+
_STAGE_TIPS[1],
|
| 296 |
+
stage2_focus,
|
| 297 |
+
"Internal contradictions between README, workflow claims, and support surfaces.",
|
| 298 |
+
),
|
| 299 |
+
_stage_card(
|
| 300 |
+
"Stage 3 — Code / Bio Responsibility",
|
| 301 |
+
s3,
|
| 302 |
+
_C["slate"],
|
| 303 |
+
_STAGE_TIPS[2],
|
| 304 |
+
stage3_focus,
|
| 305 |
+
"Engineering accountability, provenance, and reviewable responsibility surfaces.",
|
| 306 |
+
),
|
| 307 |
+
_stage_card(
|
| 308 |
+
"Stage 4 — Replication",
|
| 309 |
+
s4,
|
| 310 |
+
_C["green"],
|
| 311 |
+
_STAGE_TIPS[3],
|
| 312 |
+
stage4_focus,
|
| 313 |
+
"Reproducibility evidence is reported separately and does not alter the formal tier.",
|
| 314 |
+
),
|
| 315 |
+
])
|
| 316 |
return (
|
| 317 |
f'<section id="s2">'
|
| 318 |
+
f'<h2 class="s-title">Decision Path {tip_icon("This section explains where the score came from, with rubric-level movement and detector-linked rationale where available.")}</h2>'
|
| 319 |
+
f'<div class="panel decision-panel">'
|
| 320 |
+
f'<div class="formula-banner">Final = 0.4 × S1 + 0.2 × S2R + 0.4 × S3 − C1_penalty | Stage 4 remains a separate replication lane.</div>'
|
| 321 |
+
f'{_config_pattern_card()}'
|
| 322 |
+
f'<div class="stage-deck">{cards}</div>'
|
| 323 |
+
f'</div></section>'
|
|
|
|
| 324 |
)
|
| 325 |
|
| 326 |
|
|
|
|
| 332 |
"status": v.get("status", "PASS"),
|
| 333 |
"evidence": [f"count={v.get('count', 0)}"],
|
| 334 |
}
|
| 335 |
+
warn_pairs, pass_pairs = _code_integrity_summary(combined)
|
| 336 |
+
warning_cards = "".join(integrity_card(k, v) for k, v in warn_pairs)
|
| 337 |
+
pass_cards = "".join(integrity_card(k, v) for k, v in pass_pairs)
|
| 338 |
hint = tip_icon(
|
| 339 |
+
"C1-C6: static code and governance checks. CC1-CC3: Layer 2 AST contract detectors. "
|
| 340 |
+
"PASS means no mapped trigger was detected in the current rule scope, not that the whole repository is mature."
|
| 341 |
+
)
|
| 342 |
+
faq = (
|
| 343 |
+
f'<div class="faq-block">'
|
| 344 |
+
f'<details class="faq-item"><summary>Why can Code Integrity contain PASS while the overall score is still low?</summary>'
|
| 345 |
+
f'<p>Because Code Integrity is a narrow detector family. The formal score is still driven mainly by Stage 1, Stage 2R, and Stage 3 evidence posture.</p></details>'
|
| 346 |
+
f'<details class="faq-item"><summary>What changed in the C4 / C5 / C6 split?</summary>'
|
| 347 |
+
f'<p>C4 is now reserved for executable fail-open exception behavior, C5 for unsupported compliance or boundary integrity claims, and C6 for mock-auth or no-auth trust-boundary signals.</p></details>'
|
| 348 |
+
f'</div>'
|
| 349 |
)
|
| 350 |
return (
|
| 351 |
f'<section id="s3">'
|
| 352 |
f'<h2 class="s-title">Code Integrity & Contract {hint}</h2>'
|
| 353 |
+
f'<div class="integrity-layout">'
|
| 354 |
+
f'<div class="panel">'
|
| 355 |
+
f'<div class="eyebrow">Warnings First</div>'
|
| 356 |
+
f'<h3 class="subhead">Mapped risk lanes that fired</h3>'
|
| 357 |
+
f'<div class="card-grid">{warning_cards or "<p class=\"memo-text\">No WARN/FAIL lanes surfaced.</p>"}</div>'
|
| 358 |
+
f'</div>'
|
| 359 |
+
f'<div class="panel">'
|
| 360 |
+
f'<div class="eyebrow">Clear Lanes</div>'
|
| 361 |
+
f'<h3 class="subhead">What stayed quiet in the current rule scope</h3>'
|
| 362 |
+
f'<div class="card-grid">{pass_cards}</div>'
|
| 363 |
+
f'{faq}'
|
| 364 |
+
f'</div>'
|
| 365 |
+
f'</div></section>'
|
| 366 |
)
|
| 367 |
|
| 368 |
|
| 369 |
def _section4(airi: dict) -> str:
|
| 370 |
if not airi or "covered_risks" not in airi:
|
| 371 |
return ""
|
| 372 |
+
pct = float(airi.get("coverage_rate", 0))
|
| 373 |
covered_n = int(airi.get("covered_count", 0))
|
| 374 |
+
total_n = int(airi.get("total_risks_in_detector_scope", 0))
|
| 375 |
+
gaps = airi.get("known_gaps", [])
|
| 376 |
all_risks = airi.get("covered_risks", [])
|
| 377 |
+
donut = svg_donut(pct, _C["green"], 98)
|
| 378 |
|
|
|
|
| 379 |
domain_counts: dict[int, int] = {d: 0 for d in range(1, 8)}
|
| 380 |
for r in all_risks:
|
| 381 |
try:
|
|
|
|
| 389 |
c_rows = "".join(airi_row(r, "covered") for r in all_risks[:24])
|
| 390 |
g_rows = "".join(airi_row(g, "gap") for g in gaps)
|
| 391 |
toggle = (
|
| 392 |
+
f'<div class="toggle-row">'
|
| 393 |
f'<div class="toggle-group">'
|
| 394 |
+
f'<button class="toggle-btn" data-view="covered" onclick="airiToggle(\'covered\')">Covered ({covered_n})</button>'
|
| 395 |
+
f'<button class="toggle-btn" data-view="gaps" onclick="airiToggle(\'gaps\')">Gaps ({len(gaps)})</button>'
|
|
|
|
|
|
|
| 396 |
f'</div>'
|
| 397 |
+
f'<span class="muted-note">Click a domain to filter.</span>'
|
| 398 |
f'</div>'
|
| 399 |
)
|
| 400 |
table = (
|
| 401 |
f'<div style="overflow-x:auto"><table class="airi-table">'
|
| 402 |
+
f'<thead><tr><th>ID</th><th>Risk</th><th>Domain</th><th>Covered by / Note</th></tr></thead>'
|
|
|
|
| 403 |
f'<tbody>{c_rows}{g_rows}</tbody></table></div>'
|
| 404 |
)
|
| 405 |
+
src = xt(airi.get("airi_version", ""))
|
| 406 |
bundle_scope = xt(airi.get("airi_bundle_scope", ""))
|
| 407 |
snapshot = xt(airi.get("airi_upstream_snapshot_date", ""))
|
| 408 |
license_name = xt(airi.get("airi_upstream_license", ""))
|
| 409 |
attribution = xt(airi.get("airi_attribution_note", ""))
|
| 410 |
hint = tip_icon(
|
| 411 |
+
"Coverage counts only risks reached through local detector mappings. "
|
| 412 |
+
"Coverage is not a safety verdict, and unmapped review concerns remain outside the numerator."
|
| 413 |
+
)
|
| 414 |
+
faq = (
|
| 415 |
+
f'<div class="faq-block" style="margin-top:18px">'
|
| 416 |
+
f'<details class="faq-item"><summary>What does 7 / 32 mean?</summary>'
|
| 417 |
+
f'<p>It means seven AIRI risk IDs are currently reached by active local detector mappings, out of thirty-two AIRI risk IDs in the current detector scope.</p></details>'
|
| 418 |
+
f'<details class="faq-item"><summary>What does “why mapped” mean?</summary>'
|
| 419 |
+
f'<p>Each covered AIRI row carries a bounded explanation built from the triggered detector, the local mapping justification, and the trigger reason surfaced by the scan.</p></details>'
|
| 420 |
+
f'<details class="faq-item"><summary>What does AIRI not prove here?</summary>'
|
| 421 |
+
f'<p>AIRI does not independently verify harm, causality, clinical failure, or legal noncompliance. It is a risk-vocabulary layer around local findings.</p></details>'
|
| 422 |
+
f'</div>'
|
| 423 |
+
)
|
| 424 |
+
mapping_pattern = (
|
| 425 |
+
f'<div class="config-pattern" style="margin-top:18px">'
|
| 426 |
+
f'<div class="config-copy">'
|
| 427 |
+
f'<div class="eyebrow">Mapped, Not Guessed</div>'
|
| 428 |
+
f'<h3 class="subhead">AIRI rows light up through active detector mappings</h3>'
|
| 429 |
+
f'<p class="memo-text">The report does not infer AIRI coverage from prose alone. '
|
| 430 |
+
f'Coverage appears when a local detector fires and a governed mapping exists in the current AIRI runtime bundle.</p>'
|
| 431 |
+
f'</div>'
|
| 432 |
+
f'<div class="config-grid">'
|
| 433 |
+
f'<article class="config-card">'
|
| 434 |
+
f'<div class="config-label">trigger</div>'
|
| 435 |
+
f'<pre class="config-code">C6_mock_auth_or_fail_open_boundary\nstatus: detected</pre>'
|
| 436 |
+
f'</article>'
|
| 437 |
+
f'<article class="config-card">'
|
| 438 |
+
f'<div class="config-label">mapping</div>'
|
| 439 |
+
f'<pre class="config-code">R2R_D5_single_external_service_dependency\n→ 72.04.02 Market Concentration</pre>'
|
| 440 |
+
f'</article>'
|
| 441 |
+
f'<article class="config-card">'
|
| 442 |
+
f'<div class="config-label">report surface</div>'
|
| 443 |
+
f'<pre class="config-code">covered_by: detector id\nwhy: bounded mapping reason</pre>'
|
| 444 |
+
f'</article>'
|
| 445 |
+
f'</div>'
|
| 446 |
+
f'</div>'
|
| 447 |
)
|
| 448 |
return (
|
| 449 |
f'<section id="s4">'
|
| 450 |
+
f'<h2 class="s-title">MIT AI Risk Repository Coverage {hint}<span class="airi-tag">{src} | airisk.mit.edu</span></h2>'
|
| 451 |
+
f'<div class="airi-layout">'
|
| 452 |
+
f'<div class="panel">'
|
| 453 |
+
f'<div class="eyebrow">Feature Explainer</div>'
|
| 454 |
+
f'<h3 class="subhead">What this section is doing</h3>'
|
| 455 |
+
f'<p class="memo-text">AIRI is used here as a bounded risk-vocabulary layer around deterministic repository findings. '
|
| 456 |
+
f'The report uses the curated runtime bundle, not the full upstream AIRI universe.</p>'
|
| 457 |
+
f'<div class="airi-kpi">'
|
| 458 |
+
f'<div class="airi-donut">{donut}</div>'
|
| 459 |
+
f'<div class="airi-copy">'
|
| 460 |
+
f'<div class="metric-inline"><strong>{covered_n} / {total_n}</strong> risks in detector scope</div>'
|
| 461 |
+
f'<div class="metric-inline">Bundle scope: <strong>{bundle_scope}</strong></div>'
|
| 462 |
+
f'<div class="metric-inline">Snapshot: <strong>{snapshot}</strong> | License: <strong>{license_name}</strong></div>'
|
| 463 |
+
f'<p class="memo-note">{attribution}</p>'
|
| 464 |
+
f'</div></div>'
|
| 465 |
+
f'{faq}'
|
| 466 |
+
f'{mapping_pattern}'
|
| 467 |
+
f'</div>'
|
| 468 |
f'<div class="panel">'
|
| 469 |
+
f'<div class="eyebrow">Coverage Explorer</div>'
|
| 470 |
+
f'<h3 class="subhead">Covered and gap rows</h3>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
f'<div class="domain-grid">{domain_boxes}</div>'
|
| 472 |
f'{toggle}{table}'
|
| 473 |
+
f'</div>'
|
| 474 |
f'</div></section>'
|
| 475 |
)
|
| 476 |
|
|
|
|
| 484 |
)
|
| 485 |
n = len(evidence_ledger)
|
| 486 |
chips = " ".join(
|
| 487 |
+
f'<span class="filter-chip{" active" if i == 0 else ""}" data-sev="{s}" onclick="filterEv(\'{s}\')">{l}</span>'
|
| 488 |
+
for i, (s, l) in enumerate(
|
| 489 |
+
[
|
| 490 |
+
("all", f"All ({n})"),
|
| 491 |
+
("fail", "FAIL"),
|
| 492 |
+
("warn", "WARN"),
|
| 493 |
+
("pass", "PASS"),
|
| 494 |
+
("info", "INFO"),
|
| 495 |
+
]
|
| 496 |
+
)
|
| 497 |
)
|
| 498 |
rows = "".join(evidence_row(ev) for ev in evidence_ledger[:200])
|
| 499 |
hint = tip_icon(
|
| 500 |
+
"Full evidence ledger from all detectors. Filter by severity. Capped at 200 entries in HTML view."
|
|
|
|
| 501 |
)
|
| 502 |
th = f'style="padding:8px 5px;font-size:11px;text-align:left;color:{_C["dgray"]}"'
|
| 503 |
return (
|
| 504 |
f'<section id="s5">'
|
| 505 |
f'<h2 class="s-title">Evidence Detail {hint}</h2>'
|
| 506 |
f'<div class="panel">'
|
| 507 |
+
f'<div class="toggle-row" style="margin-bottom:14px">{chips}</div>'
|
| 508 |
f'<div style="overflow-x:auto">'
|
| 509 |
f'<table style="width:100%;border-collapse:collapse">'
|
| 510 |
f'<thead><tr style="background:{_C["lgray"]}">'
|
| 511 |
f'<th style="padding:8px 5px;width:16px"></th>'
|
| 512 |
+
f'<th {th}>SEV</th><th {th}>Detector</th><th {th}>Finding</th><th {th}>File</th>'
|
|
|
|
| 513 |
f'</tr></thead>'
|
| 514 |
f'<tbody>{rows}</tbody></table></div></div></section>'
|
| 515 |
)
|
| 516 |
|
| 517 |
|
| 518 |
def render_html(result: dict[str, Any]) -> str:
|
| 519 |
+
score = result["score"]
|
| 520 |
+
final = int(score["final_score"])
|
| 521 |
+
tier = str(score["formal_tier"])
|
| 522 |
+
tc = tier_color(tier)
|
| 523 |
target = xt(str(result["target"]["name"]))
|
| 524 |
+
date = xt(str(result.get("generated_at_local", "")))
|
| 525 |
s1 = int(score.get("stage_1_readme_intent", 0))
|
| 526 |
s2 = int(score.get("stage_2_repo_local_consistency", 0))
|
| 527 |
s3 = int(score.get("stage_3_code_bio", 0))
|
| 528 |
s4 = int(result.get("replication_score", 0))
|
| 529 |
t0 = result.get("classification", {}).get("t0_hard_floor", False)
|
| 530 |
|
| 531 |
+
css = build_css(tc)
|
| 532 |
+
nav = _nav()
|
| 533 |
+
hero = _hero(score, final, tier, tc, target, date)
|
| 534 |
+
sec1 = _section1(result, final, tc, s1, s2, s3, s4, t0)
|
| 535 |
+
sec2 = _section2(result, s1, s2, s3, s4)
|
| 536 |
+
sec3 = _section3(result.get("code_integrity", {}), result.get("code_contract", {}))
|
| 537 |
+
sec4 = _section4(result.get("airi_risk_coverage", {}))
|
| 538 |
+
sec5 = _section5(result.get("evidence_ledger", []))
|
| 539 |
+
ver = xt(result.get("stem_ai_version", ""))
|
| 540 |
|
| 541 |
return f"""<!DOCTYPE html>
|
| 542 |
<html lang="en">
|
stem_ai/render_html_styles.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""CSS and JS template strings for STEM BIO-AI interactive HTML report."""
|
| 2 |
from __future__ import annotations
|
|
|
|
| 3 |
from .render_html_components import _C
|
| 4 |
|
| 5 |
|
|
@@ -8,109 +9,238 @@ def build_css(tc: str) -> str:
|
|
| 8 |
g, a, r = _C["green"], _C["amber"], _C["red"]
|
| 9 |
p, lg, mg, dg, w = _C["purple"], _C["lgray"], _C["mgray"], _C["dgray"], _C["white"]
|
| 10 |
return f"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
| 12 |
html{{scroll-behavior:smooth}}
|
| 13 |
-
body{{
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
.
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
.
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
.
|
| 39 |
-
.
|
| 40 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
.card.expanded .caret{{transform:rotate(180deg)}}
|
| 42 |
.caret{{display:block;transition:transform .2s}}
|
| 43 |
-
.
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
[data-tooltip]{{position:relative}}
|
| 48 |
-
[data-tooltip]::after{{
|
| 49 |
-
bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);
|
| 50 |
-
background:{n};color:#fff;padding:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
box-shadow:0 4px 12px rgba(0,0,0,.25)}}
|
| 55 |
[data-tooltip]:hover::after,[data-tooltip]:focus::after{{opacity:1}}
|
| 56 |
-
.toggle-
|
| 57 |
-
|
| 58 |
-
.toggle-btn{{padding:
|
| 59 |
-
background:{w};border:none;cursor:pointer;transition:all .2s}}
|
| 60 |
.toggle-btn:hover{{background:{lg}}}
|
| 61 |
.toggle-btn.active{{background:{n};color:#fff}}
|
| 62 |
-
.
|
| 63 |
-
.
|
| 64 |
-
|
| 65 |
-
.domain-card{{display:flex;align-items:center;gap:10px;padding:
|
| 66 |
-
background:{w};border:1.5px solid {mg};border-radius:8px;
|
| 67 |
-
transition:border-color .15s,box-shadow .15s;user-select:none}}
|
| 68 |
.domain-card:hover{{border-color:{t};box-shadow:0 2px 8px rgba(0,0,0,.1)}}
|
| 69 |
-
.domain-card.domain-active{{border-color:var(--d-color,{t});
|
| 70 |
-
|
| 71 |
-
.domain-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
.
|
| 75 |
-
.
|
| 76 |
-
.
|
| 77 |
-
|
|
|
|
| 78 |
.airi-table tr:hover{{background:{lg}}}
|
| 79 |
.ev-row{{transition:background .15s;cursor:default}}
|
| 80 |
.ev-row:hover{{background:{lg}}}
|
| 81 |
-
.
|
| 82 |
-
margin-bottom:24px;font-weight:600;font-size:13px;
|
| 83 |
-
border-left:4px solid rgba(0,0,0,.2)}}
|
| 84 |
-
.stats-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
|
| 85 |
-
gap:14px;margin-bottom:28px}}
|
| 86 |
-
.stat-card{{background:{w};border-radius:8px;padding:18px 16px;
|
| 87 |
-
box-shadow:0 1px 3px rgba(0,0,0,.07);text-align:center;
|
| 88 |
-
transition:box-shadow .2s}}
|
| 89 |
-
.stat-card:hover{{box-shadow:0 4px 12px rgba(0,0,0,.12)}}
|
| 90 |
-
.stat-val{{font-size:28px;font-weight:700;margin-bottom:4px}}
|
| 91 |
-
.stat-lbl{{font-size:11px;color:{dg};font-weight:600;text-transform:uppercase;
|
| 92 |
-
letter-spacing:.05em}}
|
| 93 |
-
.risk-item{{padding:12px 16px;border-left:4px solid {a};margin-bottom:10px;
|
| 94 |
-
background:{w};border-radius:0 8px 8px 0;font-size:12px;color:{n};
|
| 95 |
-
line-height:1.5;transition:border-color .2s,box-shadow .2s}}
|
| 96 |
-
.risk-item:hover{{border-left-color:{r};box-shadow:0 2px 8px rgba(0,0,0,.08)}}
|
| 97 |
-
.filter-chip{{display:inline-flex;align-items:center;padding:4px 12px;
|
| 98 |
-
border-radius:14px;font-size:11px;font-weight:600;cursor:pointer;
|
| 99 |
-
border:1px solid {mg};margin:0 6px 8px 0;transition:all .15s;
|
| 100 |
-
background:{w};user-select:none}}
|
| 101 |
.filter-chip.active{{background:{n};color:#fff;border-color:{n}}}
|
| 102 |
.filter-chip:hover:not(.active){{background:{lg}}}
|
| 103 |
-
.
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
.
|
| 110 |
-
.
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
JS = r"""
|
|
@@ -126,7 +256,7 @@ var _airiDomain = 0;
|
|
| 126 |
|
| 127 |
function _applyAiriFilter() {
|
| 128 |
document.querySelectorAll('.airi-covered,.airi-gaps').forEach(function(r) {
|
| 129 |
-
var viewOk
|
| 130 |
var domainOk = (_airiDomain === 0 || r.dataset.domain == _airiDomain);
|
| 131 |
r.style.display = (viewOk && domainOk) ? '' : 'none';
|
| 132 |
});
|
|
@@ -145,8 +275,7 @@ function filterDomain(num) {
|
|
| 145 |
document.querySelectorAll('.domain-card').forEach(function(c) {
|
| 146 |
var active = (c.dataset.domain == _airiDomain);
|
| 147 |
c.classList.toggle('domain-active', active);
|
| 148 |
-
c.style.setProperty('--d-color', active
|
| 149 |
-
? c.querySelector('.domain-num').style.background : '');
|
| 150 |
});
|
| 151 |
_applyAiriFilter();
|
| 152 |
}
|
|
@@ -163,7 +292,7 @@ function filterEv(sev) {
|
|
| 163 |
function updateNav() {
|
| 164 |
var cur = '';
|
| 165 |
document.querySelectorAll('section[id]').forEach(function(s) {
|
| 166 |
-
if (window.scrollY >= s.offsetTop -
|
| 167 |
});
|
| 168 |
document.querySelectorAll('.nav-link').forEach(function(l) {
|
| 169 |
l.classList.toggle('active', l.getAttribute('href') === '#' + cur);
|
|
|
|
| 1 |
"""CSS and JS template strings for STEM BIO-AI interactive HTML report."""
|
| 2 |
from __future__ import annotations
|
| 3 |
+
|
| 4 |
from .render_html_components import _C
|
| 5 |
|
| 6 |
|
|
|
|
| 9 |
g, a, r = _C["green"], _C["amber"], _C["red"]
|
| 10 |
p, lg, mg, dg, w = _C["purple"], _C["lgray"], _C["mgray"], _C["dgray"], _C["white"]
|
| 11 |
return f"""
|
| 12 |
+
:root{{
|
| 13 |
+
--navy:{n};
|
| 14 |
+
--teal:{t};
|
| 15 |
+
--green:{g};
|
| 16 |
+
--amber:{a};
|
| 17 |
+
--red:{r};
|
| 18 |
+
--purple:{p};
|
| 19 |
+
--slate:{_C["slate"]};
|
| 20 |
+
--bg:{lg};
|
| 21 |
+
--panel:{w};
|
| 22 |
+
--line:{mg};
|
| 23 |
+
--muted:{dg};
|
| 24 |
+
--tier:{tc};
|
| 25 |
+
--shadow-soft:0 10px 28px rgba(13,31,60,.08);
|
| 26 |
+
--shadow-strong:0 20px 48px rgba(13,31,60,.16);
|
| 27 |
+
--radius:18px;
|
| 28 |
+
}}
|
| 29 |
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
| 30 |
html{{scroll-behavior:smooth}}
|
| 31 |
+
body{{
|
| 32 |
+
font-family:"Segoe UI Variable Text","Segoe UI",ui-sans-serif,sans-serif;
|
| 33 |
+
background:
|
| 34 |
+
radial-gradient(circle at top left, rgba(26,111,168,.09), transparent 28%),
|
| 35 |
+
linear-gradient(180deg, #f5f8fc 0%, {lg} 42%, #eef3f9 100%);
|
| 36 |
+
color:{n};
|
| 37 |
+
font-size:14px;
|
| 38 |
+
line-height:1.6;
|
| 39 |
+
}}
|
| 40 |
+
h1,h2,h3,.stage-name,.metric-value,.tier{{
|
| 41 |
+
font-family:"Iowan Old Style","Palatino Linotype","Book Antiqua",Georgia,serif;
|
| 42 |
+
}}
|
| 43 |
+
.nav{{
|
| 44 |
+
position:sticky;top:0;z-index:60;
|
| 45 |
+
background:rgba(255,255,255,.86);
|
| 46 |
+
backdrop-filter:blur(14px);
|
| 47 |
+
display:flex;gap:4px;overflow-x:auto;
|
| 48 |
+
border-bottom:1px solid rgba(13,31,60,.08);
|
| 49 |
+
box-shadow:0 8px 24px rgba(13,31,60,.06);
|
| 50 |
+
}}
|
| 51 |
+
.nav-link{{
|
| 52 |
+
padding:14px 18px;color:{dg};font-size:12px;font-weight:700;
|
| 53 |
+
text-decoration:none;transition:color .2s,background .2s,border-color .2s;
|
| 54 |
+
border-bottom:3px solid transparent;white-space:nowrap;
|
| 55 |
+
}}
|
| 56 |
+
.nav-link:hover{{color:{n};background:rgba(13,31,60,.04)}}
|
| 57 |
+
.nav-link.active{{color:{n};border-bottom-color:{t}}}
|
| 58 |
+
.hero{{
|
| 59 |
+
background:
|
| 60 |
+
radial-gradient(circle at top right, rgba(255,255,255,.12), transparent 26%),
|
| 61 |
+
linear-gradient(135deg, {n} 0%, #163457 48%, #24537B 100%);
|
| 62 |
+
color:#fff;
|
| 63 |
+
padding:42px 48px 46px;
|
| 64 |
+
display:grid;
|
| 65 |
+
grid-template-columns:minmax(180px,220px) 1fr;
|
| 66 |
+
gap:30px;
|
| 67 |
+
align-items:center;
|
| 68 |
+
}}
|
| 69 |
+
.hero-left{{display:flex;justify-content:center}}
|
| 70 |
+
.hero-right{{display:flex;flex-direction:column;gap:10px}}
|
| 71 |
+
.hero h1{{font-size:34px;font-weight:700;line-height:1.1;letter-spacing:-.02em}}
|
| 72 |
+
.eyebrow{{font-size:11px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:rgba(13,31,60,.62)}}
|
| 73 |
+
.hero .eyebrow{{color:rgba(255,255,255,.62)}}
|
| 74 |
+
.hero-meta{{display:flex;gap:10px;flex-wrap:wrap;align-items:center}}
|
| 75 |
+
.tier{{
|
| 76 |
+
display:inline-block;background:{tc};color:#fff;font-size:13px;font-weight:700;
|
| 77 |
+
padding:6px 16px;border-radius:999px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.12);
|
| 78 |
+
}}
|
| 79 |
+
.hero-chip{{
|
| 80 |
+
display:inline-flex;align-items:center;padding:6px 12px;border-radius:999px;
|
| 81 |
+
border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.08);
|
| 82 |
+
color:rgba(255,255,255,.84);font-size:11px;font-weight:700;
|
| 83 |
+
}}
|
| 84 |
+
.lede{{font-size:14px;color:rgba(255,255,255,.82);max-width:720px}}
|
| 85 |
+
.content{{max-width:1180px;margin:0 auto;padding:34px 24px 54px}}
|
| 86 |
+
section{{margin-bottom:34px;scroll-margin-top:72px}}
|
| 87 |
+
.s-title{{font-size:24px;font-weight:700;color:{n};margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}}
|
| 88 |
+
.subhead{{font-size:22px;font-weight:700;color:{n};margin:2px 0 10px}}
|
| 89 |
+
.panel{{
|
| 90 |
+
background:{w};
|
| 91 |
+
border:1px solid rgba(13,31,60,.07);
|
| 92 |
+
border-radius:var(--radius);
|
| 93 |
+
padding:24px;
|
| 94 |
+
box-shadow:var(--shadow-soft);
|
| 95 |
+
}}
|
| 96 |
+
.memo-grid,.integrity-layout,.airi-layout{{display:grid;grid-template-columns:repeat(12,1fr);gap:18px}}
|
| 97 |
+
.memo-card,.stage-card{{
|
| 98 |
+
background:{w};
|
| 99 |
+
border:1px solid rgba(13,31,60,.08);
|
| 100 |
+
border-radius:var(--radius);
|
| 101 |
+
padding:20px 20px 18px;
|
| 102 |
+
box-shadow:var(--shadow-soft);
|
| 103 |
+
}}
|
| 104 |
+
.memo-primary{{background:linear-gradient(180deg,#ffffff 0%,#f8fbff 100%)}}
|
| 105 |
+
.memo-card:nth-child(1){{grid-column:span 6}}
|
| 106 |
+
.memo-card:nth-child(2),.memo-card:nth-child(3),.memo-card:nth-child(4){{grid-column:span 6}}
|
| 107 |
+
.memo-text{{font-size:13px;color:{n}}}
|
| 108 |
+
.memo-note,.muted-note,.muted{{font-size:11px;color:{dg};line-height:1.5}}
|
| 109 |
+
.memo-list{{padding-left:18px;display:grid;gap:8px;font-size:12px;color:{n}}}
|
| 110 |
+
.pill-row{{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}}
|
| 111 |
+
.pill{{display:inline-flex;align-items:center;padding:5px 11px;border-radius:999px;background:{lg};border:1px solid {mg};font-size:11px;font-weight:700;color:{n}}}
|
| 112 |
+
.metric-grid{{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:14px;margin-bottom:18px}}
|
| 113 |
+
.metric-card{{
|
| 114 |
+
background:{w};border:1px solid rgba(13,31,60,.08);border-radius:16px;
|
| 115 |
+
padding:18px 16px;box-shadow:var(--shadow-soft);text-align:center
|
| 116 |
+
}}
|
| 117 |
+
.metric-value{{font-size:32px;font-weight:700;line-height:1;margin-bottom:6px}}
|
| 118 |
+
.metric-label{{font-size:11px;color:{dg};font-weight:800;text-transform:uppercase;letter-spacing:.08em}}
|
| 119 |
+
.alert-t0{{background:{r};color:#fff;padding:15px 18px;border-radius:14px;margin-bottom:18px;font-weight:700;box-shadow:var(--shadow-soft)}}
|
| 120 |
+
.decision-panel{{padding:18px}}
|
| 121 |
+
.formula-banner{{
|
| 122 |
+
margin-bottom:16px;padding:12px 14px;border-radius:14px;background:#eef5fb;
|
| 123 |
+
border:1px solid #d7e5f4;font-size:12px;color:{n};font-family:"Consolas","SFMono-Regular",monospace
|
| 124 |
+
}}
|
| 125 |
+
.config-pattern{{
|
| 126 |
+
display:grid;
|
| 127 |
+
grid-template-columns:minmax(0,1.2fr) minmax(0,1.8fr);
|
| 128 |
+
gap:18px;
|
| 129 |
+
margin-bottom:18px;
|
| 130 |
+
padding:18px;
|
| 131 |
+
border-radius:16px;
|
| 132 |
+
background:linear-gradient(180deg,#fbfdff 0%,#f2f7fc 100%);
|
| 133 |
+
border:1px solid #dbe6f1;
|
| 134 |
+
}}
|
| 135 |
+
.config-copy{{display:grid;gap:10px;align-content:start}}
|
| 136 |
+
.config-grid{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}}
|
| 137 |
+
.config-card{{
|
| 138 |
+
background:{w};
|
| 139 |
+
border:1px solid rgba(13,31,60,.08);
|
| 140 |
+
border-radius:14px;
|
| 141 |
+
padding:12px;
|
| 142 |
+
box-shadow:var(--shadow-soft);
|
| 143 |
+
}}
|
| 144 |
+
.config-label{{
|
| 145 |
+
font-size:10px;
|
| 146 |
+
font-weight:800;
|
| 147 |
+
letter-spacing:.12em;
|
| 148 |
+
text-transform:uppercase;
|
| 149 |
+
color:{dg};
|
| 150 |
+
margin-bottom:8px;
|
| 151 |
+
}}
|
| 152 |
+
.config-code{{
|
| 153 |
+
font-family:"Consolas","SFMono-Regular",monospace;
|
| 154 |
+
font-size:11px;
|
| 155 |
+
line-height:1.55;
|
| 156 |
+
white-space:pre-wrap;
|
| 157 |
+
color:{n};
|
| 158 |
+
background:#f7fafc;
|
| 159 |
+
border:1px solid #e3ebf3;
|
| 160 |
+
border-radius:10px;
|
| 161 |
+
padding:10px 11px;
|
| 162 |
+
overflow:auto;
|
| 163 |
+
}}
|
| 164 |
+
.stage-deck{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}}
|
| 165 |
+
.stage-card-top{{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px}}
|
| 166 |
+
.stage-name{{font-size:18px;font-weight:700;color:{n};line-height:1.2}}
|
| 167 |
+
.stage-value{{font-size:26px;font-weight:700;line-height:1}}
|
| 168 |
+
.stage-summary{{font-size:12px;color:{dg};margin:12px 0 10px}}
|
| 169 |
+
.focus-list{{list-style:none;display:grid;gap:10px}}
|
| 170 |
+
.focus-line{{padding-top:10px;border-top:1px solid rgba(13,31,60,.08)}}
|
| 171 |
+
.focus-top{{display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:4px}}
|
| 172 |
+
.focus-key{{font-size:12px;font-weight:700;color:{n}}}
|
| 173 |
+
.focus-score{{font-size:12px;font-weight:800;color:{n}}}
|
| 174 |
+
.focus-detail{{font-size:11px;color:{dg};line-height:1.55}}
|
| 175 |
+
.focus-detector{{display:inline-flex;margin-right:8px;padding:2px 8px;border-radius:999px;background:#f2f6fa;border:1px solid {mg};font-family:"Consolas","SFMono-Regular",monospace;color:{p}}}
|
| 176 |
+
.card-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}}
|
| 177 |
+
.card{{background:{w};border-radius:14px;box-shadow:var(--shadow-soft);cursor:pointer;transition:box-shadow .2s,transform .15s;overflow:hidden;outline:none;border:1px solid rgba(13,31,60,.06)}}
|
| 178 |
+
.card:hover{{box-shadow:var(--shadow-strong);transform:translateY(-2px)}}
|
| 179 |
+
.card:focus{{box-shadow:0 0 0 3px rgba(26,111,168,.2)}}
|
| 180 |
+
.card.expanded{{box-shadow:var(--shadow-strong)}}
|
| 181 |
.card.expanded .caret{{transform:rotate(180deg)}}
|
| 182 |
.caret{{display:block;transition:transform .2s}}
|
| 183 |
+
.faq-block{{display:grid;gap:10px;margin-top:16px}}
|
| 184 |
+
.faq-item{{border-top:1px solid rgba(13,31,60,.08);padding-top:10px}}
|
| 185 |
+
.faq-item summary{{cursor:pointer;font-size:12px;font-weight:800;color:{n};list-style:none}}
|
| 186 |
+
.faq-item summary::-webkit-details-marker{{display:none}}
|
| 187 |
+
.faq-item p{{margin-top:8px;font-size:12px;color:{dg}}}
|
| 188 |
+
.tip-icon{{display:inline-flex;align-items:center;justify-content:center;width:17px;height:17px;border-radius:50%;background:{mg};color:{dg};font-size:10px;font-weight:800;cursor:help;position:relative;flex-shrink:0;vertical-align:middle}}
|
| 189 |
[data-tooltip]{{position:relative}}
|
| 190 |
+
[data-tooltip]::after{{
|
| 191 |
+
content:attr(data-tooltip);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);
|
| 192 |
+
background:{n};color:#fff;padding:9px 12px;border-radius:10px;font-size:11px;line-height:1.45;white-space:normal;
|
| 193 |
+
max-width:280px;width:max-content;opacity:0;pointer-events:none;transition:opacity .2s;z-index:200;text-align:left;
|
| 194 |
+
font-weight:500;box-shadow:0 10px 28px rgba(0,0,0,.25)
|
| 195 |
+
}}
|
|
|
|
| 196 |
[data-tooltip]:hover::after,[data-tooltip]:focus::after{{opacity:1}}
|
| 197 |
+
.toggle-row{{display:flex;align-items:center;gap:12px;flex-wrap:wrap}}
|
| 198 |
+
.toggle-group{{display:flex;border:1px solid {mg};border-radius:999px;overflow:hidden;width:fit-content;background:{w}}}
|
| 199 |
+
.toggle-btn{{padding:8px 18px;font-size:12px;font-weight:800;color:{dg};background:{w};border:none;cursor:pointer;transition:all .2s}}
|
|
|
|
| 200 |
.toggle-btn:hover{{background:{lg}}}
|
| 201 |
.toggle-btn.active{{background:{n};color:#fff}}
|
| 202 |
+
.risk-item{{padding:12px 15px;border-left:4px solid {a};margin-bottom:10px;background:{w};border-radius:0 10px 10px 0;font-size:12px;color:{n};line-height:1.5;transition:border-color .2s,box-shadow .2s}}
|
| 203 |
+
.risk-item:hover{{border-left-color:{r};box-shadow:0 2px 8px rgba(0,0,0,.08)}}
|
| 204 |
+
.domain-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:18px}}
|
| 205 |
+
.domain-card{{display:flex;align-items:center;gap:10px;padding:11px 14px;background:{w};border:1.5px solid {mg};border-radius:12px;transition:border-color .15s,box-shadow .15s;user-select:none}}
|
|
|
|
|
|
|
| 206 |
.domain-card:hover{{border-color:{t};box-shadow:0 2px 8px rgba(0,0,0,.1)}}
|
| 207 |
+
.domain-card.domain-active{{border-color:var(--d-color,{t});box-shadow:0 0 0 3px color-mix(in srgb,var(--d-color,{t}) 18%,transparent)}}
|
| 208 |
+
.domain-num{{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;color:#fff;font-size:11px;font-weight:800;flex-shrink:0}}
|
| 209 |
+
.domain-label{{font-size:11px;color:{n};font-weight:700;flex:1;line-height:1.3}}
|
| 210 |
+
.domain-cnt{{font-size:12px;font-weight:800;flex-shrink:0}}
|
| 211 |
+
.airi-tag{{font-size:12px;color:{dg};font-weight:500;margin-left:4px}}
|
| 212 |
+
.airi-kpi{{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-top:14px}}
|
| 213 |
+
.airi-copy{{display:grid;gap:8px;min-width:240px}}
|
| 214 |
+
.metric-inline{{font-size:12px;color:{n}}}
|
| 215 |
+
.airi-table{{width:100%;border-collapse:collapse}}
|
| 216 |
+
.airi-table th{{padding:10px 8px;font-size:11px;text-align:left;color:{dg};background:{lg};font-weight:800;text-transform:uppercase;letter-spacing:.05em}}
|
| 217 |
.airi-table tr:hover{{background:{lg}}}
|
| 218 |
.ev-row{{transition:background .15s;cursor:default}}
|
| 219 |
.ev-row:hover{{background:{lg}}}
|
| 220 |
+
.filter-chip{{display:inline-flex;align-items:center;padding:5px 12px;border-radius:999px;font-size:11px;font-weight:800;cursor:pointer;border:1px solid {mg};margin:0 6px 8px 0;transition:all .15s;background:{w};user-select:none}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
.filter-chip.active{{background:{n};color:#fff;border-color:{n}}}
|
| 222 |
.filter-chip:hover:not(.active){{background:{lg}}}
|
| 223 |
+
.footer{{font-size:11px;color:{dg};text-align:center;padding:24px 0;border-top:1px solid rgba(13,31,60,.08);margin-top:40px}}
|
| 224 |
+
@media(max-width:1000px){{
|
| 225 |
+
.metric-grid{{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
| 226 |
+
.stage-deck{{grid-template-columns:1fr}}
|
| 227 |
+
.memo-card,.memo-card:nth-child(1),.memo-card:nth-child(2),.memo-card:nth-child(3),.memo-card:nth-child(4){{grid-column:span 12}}
|
| 228 |
+
.integrity-layout,.airi-layout{{grid-template-columns:1fr}}
|
| 229 |
+
.config-pattern{{grid-template-columns:1fr}}
|
| 230 |
+
.config-grid{{grid-template-columns:1fr}}
|
| 231 |
+
}}
|
| 232 |
+
@media(max-width:760px){{
|
| 233 |
+
.hero{{padding:28px 22px;grid-template-columns:1fr}}
|
| 234 |
+
.content{{padding:24px 14px 42px}}
|
| 235 |
+
.nav-link{{padding:12px 12px;font-size:11px}}
|
| 236 |
+
.metric-grid{{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
| 237 |
+
}}
|
| 238 |
+
@media(max-width:520px){{
|
| 239 |
+
.metric-grid{{grid-template-columns:1fr}}
|
| 240 |
+
.s-title{{font-size:21px}}
|
| 241 |
+
.hero h1{{font-size:28px}}
|
| 242 |
+
}}
|
| 243 |
+
"""
|
| 244 |
|
| 245 |
|
| 246 |
JS = r"""
|
|
|
|
| 256 |
|
| 257 |
function _applyAiriFilter() {
|
| 258 |
document.querySelectorAll('.airi-covered,.airi-gaps').forEach(function(r) {
|
| 259 |
+
var viewOk = r.classList.contains('airi-' + _airiView);
|
| 260 |
var domainOk = (_airiDomain === 0 || r.dataset.domain == _airiDomain);
|
| 261 |
r.style.display = (viewOk && domainOk) ? '' : 'none';
|
| 262 |
});
|
|
|
|
| 275 |
document.querySelectorAll('.domain-card').forEach(function(c) {
|
| 276 |
var active = (c.dataset.domain == _airiDomain);
|
| 277 |
c.classList.toggle('domain-active', active);
|
| 278 |
+
c.style.setProperty('--d-color', active ? c.querySelector('.domain-num').style.background : '');
|
|
|
|
| 279 |
});
|
| 280 |
_applyAiriFilter();
|
| 281 |
}
|
|
|
|
| 292 |
function updateNav() {
|
| 293 |
var cur = '';
|
| 294 |
document.querySelectorAll('section[id]').forEach(function(s) {
|
| 295 |
+
if (window.scrollY >= s.offsetTop - 88) cur = s.id;
|
| 296 |
});
|
| 297 |
document.querySelectorAll('.nav-link').forEach(function(l) {
|
| 298 |
l.classList.toggle('active', l.getAttribute('href') === '#' + cur);
|