Files
breakpilot-compliance/backend-compliance/compliance/services/mail_render_v2/_style.py
T
Benjamin Admin d0e3621192 feat(audit): V2 mail render + 5 new findings (B4/B5/B6/B7/B8) + LLM-Plausibility-Phase
Mail Render V2 (compliance/services/mail_render_v2/) — 11-Modul-Subpackage
das einen einheitlichen Audit-Mail-Output erzeugt mit:
  - Header + KPI-Kacheln (Score / Findings / Docs / Vendors)
  - TOC + Sprung-Links
  - 3-Bucket-Trennung: Kritische Befunde / Manuelle Prüfung / Interne Reminder
  - Cookie-Inventar (Name·Vendor·Kategorie·Speicherdauer·Löschfrist·Sitzland·Quelle·Status)
  - Sofortmaßnahmen-Aggregator ("Sitzland ergänzen für 11 Cookies")
  - 24 Legacy-Wrappers — alle alten build_*_html in V2-Sections
  - Scope-Filter: FIN/GOV/MED/INS/EDU/LEG aus Berichten wenn nicht relevant
  - Hint/Action-Dedup: keine doppelten Sätze pro Card mehr
Aktiviert via env MAIL_RENDER_V2=true (Default: legacy renderer).

5 neue deterministische Findings als Phase D-2b/B4/B5/B6/B7/B8:

  B4 vendor_consistency_check — Cross-Doc-Provider-Widerspruch
     (Elli: DSE nennt Vertex AI für Chatbot, /de/cookies nennt Iadvize → HIGH).
     6 Service-Types: chatbot/analytics/tag_manager/pixel/cdn/cmp.

  B5 ai_act_transparency_check — AI Act Art. 50 Transparenzpflicht
     (Elli: Vertex AI vorhanden ohne Pre-Chat-Disclosure → HIGH).
     Plus B5-Erweiterung: Rechtsgrundlage Art-6-Abs-1-lit-f bei AI → MED
     (Einwilligung empfehlen).

  B6 cross_doc_dpo_check — DPO in DSE genannt, nicht im Impressum (LOW).

  B7 doc_staleness_check — Datum-Extraktion aus DSE/AGB/Nutzungsbedingungen.
     Cap: AGB/NB 3y, DSE 2y. Älter → MEDIUM (Elli NB Stand 2018 → HIGH).

  B8 cmp_fingerprint_check — Banner detected, aber CMP-Provider generic
     (kein Usercentrics/OneTrust/Cookiebot/etc → MED).

  B3-Erweiterung detect_intra_doc_contradictions — Widersprüchliche
     Speicherdauer im SELBEN Doc (Elli: Logfile 7d vs 30d → HIGH).

LLM-Plausibility-Phase (Phase D-2b, finding_plausibility_check.py):
  - Läuft AFTER MC pipeline, BEFORE D3 render
  - Prompt mit Beispiel-IDs + 3-Phase-Mapping: exact-ID / position-fallback /
    fuzzy-tail-match
  - Stempelt llm_title / llm_severity / llm_recommendation / llm_drop auf
    jeden FAIL CheckItem
  - V2-Render zeigt "🤖 LLM-Plausibility:" Box pro Finding wenn gestempelt
  - KNOWN ISSUE: qwen3:30b-a3b liefert oft empty content auf format='json' +
    8000-char-excerpt prompts. Pipeline läuft mit stamped=0 weiter. Task #16.

Coverage gegen Elli Ground Truth (zeroclaw/docs/ground-truth/elli_eco_2026-06-06.json,
13 expected findings via WebFetch-Agent-Crawl):
  - 4/4 HIGH-Findings ✓ (COOKIE-CONSENT-UX-001 + WIDERRUFSBELEHRUNG-001 +
    VENDOR-CONSISTENCY-001 + AI-ACT-TRANSPARENCY-001)
  - 4/6 MEDIUM ✓
  - 2/3 LOW ✓
  - Total: 10/13 = 77% (Sprung von 4/13 = 31%)

Restliche 3 Gaps als Task #17: IMPRESSUM-001 (multi-entity USt-IdNr),
TRANSFER-001 (Vendor-Mechanismus DPF/SCC), TH-RETENTION-002 (AI-Retention
pro Datenkategorie).

