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>
222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
"""AI-Act Art. 50 Transparenzpflicht-Check (vereinfacht).
|
|
|
|
Art. 50 AI Act verlangt, dass Nutzer beim Interagieren mit einem
|
|
KI-System (Chatbot, Sprachassistent etc.) erkennen können, dass sie
|
|
mit einer KI sprechen — es sei denn, das ist offensichtlich aus dem
|
|
Kontext heraus.
|
|
|
|
Der Check ist heuristisch (kein LLM) und prüft drei Schichten:
|
|
|
|
1. AI-Provider-Detection in DSE und Vendor-Liste
|
|
(Vertex AI, OpenAI, Anthropic, etc.)
|
|
2. Disclosure-Text-Detection in DSE / Cookie-Doc
|
|
("KI-System", "Sie chatten mit einer KI", "automatisiert",
|
|
"Artificial Intelligence", "Konversations-KI", "GPT", …)
|
|
3. Cross-Check: AI-Provider gefunden + keine Disclosure → HIGH
|
|
AI-Provider gefunden + Disclosure vorhanden, aber kein "Sie
|
|
interagieren mit einer KI"-Hinweis → MEDIUM (Pre-Chat-Hinweis
|
|
vor erstem Input gefordert; kann nur ein consent-tester-DOM-Scan
|
|
verifizieren)
|
|
|
|
Bekannte Limitation: ohne consent-tester-Erweiterung kann der Check
|
|
nicht entscheiden, ob ein Pre-Chat-Hinweis im Live-DOM vor dem
|
|
ersten Nutzer-Input erscheint. Wir flaggen das daher als MEDIUM
|
|
"manuell verifizieren".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# AI-Anbieter / Modelle / Frameworks, die "AI" auslösen.
|
|
_AI_KEYWORDS = (
|
|
"vertex ai", "google vertex", "openai", "gpt-3", "gpt-4", "chatgpt",
|
|
"anthropic", "claude.ai", "claude-3", "mistral ai", "huggingface",
|
|
"hugging face", "stable diffusion", "midjourney", "llama-2", "llama-3",
|
|
"qwen", "deepseek", "perplexity ai", "azure openai", "copilot",
|
|
"konversations-ki", "konversations ki",
|
|
"ai assistant", "ai-assistant", "ki-assistent", "ki assistent",
|
|
"intelligenter assistent",
|
|
"automatisierter chat", "chatbot", "live-chat",
|
|
)
|
|
|
|
|
|
# Phrasen, die als Art-50-Disclosure gelten.
|
|
_DISCLOSURE_PHRASES = (
|
|
"sie chatten mit einer ki", "sie kommunizieren mit einer ki",
|
|
"automatisierter chat", "automatisierter assistent",
|
|
"ki-gestützter", "ki gestützt", "ki-gestuetzt",
|
|
"künstliche intelligenz", "kuenstliche intelligenz",
|
|
"artificial intelligence",
|
|
"art. 50 ai act", "ai act art. 50", "art. 50 ki-vo",
|
|
"ki-verordnung", "ki verordnung",
|
|
"automatisiertes system",
|
|
"generativ", "generative ki", "generative ai",
|
|
"large language model", "llm-",
|
|
"machine learning",
|
|
)
|
|
|
|
|
|
def _has_any(text: str, phrases) -> list[str]:
|
|
text_lc = (text or "").lower()
|
|
if not text_lc:
|
|
return []
|
|
return [p for p in phrases if p in text_lc]
|
|
|
|
|
|
def _find_ai_in_vendors(cmp_vendors: list[dict]) -> list[dict]:
|
|
"""Find vendors whose name or category mentions an AI provider."""
|
|
hits: list[dict] = []
|
|
for v in cmp_vendors or []:
|
|
haystack = " ".join([
|
|
(v.get("name") or "").lower(),
|
|
(v.get("category") or "").lower(),
|
|
(v.get("processing_company") or "").lower(),
|
|
])
|
|
if not haystack.strip():
|
|
continue
|
|
matched = [k for k in _AI_KEYWORDS if k in haystack]
|
|
if matched:
|
|
hits.append({
|
|
"vendor": v.get("name") or "—",
|
|
"matched": matched[:3],
|
|
})
|
|
return hits
|
|
|
|
|
|
def check_ai_act_transparency(state: dict) -> list[dict]:
|
|
"""Return findings about AI Art. 50 transparency obligations."""
|
|
doc_texts = state.get("doc_texts") or {}
|
|
dse_text = doc_texts.get("dse") or ""
|
|
cookie_text = doc_texts.get("cookie") or ""
|
|
cmp_vendors = state.get("cmp_vendors") or []
|
|
|
|
if not dse_text and not cookie_text and not cmp_vendors:
|
|
return []
|
|
|
|
ai_mentions_dse = _has_any(dse_text, _AI_KEYWORDS)
|
|
ai_mentions_cookie = _has_any(cookie_text, _AI_KEYWORDS)
|
|
ai_vendors = _find_ai_in_vendors(cmp_vendors)
|
|
|
|
has_ai_signal = bool(
|
|
ai_mentions_dse or ai_mentions_cookie or ai_vendors
|
|
)
|
|
if not has_ai_signal:
|
|
return []
|
|
|
|
disc_dse = _has_any(dse_text, _DISCLOSURE_PHRASES)
|
|
disc_cookie = _has_any(cookie_text, _DISCLOSURE_PHRASES)
|
|
has_disclosure = bool(disc_dse or disc_cookie)
|
|
|
|
findings: list[dict] = []
|
|
summary_signals = (
|
|
f"DSE-AI-Hinweise: {len(ai_mentions_dse)} "
|
|
f"(z.B. {', '.join(ai_mentions_dse[:3])}); "
|
|
f"Cookie-AI-Hinweise: {len(ai_mentions_cookie)}; "
|
|
f"AI-Vendors: {len(ai_vendors)}"
|
|
)
|
|
|
|
if not has_disclosure:
|
|
findings.append({
|
|
"check_id": "AI-ACT-TRANSPARENCY-001",
|
|
"severity": "HIGH",
|
|
"severity_reason": "missing",
|
|
"title": (
|
|
"AI-Act Art. 50 Transparenz-Hinweis fehlt — "
|
|
"KI-System eingesetzt, aber keine Nutzer-Erklärung"
|
|
),
|
|
"norm": "AI Act Art. 50 Abs. 1 (Transparenz gegenüber Nutzern)",
|
|
"detected_signals": summary_signals,
|
|
"ai_vendors": ai_vendors,
|
|
"ai_keywords_in_dse": ai_mentions_dse[:5],
|
|
"ai_keywords_in_cookie": ai_mentions_cookie[:5],
|
|
"action": (
|
|
"DSE und Pre-Chat-UI mit ausdrücklichem Hinweis "
|
|
"'Sie kommunizieren mit einer KI (System X)' ergänzen. "
|
|
"Anbieter offen nennen + Rechtsgrundlage + Speicherdauer."
|
|
),
|
|
})
|
|
else:
|
|
# AI detected + DSE-Disclosure vorhanden — aber Pre-Chat-Hinweis
|
|
# im Live-DOM kann der Check nicht verifizieren.
|
|
findings.append({
|
|
"check_id": "AI-ACT-TRANSPARENCY-002",
|
|
"severity": "MEDIUM",
|
|
"severity_reason": "manual_review_required",
|
|
"title": (
|
|
"AI-Act Art. 50: DSE-Disclosure vorhanden — Pre-Chat-Hinweis "
|
|
"im UI manuell verifizieren"
|
|
),
|
|
"norm": "AI Act Art. 50 Abs. 1",
|
|
"detected_signals": summary_signals,
|
|
"ai_vendors": ai_vendors,
|
|
"disclosure_in_dse": disc_dse[:3],
|
|
"disclosure_in_cookie": disc_cookie[:3],
|
|
"action": (
|
|
"Pre-Chat-UI öffnen: vor der ersten Nutzereingabe muss "
|
|
"ein klarer Hinweis erscheinen, dass die Konversation "
|
|
"mit einer KI geführt wird. Verifizieren ob Banner/Modal "
|
|
"vorhanden oder reine Footnote."
|
|
),
|
|
})
|
|
|
|
# Zusatzcheck: Wenn AI vorhanden und Rechtsgrundlage = berechtigtes
|
|
# Interesse (Art. 6 Abs. 1 lit. f) statt Einwilligung — MEDIUM
|
|
if ai_vendors or ai_mentions_dse:
|
|
if _legitimate_interest_for_ai(dse_text):
|
|
findings.append({
|
|
"check_id": "AI-ACT-RISK-001",
|
|
"severity": "MEDIUM",
|
|
"severity_reason": "misclassified",
|
|
"title": (
|
|
"Rechtsgrundlage 'berechtigtes Interesse' für "
|
|
"KI-Verarbeitung — Einwilligung empfehlen"
|
|
),
|
|
"norm": "DSGVO Art. 6 Abs. 1 lit. a vs lit. f + AI Act",
|
|
"detected_signals": (
|
|
"AI-Provider erkannt; Art. 6 Abs. 1 lit. f als "
|
|
"Rechtsgrundlage in DSE genannt"
|
|
),
|
|
"ai_vendors": ai_vendors,
|
|
"action": (
|
|
"Bei generativer KI (insbesondere mit Drittland-"
|
|
"Transfer und Profiling-Verwandtschaft) "
|
|
"Rechtsgrundlage auf Einwilligung (Art. 6 Abs. 1 "
|
|
"lit. a) umstellen. Interessenabwägung dokumentieren."
|
|
),
|
|
})
|
|
|
|
if findings:
|
|
logger.info("ai-act-transparency: %d findings", len(findings))
|
|
return findings
|
|
|
|
|
|
def _legitimate_interest_for_ai(dse_text: str) -> bool:
|
|
"""Detect 'Rechtsgrundlage Art. 6 Abs. 1 lit. f' near AI mentions."""
|
|
text_lc = (dse_text or "").lower()
|
|
if not text_lc:
|
|
return False
|
|
# crude proximity check: any of the AI keywords AND lit-f phrase
|
|
# within a ~600 char window
|
|
import re
|
|
lit_f_patterns = (
|
|
"art. 6 abs. 1 lit. f", "artikel 6 abs. 1 lit. f",
|
|
"art. 6 1 f", "berechtigtes interesse", "berechtigten interesses",
|
|
)
|
|
for ai_kw in _AI_KEYWORDS:
|
|
for pos in range(0, len(text_lc) - 200):
|
|
window = text_lc[max(0, pos-300):pos+300]
|
|
if ai_kw in window and any(p in window for p in lit_f_patterns):
|
|
return True
|
|
# don't walk every char; jump to next ai_kw occurrence
|
|
idx = text_lc.find(ai_kw, pos)
|
|
if idx == -1:
|
|
break
|
|
pos = idx + 1
|
|
break
|
|
return False
|