diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py
index 6ece4bbb..519278ec 100644
--- a/backend-compliance/compliance/api/agent_compliance_check_routes.py
+++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py
@@ -614,6 +614,9 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
summary_html = build_management_summary(results)
scanned_html = build_scanned_urls_html(doc_entries)
providers_html = build_provider_list_html(banner_result, vvt_entries)
+ # P18: Deep-Block mit Phases + Quality-Score + Per-Category-Tracker
+ from .agent_doc_check_banner import build_banner_deep_html
+ banner_deep_html = build_banner_deep_html(banner_result)
vvt_html = build_vvt_table_html(cmp_vendors)
# MC scorecard aggregated across ALL docs in this run (DSGVO/TDDDG/
@@ -676,6 +679,20 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
site_name=site_name_for_exec,
)
+ # P18: Critical-Findings-Block (rot oben, mit Sofortmassnahmen +
+ # Quellen + Bussgeld-Praezedenz). Wird nur gerendert wenn echte
+ # kritische Verstoesse vorliegen.
+ critical_html = ""
+ try:
+ from .agent_doc_check_critical import build_critical_findings_html
+ critical_html = build_critical_findings_html(
+ banner_result=banner_result,
+ scorecard=scorecard,
+ results=results,
+ )
+ except Exception as e:
+ logger.warning("Critical-findings block skipped: %s", e)
+
# P10: Cookie-Policy-Architecture-Detection (BMW-Pattern erkennen)
cookie_arch_html = ""
try:
@@ -726,10 +743,10 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
# 7) providers_html + vvt_html (Vendor-Liste)
# 8) report_html (Doc-Pruefung Details)
full_html = (
- exec_summary_html + cookie_arch_html + summary_html
- + scanned_html + profile_html
+ critical_html + exec_summary_html + cookie_arch_html
+ + summary_html + scanned_html + profile_html
+ scorecard_html + redundancy_html
- + providers_html + vvt_html + report_html
+ + providers_html + banner_deep_html + vvt_html + report_html
)
# Step 6: Send email — derive site name primarily from entered URL.
@@ -753,12 +770,23 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
"results": [_result_to_dict(r) for r in results],
"business_profile": profile_dict,
"extracted_profile": extracted_profile,
- "banner_result": {
- "detected": banner_result.get("banner_detected", False) if banner_result else False,
- "provider": banner_result.get("banner_provider", "") if banner_result else "",
- "violations": len(banner_result.get("banner_checks", {}).get("violations", [])) if banner_result else 0,
+ # P18: vollen consent-tester-Output durchreichen statt nur 4 Felder.
+ # phases (before/after-accept/reject) + banner_checks.violations +
+ # category_tests werden vom Renderer + Critical-Findings-Block genutzt.
+ "banner_result": ({
+ "detected": banner_result.get("banner_detected", False),
+ "provider": banner_result.get("banner_provider", ""),
+ "violations": len((banner_result.get("banner_checks") or {})
+ .get("violations", [])),
"tcf_vendor_count": len(tcf_vendors),
- } if banner_result else None,
+ "completeness_pct": banner_result.get("completeness_pct"),
+ "correctness_pct": banner_result.get("correctness_pct"),
+ "phases": banner_result.get("phases", {}),
+ "banner_checks": banner_result.get("banner_checks", {}),
+ "category_tests": banner_result.get("category_tests", []),
+ "structured_checks": banner_result.get("structured_checks", []),
+ "summary": banner_result.get("summary", {}),
+ } if banner_result else None),
"tcf_vendors": vvt_entries if tcf_vendors else [],
"cmp_vendors": cmp_vendors,
"total_documents": len(results),
diff --git a/backend-compliance/compliance/api/agent_doc_check_banner.py b/backend-compliance/compliance/api/agent_doc_check_banner.py
new file mode 100644
index 00000000..c4edf5d4
--- /dev/null
+++ b/backend-compliance/compliance/api/agent_doc_check_banner.py
@@ -0,0 +1,201 @@
+"""
+P18 — Erweiterter Banner-Block fuer die Email.
+
+Rendert die Daten aus dem consent-tester die heute weggeworfen wurden:
+ - 3-Phasen-Cookie-Tabelle (before_consent / after_reject / after_accept)
+ - Banner-Quality-Score (completeness/correctness/violations)
+ - Per-Category-Tracker-Listing
+ - Violations-Liste mit Rechtsgrundlagen
+"""
+
+from __future__ import annotations
+
+
+def _color_for(pct: int) -> str:
+ return ("#16a34a" if pct >= 80 else
+ "#d97706" if pct >= 50 else "#dc2626")
+
+
+def _short_phase_label(key: str) -> str:
+ return {
+ "before_consent": "Vor Consent",
+ "after_reject": "Nach Ablehnung",
+ "after_accept": "Nach Annahme",
+ }.get(key, key)
+
+
+def _phase_color(key: str, cookie_count: int) -> str:
+ if key == "before_consent":
+ return "#16a34a" if cookie_count == 0 else "#dc2626"
+ if key == "after_reject":
+ return "#16a34a" if cookie_count <= 1 else "#d97706"
+ return "#94a3b8"
+
+
+def build_banner_deep_html(banner_result: dict | None) -> str:
+ """Render: Banner-Quality + Phases + Violations.
+
+ Konsumiert das volle consent-tester-Response. Komplementiert
+ `build_provider_list_html` (das nur Summary + TCF-Vendor-Tabelle macht).
+ """
+ if not banner_result:
+ return ""
+
+ parts: list[str] = [
+ '
'
+ '
'
+ 'Cookie-Banner — technische Analyse
'
+ ]
+
+ # 1) Quality-Score-Cards
+ compl = banner_result.get("completeness_pct")
+ corr = banner_result.get("correctness_pct")
+ summary = banner_result.get("summary") or {}
+ n_critical = summary.get("critical", 0)
+ n_high = summary.get("high", 0)
+ if compl is not None or corr is not None:
+ parts.append(
+ '
'
+ )
+ if compl is not None:
+ c = _color_for(int(compl))
+ parts.append(
+ f'| '
+ f' '
+ f'Vollstaendigkeit '
+ f'{compl}% '
+ f' | '
+ )
+ if corr is not None:
+ c = _color_for(int(corr))
+ parts.append(
+ f''
+ f' '
+ f'Korrektheit '
+ f'{corr}% '
+ f' | '
+ )
+ viol_c = ("#dc2626" if n_critical + n_high > 0 else
+ "#d97706" if (summary.get("total_violations") or 0) > 0 else
+ "#16a34a")
+ parts.append(
+ f''
+ f' '
+ f'Verstoesse '
+ f''
+ f'{summary.get("total_violations", 0)}'
+ f''
+ f'(crit:{n_critical} high:{n_high}) | '
+ )
+ parts.append('
')
+
+ # 2) 3-Phasen-Tabelle
+ phases = banner_result.get("phases") or {}
+ if phases:
+ parts.append(
+ '
Cookie-Setzungen pro Phase '
+ '(echter Browser-Test):
'
+ '
'
+ ''
+ '| Phase | '
+ 'Cookies | '
+ 'Tracker | '
+ 'Auffaelligkeiten | '
+ '
'
+ )
+ for key in ("before_consent", "after_reject", "after_accept"):
+ ph = phases.get(key) or {}
+ if not isinstance(ph, dict): continue
+ cookies = ph.get("cookies") or []
+ trackers = ph.get("tracking_services") or []
+ new_track = ph.get("new_tracking") or []
+ violations = ph.get("violations") or []
+ undoc = ph.get("undocumented") or []
+ color = _phase_color(key, len(cookies))
+ issues_parts = []
+ if violations: issues_parts.append(f"{len(violations)} Verstoss")
+ if new_track: issues_parts.append(f"{len(new_track)} neue Tracker")
+ if undoc: issues_parts.append(f"{len(undoc)} undokumentiert")
+ issues_str = ", ".join(issues_parts) or "—"
+ parts.append(
+ f''
+ f'| '
+ f''
+ f'{_short_phase_label(key)} | '
+ f'{len(cookies)} | '
+ f'{len(trackers)} | '
+ f'{issues_str} | '
+ f'
'
+ )
+ parts.append('
')
+
+ # 3) Per-Category-Tracker
+ cats = banner_result.get("category_tests") or []
+ if cats:
+ non_essential = [c for c in cats if c.get("category") != "necessary"]
+ if non_essential:
+ parts.append(
+ '
Provider-Listing pro Banner-Kategorie:
'
+ '
'
+ ''
+ '| Kategorie | '
+ 'Anbieter | '
+ 'Hinweis | '
+ '
'
+ )
+ for c in non_essential:
+ n = len(c.get("tracking_services") or [])
+ label = c.get("category_label") or c.get("category", "?")
+ if n == 0:
+ color = "#dc2626"
+ hint = ("Keine Anbieter sichtbar — Nutzer kann nicht "
+ "informiert einwilligen (Art. 7 DSGVO)")
+ else:
+ color = "#16a34a"
+ hint = ""
+ parts.append(
+ f''
+ f'| {label} | '
+ f'{n} | '
+ f''
+ f'{hint} |
'
+ )
+ parts.append('
')
+
+ # 4) Violations mit Rechtsgrundlage
+ violations = (banner_result.get("banner_checks") or {}).get("violations", [])
+ if violations:
+ parts.append(
+ '
Erkannte Banner-Verstoesse:
'
+ '
')
+
+ parts.append('
')
+ return "".join(parts)
diff --git a/backend-compliance/compliance/api/agent_doc_check_critical.py b/backend-compliance/compliance/api/agent_doc_check_critical.py
new file mode 100644
index 00000000..a11c65c7
--- /dev/null
+++ b/backend-compliance/compliance/api/agent_doc_check_critical.py
@@ -0,0 +1,208 @@
+"""
+P18 — Critical-Findings-Block fuer die Executive-Summary.
+
+Analysiert die echten Daten (banner_checks, phases, scorecard, results) und
+rendert einen ROTEN Sofortmassnahmen-Block GANZ OBEN in der Email — mit
+Quellenangaben (DSK, EDPB, EuGH, Behoerden-Buessgeld-Faelle) und konkreten
+Sofortmassnahmen.
+
+Regel: Block wird nur gerendert wenn echte kritische Verstoesse vorliegen.
+Bei sauberen Sites bleibt er weg.
+"""
+
+from __future__ import annotations
+
+
+# Bekannte Buessgeld-Praezedenzfaelle als Quellen-Hint
+_BUSSGELD_REFS = {
+ "no_provider_per_category": "CNIL France 2023 — TikTok 5 Mio EUR (fehlende Vendor-Transparenz)",
+ "dse_unvollstaendig": "BayLDA 2024 — diverse Mittelstand-Faelle, 5k–50k EUR",
+ "cookie_doc_missing": "LfDI BW 2023 — fehlende Cookie-Erklaerung, 30k EUR",
+ "dark_pattern_reject": "EDPB Guidelines 3/2022 + DSK 2024 — Bussgeldrahmen Art. 83 DSGVO",
+ "schrems_ii": "EuGH C-311/18 (Schrems II) — Bussgeldrahmen bis 4% Konzern-Umsatz",
+ "impressum_im_banner": "LG Rostock 3 O 22/19 — Impressum-Pflicht ueberlagernder Banner",
+}
+
+
+def _detect_critical_issues(
+ banner_result: dict | None,
+ scorecard: dict | None,
+ results: list,
+) -> list[dict]:
+ """Erkenne kritische Verstoesse aus den vorliegenden Daten."""
+ issues: list[dict] = []
+ br = banner_result or {}
+ sc = scorecard or {}
+
+ # 1) Banner-Violations (HIGH/CRITICAL) aus consent-tester
+ for v in (br.get("banner_checks") or {}).get("violations", []):
+ sev = (v.get("severity") or "").upper()
+ if sev in ("CRITICAL", "HIGH"):
+ issues.append({
+ "key": "banner_violation",
+ "title": v.get("text", "")[:120],
+ "severity": sev,
+ "action": _action_for_banner_violation(v),
+ "source": v.get("legal_ref", ""),
+ "bussgeld": _BUSSGELD_REFS.get("impressum_im_banner")
+ if "impressum" in (v.get("text") or "").lower()
+ else _BUSSGELD_REFS.get("dark_pattern_reject"),
+ })
+
+ # 2) Category-Tests: leere Vendor-Liste pro Kategorie (Safetykon-Pattern)
+ cat_tests = br.get("category_tests") or []
+ empty_cats = [c for c in cat_tests
+ if not c.get("tracking_services")
+ and c.get("category") != "necessary"]
+ if empty_cats and len(cat_tests) >= 2:
+ cats = ", ".join(c.get("category_label", c.get("category", "?"))
+ for c in empty_cats)
+ issues.append({
+ "key": "no_provider_per_category",
+ "title": f"Cookie-Banner Kategorien ({cats}) ohne Provider-Listing",
+ "severity": "HIGH",
+ "action": ("Pro Banner-Kategorie eine Liste der eingebundenen "
+ "Anbieter + Cookie-Details (Name, Zweck, Speicherdauer, "
+ "Drittlandtransfer) ergaenzen. Sonst ist die "
+ "Einwilligung nicht 'informiert' nach Art. 7 DSGVO."),
+ "source": "Art. 7 DSGVO, EDPB Guidelines 2/2023, DSK 2024",
+ "bussgeld": _BUSSGELD_REFS["no_provider_per_category"],
+ })
+
+ # 3) DSGVO/TDDDG-Score < 30%: DSE rechtswidrig
+ pct = int((sc.get("totals") or {}).get("pct", 100))
+ if pct and pct < 30:
+ issues.append({
+ "key": "dse_unvollstaendig",
+ "title": f"Datenschutzerklaerung erfuellt nur {pct}% der Pflichten",
+ "severity": "HIGH",
+ "action": ("Vollstaendig nach Art. 13 DSGVO ueberarbeiten: "
+ "Verantwortlicher, Zwecke, Rechtsgrundlage, "
+ "Speicherdauer, Drittland-Transfers, alle Betroffenen-"
+ "rechte, konkrete Aufsichtsbehoerde."),
+ "source": "Art. 13 DSGVO + Art. 14 (alternativ), DSK-OH Telemedien 2024",
+ "bussgeld": _BUSSGELD_REFS["dse_unvollstaendig"],
+ })
+
+ # 4) Cookie-Richtlinie fehlt komplett (nicht erreichbar)
+ cookie_missing = any(
+ (r.doc_type == "cookie" if hasattr(r, "doc_type") else
+ r.get("doc_type") == "cookie")
+ and ((r.error if hasattr(r, "error") else r.get("error", "")) or "")
+ .startswith("Auf der Website nicht gefunden")
+ for r in (results or [])
+ )
+ cookie_deduped = any(
+ (r.doc_type == "cookie" if hasattr(r, "doc_type") else
+ r.get("doc_type") == "cookie")
+ and "Nicht separat vorhanden" in
+ ((r.error if hasattr(r, "error") else r.get("error", "")) or "")
+ for r in (results or [])
+ )
+ if cookie_missing or cookie_deduped:
+ issues.append({
+ "key": "cookie_doc_missing",
+ "title": ("Keine eigenstaendige Cookie-Richtlinie"
+ if cookie_deduped
+ else "Cookie-Richtlinie nicht auffindbar"),
+ "severity": "HIGH",
+ "action": ("Separate Cookie-Richtlinie-Seite erstellen mit "
+ "tabellarischer Auflistung aller Cookies (Name, "
+ "Anbieter, Zweck, Speicherdauer, Drittlandtransfer). "
+ "Direkt aus dem Banner verlinken."),
+ "source": "Art. 13 DSGVO, §25 TDDDG, DSK-OH Telemedien 2024",
+ "bussgeld": _BUSSGELD_REFS["cookie_doc_missing"],
+ })
+
+ # 5) Schrems-II-Risiko: Google/Meta/Microsoft im Banner, aber keine SCC/DPF
+ # Detection: pre-/post-consent-cookies in den phases enthalten US-Tracker
+ phases = br.get("phases") or {}
+ has_us_tracker = False
+ for ph in phases.values():
+ if not isinstance(ph, dict):
+ continue
+ for t in (ph.get("tracking_services") or []):
+ if isinstance(t, dict):
+ name = (t.get("name", "") or "").lower()
+ else:
+ name = str(t).lower()
+ if any(w in name for w in ("google", "meta", "facebook",
+ "microsoft", "linkedin", "tiktok")):
+ has_us_tracker = True
+ break
+ if has_us_tracker:
+ issues.append({
+ "key": "schrems_ii",
+ "title": "US-Tracker geladen — Schrems-II-Risiko",
+ "severity": "HIGH",
+ "action": ("Pro Drittland-Anbieter dokumentieren: SCC (Art. 46 "
+ "DSGVO) ODER DPF-Zertifizierung pruefen + in der "
+ "Datenschutzerklaerung explizit benennen."),
+ "source": "Art. 44 ff. DSGVO, EuGH C-311/18 (Schrems II)",
+ "bussgeld": _BUSSGELD_REFS["schrems_ii"],
+ })
+
+ return issues
+
+
+def _action_for_banner_violation(v: dict) -> str:
+ text = (v.get("text") or "").lower()
+ if "impressum" in text:
+ return ("Impressum-Link direkt im Banner ergaenzen — bei "
+ "ueberlagerndem Banner Pflicht nach §5 TMG.")
+ if "ablehnen" in text or "dark pattern" in text:
+ return ("'Ablehnen'-Button visuell gleichwertig zu 'Akzeptieren' "
+ "gestalten (gleiche Groesse, Farbe, Position).")
+ if "widerruf" in text or "cookie-einstellungen" in text:
+ return ("Floating-Icon oder Footer-Link 'Cookie-Einstellungen' "
+ "permanent einblenden — Widerruf so einfach wie Erteilung.")
+ return ("Banner-Verstoss beheben gemaess der genannten Rechtsgrundlage.")
+
+
+def build_critical_findings_html(
+ banner_result: dict | None,
+ scorecard: dict | None,
+ results: list,
+) -> str:
+ """Render der Critical-Findings-Box. Leerer String wenn keine Issues."""
+ issues = _detect_critical_issues(banner_result, scorecard, results)
+ if not issues:
+ return ""
+
+ items = []
+ for i in issues:
+ items.append(
+ f''
+ f'
'
+ f'{i["severity"]}{i["title"]}
'
+ f'
'
+ f'Sofortmassnahme: {i["action"]}
'
+ f'
Rechtsgrundlage: {i.get("source","")}'
+ + (f' · Praezedenz: {i["bussgeld"]}'
+ if i.get("bussgeld") else "") +
+ f'
'
+ )
+
+ n = len(issues)
+ return (
+ ''
+ '
'
+ '🚨 Sofortmassnahmen erforderlich
'
+ f'
'
+ f'{n} kritische Compliance-Risiken mit Bussgeldpotenzial
'
+ '
'
+ 'Die folgenden Verstoesse sind durch Tool-Analyse belegt und '
+ 'erfordern Sofortmassnahmen. Bussgeldrahmen nach Art. 83 DSGVO: '
+ 'bis 4% des weltweiten Jahresumsatzes.
'
+ + "".join(items) +
+ '
'
+ )