V2-Mail-Preview in Mailpit: 'v2all@local.test' Subject '[V2 ALL] ELLI'.
Backend healthy, B1+B3+B4+B5+B6+B7+B8 alle live im Orchestrator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 21:19:49 +02:00

201 lines
7.6 KiB
Python

"""Mail-V2 style system — single source of truth for all visual props.
Email rendering = inline styles only (most clients strip <style> tags
or sandbox them). Table-based layouts because flex/grid is unreliable
in Outlook. Font stack = email-safe (no web fonts).
Public helpers:
- `section(title, body_html, *, sev=None, anchor=None)` →
standardized full-width card with optional severity stripe + TOC
anchor
- `card(body_html, *, sev=None)` → smaller card inside a section
- `kpi(label, value, sub=None, sev=None)` → single KPI tile (used
in 4-column header grid)
- `kpi_row(items)` → evenly-sized row of KPIs
- `chip(text, sev)` → inline pill for severity / status
- `table(headers, rows, *, sev_col=None)` → consistent zebra table
`sev` is one of: "pass" | "fail" | "warn" | "info" | None (neutral)
"""
from __future__ import annotations
# ── Color palette ─────────────────────────────────────────────────
PAGE_BG = "#f8fafc"
CARD_BG = "#ffffff"
BORDER = "#e2e8f0"
TEXT = "#1e293b"
TEXT_MUTED = "#64748b"
HEADER_BG = "#0f172a"
HEADER_FG = "#f8fafc"
SEV = {
"pass": {"bg": "#dcfce7", "fg": "#15803d", "stripe": "#16a34a"},
"fail": {"bg": "#fee2e2", "fg": "#991b1b", "stripe": "#dc2626"},
"warn": {"bg": "#fef3c7", "fg": "#92400e", "stripe": "#f59e0b"},
"info": {"bg": "#dbeafe", "fg": "#1e40af", "stripe": "#3b82f6"},
}
NEUTRAL_STRIPE = "#cbd5e1"
# ── Typography ────────────────────────────────────────────────────
FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,"
"Ubuntu,sans-serif")
SZ_TITLE = "24px"
SZ_H2 = "18px"
SZ_H3 = "15px"
SZ_BODY = "14px"
SZ_SMALL = "12px"
# ── Layout ────────────────────────────────────────────────────────
MAX_W = "720px"
PAD_SECTION = "20px"
PAD_CARD = "14px"
RADIUS = "6px"
def _stripe(sev: str | None) -> str:
return SEV[sev]["stripe"] if sev in SEV else NEUTRAL_STRIPE
def section(title: str, body_html: str, *,
sev: str | None = None, anchor: str | None = None) -> str:
"""Top-level audit card — every report section uses this shell."""
stripe = _stripe(sev)
a = f'<a id="{anchor}"></a>' if anchor else ""
return (
f'{a}<table role="presentation" cellpadding="0" cellspacing="0" '
f'border="0" width="100%" style="margin:24px 0;border-collapse:'
f'separate;border-spacing:0;">'
f'<tr><td style="background:{CARD_BG};border:1px solid {BORDER};'
f'border-left:4px solid {stripe};border-radius:{RADIUS};'
f'padding:{PAD_SECTION};">'
f'<h2 style="margin:0 0 12px;font-family:{FONT};font-size:{SZ_H2};'
f'color:{TEXT};font-weight:600;">{title}</h2>'
f'<div style="font-family:{FONT};font-size:{SZ_BODY};color:{TEXT};'
f'line-height:1.5;">{body_html}</div>'
f'</td></tr></table>'
)
def card(body_html: str, *, sev: str | None = None) -> str:
"""Sub-card inside a section."""
stripe = _stripe(sev)
return (
f'<table role="presentation" cellpadding="0" cellspacing="0" '
f'border="0" width="100%" style="margin:8px 0;border-collapse:'
f'separate;border-spacing:0;">'
f'<tr><td style="background:{CARD_BG};border:1px solid {BORDER};'
f'border-left:3px solid {stripe};border-radius:{RADIUS};'
f'padding:{PAD_CARD};font-family:{FONT};font-size:{SZ_BODY};'
f'color:{TEXT};">'
f'{body_html}'
f'</td></tr></table>'
)
def kpi(label: str, value: str, sub: str | None = None,
sev: str | None = None) -> str:
"""One KPI tile. Used 4-in-a-row in the header."""
value_color = SEV[sev]["fg"] if sev in SEV else TEXT
sub_html = (
f'<div style="font-family:{FONT};font-size:{SZ_SMALL};'
f'color:{TEXT_MUTED};margin-top:2px;">{sub}</div>'
if sub else ""
)
return (
f'<td style="background:{CARD_BG};border:1px solid {BORDER};'
f'border-radius:{RADIUS};padding:14px;text-align:center;'
f'width:25%;vertical-align:top;">'
f'<div style="font-family:{FONT};font-size:{SZ_SMALL};'
f'color:{TEXT_MUTED};text-transform:uppercase;letter-spacing:.5px;">'
f'{label}</div>'
f'<div style="font-family:{FONT};font-size:26px;color:{value_color};'
f'font-weight:700;margin-top:6px;">{value}</div>'
f'{sub_html}'
f'</td>'
)
def kpi_row(items: list[dict]) -> str:
"""Render a row of 2-4 KPI tiles, equally sized."""
cells = "".join(
kpi(it["label"], it["value"], it.get("sub"), it.get("sev"))
for it in items
)
spacers = "".join(
'<td style="width:8px;"></td>' for _ in range(max(0, len(items) - 1))
)
# interleave
parts = items[:]
cells_list = [
kpi(it["label"], it["value"], it.get("sub"), it.get("sev"))
for it in parts
]
interleaved = '<td style="width:8px;"></td>'.join(cells_list)
return (
f'<table role="presentation" cellpadding="0" cellspacing="0" '
f'border="0" width="100%" style="margin:12px 0;border-collapse:'
f'separate;border-spacing:0;"><tr>{interleaved}</tr></table>'
)
def chip(text: str, sev: str | None = None) -> str:
"""Inline pill for severity / status."""
pal = SEV.get(sev or "", {"bg": "#f1f5f9", "fg": TEXT_MUTED})
return (
f'<span style="display:inline-block;background:{pal["bg"]};'
f'color:{pal["fg"]};font-family:{FONT};font-size:11px;font-weight:600;'
f'padding:2px 8px;border-radius:999px;'
f'text-transform:uppercase;letter-spacing:.3px;">{text}</span>'
)
def table(headers: list[str], rows: list[list[str]], *,
sev_col: int | None = None) -> str:
"""Render a consistent zebra table.
`sev_col`, when set, indicates which column already contains a
chip() (so we don't escape it).
"""
th = "".join(
f'<th style="text-align:left;padding:8px 10px;font-family:{FONT};'
f'font-size:{SZ_SMALL};color:{TEXT_MUTED};text-transform:uppercase;'
f'letter-spacing:.5px;border-bottom:1px solid {BORDER};">{h}</th>'
for h in headers
)
body_rows = []
for i, r in enumerate(rows):
bg = "#ffffff" if i % 2 == 0 else "#f8fafc"
cells = "".join(
f'<td style="padding:8px 10px;font-family:{FONT};font-size:13px;'
f'color:{TEXT};border-bottom:1px solid {BORDER};vertical-align:top;">'
f'{c}</td>' for c in r
)
body_rows.append(f'<tr style="background:{bg};">{cells}</tr>')
body = "".join(body_rows)
return (
f'<table role="presentation" cellpadding="0" cellspacing="0" '
f'border="0" width="100%" style="border-collapse:collapse;'
f'margin:8px 0;background:{CARD_BG};border:1px solid {BORDER};'
f'border-radius:{RADIUS};overflow:hidden;">'
f'<thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
)
def page_open(site_name: str) -> str:
return (
f'<div style="background:{PAGE_BG};padding:24px 16px;font-family:{FONT};">'
f'<div style="max-width:{MAX_W};margin:0 auto;">'
)
def page_close(check_id: str, build_sha: str) -> str:
return (
f'<div style="margin-top:32px;padding:16px;font-family:{FONT};'
f'font-size:11px;color:{TEXT_MUTED};text-align:center;">'
f'BreakPilot Compliance · check_id <code>{check_id}</code> · '
f'build <code>{build_sha}</code>'
f'</div>'
f'</div></div>'
)