Files
breakpilot-compliance/backend-compliance/compliance/services/mail_render_v2/_actions.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

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)