d0e3621192
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>
297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""Mail-V2 Action library — turn findings into 'what to do where'.
|
|
|
|
Each finding type maps to a concrete action recommendation. The
|
|
mapping is intentionally pattern-matched (not LLM-generated): the
|
|
audit is deterministic, so the corrective action must be too.
|
|
|
|
Patterns matched by:
|
|
- finding `id` prefix (mc-impressum-handelsregister → impressum/HR)
|
|
- severity_reason (factually_wrong / missing / misclassified)
|
|
- mismatch_type (dsi_under_actual / table_under_actual / ...)
|
|
- cookie field name (country / duration / processing_company)
|
|
|
|
Fallback: "Manuelle Prüfung beim DSB erforderlich" with finding hint.
|
|
|
|
Returns an Action dict:
|
|
- title: short imperative ("Sitzland ergänzen")
|
|
- target: where to fix ("DSE / Vendor-Liste")
|
|
- detail: extended explanation
|
|
- aggregation_key: groupBy key for bulk recommendations
|
|
("missing_country" / "long_retention" / ...)
|
|
- effort: "low" | "med" | "hi"
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
|
|
|
|
@dataclass
|
|
class Action:
|
|
title: str
|
|
target: str
|
|
detail: str
|
|
aggregation_key: str | None
|
|
effort: str # low | med | hi
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
# ── Field-level actions for cookie inventory ──────────────────────
|
|
|
|
def cookie_field_missing_action(field: str, cookie_name: str,
|
|
vendor: str) -> Action | None:
|
|
"""Return action when a cookie field is missing (or unknown)."""
|
|
if field == "country":
|
|
return Action(
|
|
title="Sitzland ergänzen",
|
|
target="DSE / Vendor-Tabelle",
|
|
detail=(f"Für Cookie '{cookie_name}' (Vendor {vendor or '—'}) "
|
|
"ist kein Sitzland der verarbeitenden Stelle angegeben. "
|
|
"Art. 13 Abs. 1 lit. a DSGVO verlangt die Identität + "
|
|
"Anschrift des Verantwortlichen."),
|
|
aggregation_key="missing_country",
|
|
effort="low",
|
|
)
|
|
if field == "duration":
|
|
return Action(
|
|
title="Speicherdauer angeben",
|
|
target="DSE / Cookie-Tabelle",
|
|
detail=(f"Cookie '{cookie_name}' hat keine deklarierte "
|
|
"Speicherdauer. Art. 13 Abs. 2 lit. a DSGVO verlangt "
|
|
"die Dauer der Speicherung oder ein Kriterium dafür."),
|
|
aggregation_key="missing_duration",
|
|
effort="low",
|
|
)
|
|
if field == "retention_grounds":
|
|
return Action(
|
|
title="Löschfrist + Rechtsgrundlage angeben",
|
|
target="Löschkonzept + DSE",
|
|
detail=(f"Für Cookie '{cookie_name}' fehlt eine konkrete "
|
|
"Löschfrist. § 35 BDSG + DSK-Standard verlangen ein "
|
|
"dokumentiertes Löschkonzept pro Datenkategorie."),
|
|
aggregation_key="missing_retention",
|
|
effort="med",
|
|
)
|
|
if field == "processing_company":
|
|
return Action(
|
|
title="Verantwortliche Stelle nennen",
|
|
target="DSE",
|
|
detail=(f"Cookie '{cookie_name}' nennt keinen Verantwortlichen "
|
|
"(Firma + Adresse). Art. 13 Abs. 1 DSGVO Pflichtangabe."),
|
|
aggregation_key="missing_processing_company",
|
|
effort="low",
|
|
)
|
|
if field == "third_country":
|
|
return Action(
|
|
title="Drittlandtransfer absichern",
|
|
target="DSE + AVV-Anhang",
|
|
detail=(f"Cookie '{cookie_name}' (Vendor {vendor or '—'}) "
|
|
"verarbeitet Daten außerhalb EU/EWR. Erforderlich: "
|
|
"Angemessenheitsbeschluss, Standardvertragsklauseln "
|
|
"oder ausdrückliche Einwilligung (Art. 44 ff. DSGVO)."),
|
|
aggregation_key="missing_third_country",
|
|
effort="med",
|
|
)
|
|
if field == "category":
|
|
return Action(
|
|
title="Kategorie zuordnen",
|
|
target="Cookie-Tabelle",
|
|
detail=(f"Cookie '{cookie_name}' hat keine Kategorie. EDPB "
|
|
"Cookie-Sweep verlangt: technisch notwendig / "
|
|
"Statistik / Marketing / Externe Medien."),
|
|
aggregation_key="missing_category",
|
|
effort="low",
|
|
)
|
|
return None
|
|
|
|
|
|
# ── Status-level actions (UNDOC / ORPH / MISMATCH) ───────────────
|
|
|
|
def cookie_status_action(status_code: str, cookie_name: str,
|
|
vendor: str) -> Action | None:
|
|
if status_code == "UNDOC":
|
|
return Action(
|
|
title="Cookie deklarieren oder entfernen",
|
|
target="CMP-Config + DSE",
|
|
detail=(f"Cookie '{cookie_name}' wird im Browser gesetzt, ist "
|
|
"aber nicht in DSE/Cookie-Tabelle deklariert. § 25 "
|
|
"TDDDG: entweder Deklaration nachholen oder Cookie "
|
|
"blockieren (CMP-Trigger prüfen)."),
|
|
aggregation_key="undoc_cookies",
|
|
effort="med",
|
|
)
|
|
if status_code == "ORPH":
|
|
return Action(
|
|
title="Veraltete Cookie-Angabe entfernen",
|
|
target="DSE / Cookie-Tabelle",
|
|
detail=(f"Cookie '{cookie_name}' ist in DSE deklariert, wird "
|
|
"aber im Live-Browser nicht gesetzt. Veraltete Angabe "
|
|
"entfernen, um Transparenz zu wahren."),
|
|
aggregation_key="orphan_cookies",
|
|
effort="low",
|
|
)
|
|
if status_code == "MISMATCH":
|
|
return Action(
|
|
title="Cookie-Werte korrigieren",
|
|
target="DSE / Cookie-Tabelle",
|
|
detail=(f"Cookie '{cookie_name}': deklarierte Werte weichen von "
|
|
"tatsächlich gesetzten ab. Tabelle anpassen oder "
|
|
"Cookie-Setup korrigieren."),
|
|
aggregation_key="mismatch_cookies",
|
|
effort="med",
|
|
)
|
|
return None
|
|
|
|
|
|
# ── Retention-comparison actions ─────────────────────────────────
|
|
|
|
def retention_action(retention_finding: dict) -> Action | None:
|
|
mt = retention_finding.get("mismatch_type")
|
|
cookie = retention_finding.get("cookie_name", "—")
|
|
if mt == "dsi_under_actual":
|
|
return Action(
|
|
title="DSE-Speicherdauer korrigieren",
|
|
target="DSE",
|
|
detail=(f"DSE behauptet für '{cookie}' kürzere Speicherdauer als "
|
|
"real. Wert in DSE auf reale Dauer anpassen ODER Cookie-"
|
|
"Setup auf deklarierte Dauer reduzieren."),
|
|
aggregation_key="dsi_too_short",
|
|
effort="low",
|
|
)
|
|
if mt == "table_under_actual":
|
|
return Action(
|
|
title="Cookie-Tabelle korrigieren",
|
|
target="Cookie-Tabelle / CMP",
|
|
detail=(f"Cookie-Tabelle behauptet für '{cookie}' kürzere Dauer "
|
|
"als real. Wert anpassen oder Cookie-Lifetime reduzieren."),
|
|
aggregation_key="table_too_short",
|
|
effort="low",
|
|
)
|
|
if mt == "dsi_vs_table":
|
|
return Action(
|
|
title="DSE und Cookie-Tabelle synchronisieren",
|
|
target="DSE + Cookie-Tabelle",
|
|
detail=(f"DSE und Cookie-Tabelle geben unterschiedliche Werte "
|
|
f"für '{cookie}' an. Werte abgleichen."),
|
|
aggregation_key="dsi_table_mismatch",
|
|
effort="low",
|
|
)
|
|
if mt == "actual_under_table":
|
|
return Action(
|
|
title="Speicherdauer-Cap dokumentieren (Safari-ITP)",
|
|
target="DSE",
|
|
detail=(f"Cookie '{cookie}' lebt real kürzer als deklariert — "
|
|
"wahrscheinlich Safari ITP 7-Tage-Cap. In DSE ergänzen: "
|
|
"'Auf Safari-Geräten kann die Speicherdauer durch ITP "
|
|
"verkürzt werden.'"),
|
|
aggregation_key="safari_itp",
|
|
effort="low",
|
|
)
|
|
return None
|
|
|
|
|
|
# ── Reachability actions (B1) ────────────────────────────────────
|
|
|
|
def reachability_action(rb1: dict) -> Action | None:
|
|
if rb1.get("passed"):
|
|
return None
|
|
reason = rb1.get("severity_reason")
|
|
if reason == "missing":
|
|
return Action(
|
|
title="Cookie-Einstellungen-Link im Footer ergänzen",
|
|
target="Website-Footer (alle Seiten)",
|
|
detail=("Art. 7 Abs. 3 DSGVO: Widerruf muss so einfach wie "
|
|
"Erteilung sein. Footer-Link 'Cookie-Einstellungen' "
|
|
"ergänzen, der den CMP direkt öffnet (kein neuer Tab, "
|
|
"kein Zwischendokument)."),
|
|
aggregation_key="footer_reachability",
|
|
effort="low",
|
|
)
|
|
if reason == "misclassified":
|
|
return Action(
|
|
title="CMP direkt öffnen statt neuer Tab",
|
|
target="Footer-Link-Config",
|
|
detail=("Bestehender Footer-Link öffnet die CMP nicht direkt. "
|
|
"JavaScript-Trigger umstellen: kein target=_blank, "
|
|
"keine externe Policy-Seite — CMP-Layer direkt öffnen."),
|
|
aggregation_key="footer_reachability",
|
|
effort="low",
|
|
)
|
|
if reason == "factually_wrong":
|
|
return Action(
|
|
title="Eigenen CMP statt Browser-Verweis",
|
|
target="Footer + CMP",
|
|
detail=("Nutzer wird auf Browser-Einstellungen verwiesen — das "
|
|
"ist nach LfDI BW kein gleichwertiger Widerruf. Eigenen "
|
|
"CMP-Re-Open-Mechanismus implementieren."),
|
|
aggregation_key="footer_reachability",
|
|
effort="med",
|
|
)
|
|
return None
|
|
|
|
|
|
# ── Generic finding → action ────────────────────────────────────
|
|
|
|
_ID_PATTERNS = {
|
|
"handelsregister": ("HR-Eintrag im Impressum ergänzen",
|
|
"Impressum",
|
|
"§ 5 Abs. 1 Nr. 4 TMG: Registereintrag mit "
|
|
"Registergericht + HR-Nr."),
|
|
"ust-id": ("USt-IdNr. ergänzen",
|
|
"Impressum",
|
|
"§ 5 Abs. 1 Nr. 6 TMG: USt-IdNr. falls vorhanden."),
|
|
"vertretungsberechtig": ("Vertretungsberechtigte Person nennen",
|
|
"Impressum",
|
|
"§ 5 Abs. 1 Nr. 1 TMG"),
|
|
"aufsichtsbehoerde": ("Aufsichtsbehörde nennen",
|
|
"Impressum",
|
|
"§ 5 Abs. 1 Nr. 3 TMG (regulierte Branchen)"),
|
|
"berufsordnung": ("Berufsrechtliche Angaben ergänzen",
|
|
"Impressum",
|
|
"§ 5 Abs. 1 Nr. 5 TMG"),
|
|
"dsb": ("DSB benennen",
|
|
"DSE",
|
|
"Art. 37 ff. DSGVO: Datenschutzbeauftragten benennen + DSE "
|
|
"ergänzen."),
|
|
"odr": ("OS-Link auf EU-Plattform ergänzen",
|
|
"Impressum / AGB",
|
|
"Art. 14 EU-VO 524/2013 (B2C-Onlineshop)"),
|
|
"widerrufsbelehrung": ("Widerrufsbelehrung anpassen",
|
|
"Widerruf-Dokument",
|
|
"§ 312g BGB + Art. 246a EGBGB Muster-Widerrufs-"
|
|
"belehrung."),
|
|
}
|
|
|
|
|
|
def derive_generic_action(finding_id: str, label: str,
|
|
hint: str) -> Action | None:
|
|
"""Pattern-match a generic MC finding ID to an action template."""
|
|
fid = (finding_id or "").lower()
|
|
haystack = f"{fid} {label.lower()}"
|
|
for kw, (title, target, detail) in _ID_PATTERNS.items():
|
|
if kw in haystack:
|
|
return Action(
|
|
title=title,
|
|
target=target,
|
|
detail=detail + (f" Hinweis: {hint[:200]}" if hint else ""),
|
|
aggregation_key=f"mc_{kw}",
|
|
effort="low",
|
|
)
|
|
if hint:
|
|
return Action(
|
|
title="Manuelle Prüfung beim DSB",
|
|
target=label or "Doc",
|
|
detail=hint[:400],
|
|
aggregation_key=None,
|
|
effort="med",
|
|
)
|
|
return None
|
|
|
|
|
|
def action_for_finding(finding_id: str, severity: str, label: str,
|
|
hint: str) -> Action | None:
|
|
"""Top-level entry point for MC findings."""
|
|
return derive_generic_action(finding_id, label, hint)
|