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:
Benjamin Admin
2026-06-06 21:19:49 +02:00
parent c2c8783fee
commit d0e3621192
27 changed files with 4426 additions and 3 deletions
@@ -20,6 +20,7 @@ import time
from compliance.services.retention_comparator import (
build_retention_theme_summary,
compare_retention,
detect_intra_doc_contradictions,
extract_retention_claims,
)
@@ -54,6 +55,11 @@ def run_b3(state: dict) -> None:
if not dsi_text:
return
# Intra-doc contradictions are independent of cmp_vendors — run
# them first so they survive the early-return below.
intra = detect_intra_doc_contradictions(dsi_text)
state["retention_intra_doc"] = intra
cookie_records: list[dict] = []
cookie_names: list[str] = []
vendor_names: list[str] = []
@@ -0,0 +1,78 @@
"""B4 wiring — Cross-Doc Vendor-Consistency check + HTML block.
Activated after B1+B3 in the orchestrator. The check itself is
deterministic (no LLM); it scans DSE + cookie texts for known
service providers per service type and flags every mismatch.
The mail renderer reads `state["vendor_consistency_findings"]` and
`state["vendor_consistency_html"]` directly — no further wiring.
"""
from __future__ import annotations
import html
import logging
from compliance.services.vendor_consistency_check import (
check_vendor_consistency,
)
logger = logging.getLogger(__name__)
def run_b4(state: dict) -> None:
findings = check_vendor_consistency(state)
state["vendor_consistency_findings"] = findings
if not findings:
return
state["vendor_consistency_html"] = _render(findings)
logger.info(
"B4 Vendor-Consistency: %d findings (HIGH=%d, MEDIUM=%d)",
len(findings),
sum(1 for f in findings if (f.get("severity") or "") == "HIGH"),
sum(1 for f in findings if (f.get("severity") or "") == "MEDIUM"),
)
def _render(findings: list[dict]) -> str:
rows = []
for f in findings:
sev = (f.get("severity") or "").upper()
color = "#dc2626" if sev == "HIGH" else "#f59e0b"
dse = ", ".join(f.get("dse_providers") or []) or "<em></em>"
cookie = ", ".join(f.get("cookie_providers") or []) or "<em></em>"
rows.append(
"<tr>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>"
f"{html.escape((f.get('service_type') or '').replace('_',' ').title())}"
"</td>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>"
f"{dse}</td>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>"
f"{cookie}</td>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;"
f"color:{color};font-weight:600;'>"
f"{sev} {html.escape(f.get('severity_reason') or '')}</td>"
"</tr>"
)
return (
"<div style='margin:24px 0;padding:16px;border-left:4px solid #dc2626;"
"background:#fff1f2;border-radius:4px;'>"
"<h2 style='margin:0 0 8px;color:#991b1b;font-size:16px;'>"
"VENDOR-CONSISTENCY-001 — Vendor-Konsistenz DSE ↔ Cookies</h2>"
"<p style='margin:0 0 8px;font-size:14px;color:#3f3f46;'>"
f"<strong>{len(findings)}</strong> Provider-Widersprüche zwischen "
"Datenschutzerklärung und Cookie-Seite. Beispiel Elli: "
"DSE = Vertex AI für Chatbot, Cookies-Seite = Iadvize.</p>"
"<table style='width:100%;border-collapse:collapse;font-size:13px;"
"margin-top:8px;background:#fff;'>"
"<thead><tr style='background:#f1f5f9;'>"
"<th style='text-align:left;padding:6px 10px;'>Service-Typ</th>"
"<th style='text-align:left;padding:6px 10px;'>In DSE</th>"
"<th style='text-align:left;padding:6px 10px;'>Auf Cookies-Seite</th>"
"<th style='text-align:left;padding:6px 10px;'>Severity</th>"
"</tr></thead>"
f"<tbody>{''.join(rows)}</tbody>"
"</table>"
"</div>"
)
@@ -0,0 +1,81 @@
"""B5 wiring — AI-Act Art. 50 Transparenzpflicht-Check + HTML block.
Runs after B4 (vendor-consistency). Deterministic detection of
AI-Provider mentions + disclosure-phrase mentions. When an AI is
present but no Art-50-disclosure → HIGH finding; when both present
the renderer flags MEDIUM/manual-review because the LIVE pre-chat
UI hint cannot be verified without a consent-tester DOM scan.
"""
from __future__ import annotations
import html
import logging
from compliance.services.ai_act_transparency_check import (
check_ai_act_transparency,
)
logger = logging.getLogger(__name__)
def run_b5(state: dict) -> None:
findings = check_ai_act_transparency(state)
state["ai_act_findings"] = findings
if not findings:
return
state["ai_act_html"] = _render(findings)
logger.info(
"B5 AI-Act: %d findings (HIGH=%d, MEDIUM=%d)",
len(findings),
sum(1 for f in findings if (f.get("severity") or "") == "HIGH"),
sum(1 for f in findings if (f.get("severity") or "") == "MEDIUM"),
)
def _render(findings: list[dict]) -> str:
cards = []
for f in findings:
sev = (f.get("severity") or "").upper()
color = "#dc2626" if sev == "HIGH" else "#f59e0b"
vendors_html = ""
if f.get("ai_vendors"):
chips = "".join(
f"<span style='display:inline-block;background:#f1f5f9;"
f"padding:2px 8px;border-radius:999px;margin:2px 4px 2px 0;"
f"font-size:11px;'>{html.escape(v.get('vendor') or '')}</span>"
for v in f["ai_vendors"]
)
vendors_html = (
"<div style='margin-top:6px;font-size:13px;'>"
f"<strong>Erkannte AI-Vendors:</strong> {chips}</div>"
)
signals_html = (
f"<div style='font-size:12px;color:#475569;margin-top:6px;'>"
f"<em>{html.escape(f.get('detected_signals') or '')}</em></div>"
)
cards.append(
f"<div style='margin:12px 0;padding:14px;background:#fff;"
f"border-left:3px solid {color};border-radius:4px;'>"
f"<div style='font-weight:600;color:{color};font-size:14px;'>"
f"{sev} · {html.escape(f.get('check_id') or '')}</div>"
f"<div style='font-size:14px;margin-top:4px;'>"
f"<strong>{html.escape(f.get('title') or '')}</strong></div>"
f"<div style='font-size:12px;color:#64748b;margin-top:2px;'>"
f"{html.escape(f.get('norm') or '')}</div>"
f"{vendors_html}{signals_html}"
f"<div style='font-size:13px;margin-top:8px;background:#dcfce7;"
f"padding:8px 10px;border-radius:4px;'>"
f"<strong>→ Empfehlung:</strong> "
f"{html.escape(f.get('action') or '')}</div>"
"</div>"
)
return (
"<div style='margin:24px 0;padding:16px;border-left:4px solid #dc2626;"
"background:#fef2f2;border-radius:4px;'>"
"<h2 style='margin:0 0 8px;color:#991b1b;font-size:16px;'>"
"🤖 AI-Act Art. 50 — Transparenzpflicht KI-Interaktion"
"</h2>"
+ "".join(cards) +
"</div>"
)
@@ -0,0 +1,97 @@
"""B6 / B7 / B8 wiring — DPO-cross-doc, doc-staleness, CMP-fingerprint.
Three small, deterministic checks added after B5. Each writes one or
more findings into `state["extra_findings"]` and a tiny HTML block
into `state["extra_findings_html"]` that the V2 renderer concatenates
between B5 (AI-Act) and the legacy section block.
"""
from __future__ import annotations
import html
import logging
from compliance.services.cmp_fingerprint_check import check_cmp_fingerprint
from compliance.services.cross_doc_dpo_check import check_dpo_cross_doc
from compliance.services.doc_staleness_check import check_staleness
logger = logging.getLogger(__name__)
def run_b6b7b8(state: dict) -> None:
findings: list[dict] = []
dpo = check_dpo_cross_doc(state)
if dpo:
findings.append(dpo)
stale = check_staleness(state)
findings.extend(stale)
cmp = check_cmp_fingerprint(state)
if cmp:
findings.append(cmp)
state["extra_findings"] = findings
if findings:
state["extra_findings_html"] = _render(findings)
logger.info(
"B6/B7/B8 extra: %d findings (DPO=%d, staleness=%d, CMP=%d)",
len(findings), 1 if dpo else 0, len(stale), 1 if cmp else 0,
)
def _render(findings: list[dict]) -> str:
cards = []
for f in findings:
sev = (f.get("severity") or "").upper()
color = "#dc2626" if sev == "HIGH" else (
"#f59e0b" if sev == "MEDIUM" else "#64748b"
)
evidence_html = ""
if f.get("evidence_dse"):
evidence_html = (
"<div style='font-size:12px;color:#475569;margin-top:6px;'>"
f"<em>In DSE: {html.escape(', '.join(f['evidence_dse']))}</em>"
"</div>"
)
if f.get("doc_date"):
evidence_html = (
"<div style='font-size:12px;color:#475569;margin-top:6px;'>"
f"<em>Stand: {html.escape(f['doc_date'])} "
f"({f.get('age_years','?')} Jahre alt, Cap "
f"{f.get('threshold_years','?')} Jahre)</em>"
"</div>"
)
if f.get("detected_provider"):
evidence_html = (
"<div style='font-size:12px;color:#475569;margin-top:6px;'>"
f"<em>Erkannter Provider: "
f"{html.escape(f['detected_provider'])}</em>"
"</div>"
)
cards.append(
f"<div style='margin:12px 0;padding:14px;background:#fff;"
f"border-left:3px solid {color};border-radius:4px;'>"
f"<div style='font-weight:600;color:{color};font-size:14px;'>"
f"{sev} · {html.escape(f.get('check_id') or '')}</div>"
f"<div style='font-size:14px;margin-top:4px;'>"
f"<strong>{html.escape(f.get('title') or '')}</strong></div>"
f"<div style='font-size:12px;color:#64748b;margin-top:2px;'>"
f"{html.escape(f.get('norm') or '')}</div>"
f"{evidence_html}"
f"<div style='font-size:13px;margin-top:8px;background:#dcfce7;"
f"padding:8px 10px;border-radius:4px;'>"
f"<strong>→ Empfehlung:</strong> "
f"{html.escape(f.get('action') or '')}</div>"
"</div>"
)
return (
"<div style='margin:24px 0;padding:16px;border-left:4px solid #f59e0b;"
"background:#fffbeb;border-radius:4px;'>"
"<h2 style='margin:0 0 8px;color:#92400e;font-size:16px;'>"
"📌 Zusätzliche Cross-Doc-Befunde (DPO / Staleness / CMP-Fingerprint)"
"</h2>"
+ "".join(cards) +
"</div>"
)
@@ -18,12 +18,16 @@ import logging
from ._b1_wiring import run_b1
from ._b3_wiring import run_b3
from ._b4_wiring import run_b4
from ._b5_wiring import run_b5
from ._b6b7b8_wiring import run_b6b7b8
from ._constants import _compliance_check_jobs
from ._phase_a_resolve import run_phase_a
from ._phase_b_profile_check import run_phase_b
from ._phase_c_banner import run_phase_c
from ._phase_d1_vendors_raw import run_phase_d1
from ._phase_d2_vendors_finalize import run_phase_d2
from ._phase_d2b_plausibility import run_phase_d2b
from ._phase_d3_blocks_bot import run_phase_d3_bot
from ._phase_d3_blocks_mid import run_phase_d3_mid
from ._phase_d3_blocks_top import run_phase_d3_top
@@ -49,11 +53,16 @@ async def run_compliance_check(check_id: str, req) -> None:
# Phase D-1/D-2: Step 5 vendor extraction + finalize
await run_phase_d1(state)
await run_phase_d2(state)
# D-2b: LLM Plausibility Re-Eval — stamps llm_* on all FAIL checks
await run_phase_d2b(state)
# B1 + B3: cross-cutting checks that need the finalized vendor
# list + DSI text. Render their own HTML blocks consumed by
# phase D-3 bot's full_html composition.
await run_b1(state)
run_b3(state)
run_b4(state) # Cross-doc vendor-consistency (Elli Vertex↔Iadvize)
run_b5(state) # AI-Act Art. 50 transparency
run_b6b7b8(state) # DPO-cross-doc + Doc-Staleness + CMP-fingerprint
# Phase D-3 top/mid/bot: Step 5 HTML blocks
await run_phase_d3_top(state)
await run_phase_d3_mid(state)
@@ -0,0 +1,41 @@
"""Phase D-2b — LLM Plausibility Re-Eval over all MC findings.
Runs AFTER vendor finalize and BEFORE D3 HTML blocks. Stamps the
`llm_title` / `llm_severity` / `llm_recommendation` / `llm_drop`
fields onto every FAIL CheckItem. The V2 mail renderer reads these
fields automatically — no further wiring needed.
Opt-out via env var `PLAUSIBILITY_DISABLED=true` (e.g. for CI runs
where the LLM endpoint isn't reachable).
"""
from __future__ import annotations
import logging
import os
from ._helpers import _update
logger = logging.getLogger(__name__)
async def run_phase_d2b(state: dict) -> None:
"""Run the plausibility re-eval over state["results"]. Mutates checks."""
if os.environ.get("PLAUSIBILITY_DISABLED", "false").lower() in (
"true", "1", "yes",
):
logger.info("plausibility-check disabled by env")
return
check_id = state["check_id"]
results = state.get("results") or []
doc_texts = state.get("doc_texts") or {}
if not results:
return
_update(check_id, "LLM-Plausibilitäts-Check über alle Findings...", 94)
try:
from compliance.services.finding_plausibility_check import (
verify_plausibility,
)
await verify_plausibility(results, doc_texts)
except Exception as e:
logger.warning("plausibility-phase failed (continuing): %s", e)
@@ -217,4 +217,17 @@ async def run_phase_d3_bot(state: dict) -> None:
)
state["audit_quality_findings"] = audit_quality_findings
# MAIL_RENDER_V2 — opt-in unified layout. Default keeps the legacy
# composition so we can A/B compare in Mailpit.
try:
from compliance.services.mail_render_v2._compose import (
compose_v2, is_v2_enabled,
)
if is_v2_enabled():
full_html = compose_v2(state)
logger.info("MAIL_RENDER_V2 active: %d bytes", len(full_html))
except Exception as e:
logger.warning("MAIL_RENDER_V2 fallback to legacy: %s", e)
state["full_html"] = full_html