"""SVG generators, card builders, and shared helpers for STEM BIO-AI HTML report.""" from __future__ import annotations import math from typing import Any _C = { "navy": "#0D1F3C", # deeper, truer navy (AIRI-aligned) "teal": "#1A6FA8", # richer mid-blue "green": "#1A7A47", # botanical green "amber": "#C07B10", # warm amber "red": "#9B2335", # AIRI crimson "purple": "#5B3D8A", # deeper purple "slate": "#2D4A6E", # slate-blue "lgray": "#F8FAFC", # cooler background "mgray": "#E4EBF5", # cooler divider "dgray": "#5A6B80", # bluer muted text "white": "#FFFFFF", } _TIER_COLOR = {"T0": _C["red"], "T1": _C["red"], "T2": _C["amber"], "T3": _C["teal"], "T4": _C["green"]} _STATUS_COLOR = {"PASS": _C["green"], "WARN": _C["amber"], "FAIL": _C["red"]} # AIRI 7-domain palette (matches MIT AI Risk Navigator) _AIRI_DOMAIN_COLORS = { 1: "#B5272A", # Discrimination & Toxicity 2: "#1E3A6E", # Privacy & Security 3: "#C07020", # Misinformation 4: "#6B3FA0", # Malicious Actors & Misuse 5: "#1B7A4E", # Human-Computer Interaction 6: "#7A6520", # Socioeconomic & Environmental 7: "#1A5568", # AI System Safety, Failures & Limitations } _AIRI_DOMAIN_NAMES = { 1: "Discrimination & Toxicity", 2: "Privacy & Security", 3: "Misinformation", 4: "Malicious Actors & Misuse", 5: "Human-Computer Interaction", 6: "Socioeconomic & Environmental", 7: "AI System Safety, Failures & Limitations", } _STAGE_TIPS = [ "Stage 1: Scans README/docs for hype language, overconfidence claims, and missing clinical disclaimers.", "Stage 2R: Cross-checks API names advertised in README against actual code exports and __all__ lists.", "Stage 3: AST scan for clinical safety patterns: PII validators, exception handling, dependency pinning.", "Stage 4: Evaluates reproducibility artifacts: seed fixation, dataset pinning, environment lock files.", ] # Human-readable labels for regulatory requirement IDs (shared by MD and HTML renderers). # Stored as PLAIN text — each renderer escapes via its own xt()/_xt() helper. REQ_LABELS: dict[str, str] = { "EU_AI_ACT_ARTICLE_10": "EU AI Act Art. 10 — Data Governance", "EU_AI_ACT_ARTICLE_12": "EU AI Act Art. 12 — Record Keeping", "EU_AI_ACT_ARTICLE_13": "EU AI Act Art. 13 — Transparency", "EU_AI_ACT_ARTICLE_15": "EU AI Act Art. 15 — Robustness", "ICH_M15_SECTION_2_1_2_CONTEXT_OF_USE": "ICH M15 §2.1.2 — Context of Use", "ICH_M15_SECTION_3_VERIFICATION": "ICH M15 §3 — Verification", "ICH_M15_SECTION_3_VALIDATION": "ICH M15 §3 — Validation & Applicability", "ICH_M15_SECTION_4_1_MAP": "ICH M15 §4.1 — Model Analysis Plan", "ICH_M15_SECTION_4_3_CODE_SUBMISSION": "ICH M15 §4.3 — Code & Data Submission", "IMDRF_CLINICAL_CONTEXT_BOUNDARY_SIGNAL": "IMDRF SaMD — Clinical Context Boundary", "IMDRF_ANALYTICAL_VALIDATION_SIGNAL": "IMDRF SaMD — Analytical Validation", "COMPLIANCE_CLAIM_GROUNDING_SIGNAL": "Compliance Claim Grounding", } # HTML badge for each traceability status. REQ_STATUS_BADGE: dict[str, str] = { "signal_only": f'⚠ Signal only', "partially_aligned": f'~ Partially aligned', "aligned": f'✓ Aligned', "not_detected": f'✗ Not detected', } def tier_color(tier: str) -> str: for k, v in _TIER_COLOR.items(): if k in tier: return v return _C["dgray"] def status_color(s: str) -> str: return _STATUS_COLOR.get(str(s).upper(), _C["dgray"]) def xt(t: str) -> str: return str(t).replace("&", "&").replace("<", "<").replace(">", ">") def tip_icon(tip: str) -> str: safe = tip.replace('"', """) return f'?' # ── SVG generators ───────────────────────────────────────────────────────────── def svg_gauge(score: int, color: str, size: int = 160) -> str: cx, cy, r = size // 2, size // 2, size // 2 - 16 a = math.radians(180 - min(max(score, 0), 100) * 1.8) ex, ey = round(cx + r * math.cos(a), 2), round(cy - r * math.sin(a), 2) lx, ly, rx, ry, sw = cx - r, cy, cx + r, cy, 14 h = size // 2 + 28 return ( f'' f'' f'' f'{score}' f'/ 100' ) def svg_donut(pct: float, color: str, size: int = 90) -> str: cx = cy = size // 2 r = size // 2 - 10 circ = 2 * math.pi * r dash, gap = round(pct * circ, 2), round((1 - pct) * circ, 2) return ( f'' f'' f'' f'{int(pct*100)}%' ) def svg_hbar(value: int, max_val: int = 100, color: str = _C["teal"], height: int = 20, width: int = 270) -> str: fw = round(value / max_val * width) if max_val else 0 return ( f'' f'' f'' ) # ── Card builders ────────────────────────────────────────────────────────────── def integrity_card(key: str, info: dict[str, Any]) -> str: status = str(info.get("status", "PASS")) color = status_color(status) evl = info.get("evidence", []) preview = xt(str(evl[0])[:80]) if evl else "No evidence captured" label_map = { "C1_hardcoded_credentials": "C1 Hardcoded Credentials", "C2_dependency_pinning": "C2 Dependency Pinning", "C3_dead_or_deprecated_patient_adjacent_paths": "C3 Deprecated Patient Paths", "C4_exception_handling_clinical_adjacent_paths": "C4 Fail-Open Exceptions", "C5_compliance_boundary_integrity": "C5 Compliance Boundary Integrity", "C6_mock_auth_or_fail_open_boundary": "C6 Mock Auth / Fail-Open Boundary", "CC1_clinical_zero_default": "CC1 Clinical Zero Default", "CC2_api_contract": "CC2 API Contract", "CC3_shallow_validator": "CC3 Shallow Validator", } label = label_map.get(key, key.replace("_", " ").title()) items = "".join(f'
  • {xt(str(e)[:120])}
  • ' for e in evl[:8]) return ( f'' ) def domain_card(d_num: int, covered_count: int, gap_count: int = 0) -> str: if d_num == 0: color = _C["navy"] name = "All Domains" else: color = _AIRI_DOMAIN_COLORS.get(d_num, _C["dgray"]) name = _AIRI_DOMAIN_NAMES.get(d_num, f"Domain {d_num}") total = covered_count + gap_count dim = "opacity:.55;cursor:pointer" if total == 0 else "cursor:pointer" badge = f"{covered_count}/{gap_count}" return ( f'
    ' f'{"All" if d_num == 0 else d_num}' f'{name}' f'{badge}' f'
    ' ) def _compact_labels(items: list[str], *, limit: int = 5) -> str: values = [str(item).strip() for item in items if str(item).strip()] if not values: return "" shown = values[:limit] extra = max(0, len(values) - len(shown)) text = ",".join(shown) if extra: text += f" (+{extra} more)" return text def airi_row(r: dict[str, Any], status: str = "covered") -> str: color = _C["green"] if status == "covered" else _C["amber"] if status == "covered": details = r.get("mapping_details", []) primary = str(r.get("primary_detector_id", "")).strip() secondary = [str(det).strip() for det in r.get("secondary_detector_ids", []) if str(det).strip()] if details: preview_bits = [] for detail in details[:5]: detector_id = str(detail.get("detector_id", "")).strip() reason = str(detail.get("trigger_reason") or detail.get("mapping_justification") or "").strip() if detector_id and reason: preview_bits.append(f"{detector_id}: {reason}") elif detector_id: preview_bits.append(detector_id) if preview_bits: prefix = f"primary={primary}" if primary else "" if secondary: prefix += f"; also={_compact_labels(secondary)}" if prefix else f"also={_compact_labels(secondary)}" detail_text = " | ".join(preview_bits) extra = max(0, len(details) - len(preview_bits)) if extra: detail_text += f" | (+{extra} more mapping details)" det = f"{prefix} | {detail_text}" if prefix else detail_text else: det = _compact_labels(r.get("covered_by", [])) else: if primary and secondary: det = f"primary={primary}; also={_compact_labels(secondary)}" elif primary: det = f"primary={primary}" else: det = _compact_labels(r.get("covered_by", [])) else: det = r.get("note", "")[:70] sub = xt(r.get("subdomain_label", r.get("subdomain_id", "")))[:32] det_text = xt(str(det)) if "(+" not in str(det): det_text = det_text[:65] cls = "airi-covered" if status == "covered" else "airi-gaps" dom = str(r.get("subdomain_id", "0")).split(".")[0] return ( f'' f'{xt(r["id"])}' f'' f'{xt(r.get("title",""))[:55]}' f'{sub}' f'' f'{det_text}' ) def evidence_row(ev: dict[str, Any]) -> str: sev = str(ev.get("severity", ev.get("status", "INFO"))).upper() sc = _STATUS_COLOR.get(sev, _C["dgray"]) det = xt(str(ev.get("detector", ev.get("check", "")))[:32]) raw_msg = ( ev.get("explanation") or ev.get("message") or ev.get("detail") or ev.get("snippet") or "" ) msg = xt(str(raw_msg)[:90]) if raw_msg else "No human-readable evidence summary available." loc = xt(str(ev.get("file", ev.get("path", "")))[:50]) return ( f'' f'' f'' f'{sev}' f'{det}' f'{msg}' f'{loc}' f'' )