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>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user