refactor(agent-check): split routes file (2692→347 LOC) + wire B1/B3/A1 [guardrail-change]

Phase-5 split of agent_compliance_check_routes.py — the 2700-line
monolith was decomposed into 19 modules in compliance/api/agent_check/:

  - Phase A-F: resolve / profile+check / banner+TCF / vendors raw+finalize /
    HTML blocks top+mid+bot / email / persist
  - Helpers: _constants, _helpers, _fetch, _discovery, _single_check
  - Schemas + State + thin _orchestrator

A1 ZIP-Anhang nativ in _phase_e_email: evidence_zip_builder.py bundles
slices + manifest.json + audit_metadata.json (SHA256 per slice +
build_sha + source_url). smtp_sender.py erweitert um attachments-Parameter.

B1 COOKIE-CONSENT-UX-001 (Mobile Reachability): consent_reachability_check.py
parses footer anchors, classifies intent (reopen_cmp / info_only /
browser_deflect) + target (same_page_cmp / new_tab / external).
_b1_wiring.py fetches homepage with iPhone-UA + renders Art-7-Abs-3
severity-coloured block.

B3 TH-RETENTION (Cross-Doc Speicherdauer): retention_comparator.py
compares DSI claim ↔ cookie-table duration ↔ actual Max-Age/expires
with 5% tolerance + severity hierarchy (dsi_under_actual HIGH,
table_under_actual HIGH, dsi_vs_table MEDIUM, actual_under_table LOW
Safari-ITP-Hint). _b3_wiring.py + Top-10 mismatches table in mail.

Side-effects:
- Fixed silent UnboundLocalError in original Step 5 (gf_one_pager used
  audit_quality_findings before declaration, caught by surrounding
  except → block never rendered). New _phase_d3_blocks_bot.py runs
  audit-quality FIRST.
- agent_compliance_check_routes.py removed from loc-exceptions.txt
  ("Phase 5 split target" — done).

Tests: 55/55 grün (B1 22 + B3 27 + saving_scan 6).
E2E: smoke against Elli DSE+Cookie produced HIGH/missing B1 finding,
TH-RETENTION table (17 cookies / 3 ✓ / 3 ✗ / 11 ?), evidence-zip
with 2 slices + manifest + audit_metadata (12089B, SHA256-chained,
source verified), email sent (attachments=1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-06 14:47:25 +02:00
parent dfadff5b02
commit c2c8783fee
29 changed files with 4545 additions and 2408 deletions
@@ -0,0 +1,10 @@
"""
Subpackage for the compliance-check route — extracted to keep
`agent_compliance_check_routes.py` under the 500-line guardrail.
The route module still owns the public HTTP endpoints and re-exports
all helpers from this subpackage, so external callers
(`saving_scan_routes`, `agent_migration_routes`, tests) continue to
import them from `compliance.api.agent_compliance_check_routes`
unchanged.
"""
@@ -0,0 +1,105 @@
"""B1 wiring — Mobile Consent-Reachability check + HTML block.
Fetches the homepage of the first submitted URL, runs the static
`evaluate_reachability` analysis on the footer, and renders the
result as an HTML block for the audit mail.
Only renders a block when the check FAILS — a passing site doesn't
need a block. The block is severity-colored and lists the specific
notes that triggered the finding (missing reopen anchor, new-tab
break, browser-deflection language).
"""
from __future__ import annotations
import html
import logging
import httpx
from compliance.services.consent_reachability_check import (
evaluate_reachability,
)
from ._helpers import _update
logger = logging.getLogger(__name__)
async def run_b1(state: dict) -> None:
"""Run the reachability check + render HTML. Mutates state in place."""
req = state["req"]
check_id = state["check_id"]
homepage_url = ""
for d in req.documents:
if d.url:
from urllib.parse import urlparse
p = urlparse(d.url)
if p.scheme and p.netloc:
homepage_url = f"{p.scheme}://{p.netloc}/"
break
if not homepage_url:
return
_update(check_id, "Mobile Consent-Reachability prüfen...", 95)
try:
async with httpx.AsyncClient(
timeout=20.0, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 "
"like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) "
"Version/17.5 Mobile/15E148 Safari/604.1"},
) as c:
r = await c.get(homepage_url)
if r.status_code != 200:
logger.info("B1: homepage fetch %s → HTTP %d", homepage_url, r.status_code)
return
page_html = r.text
except Exception as e:
logger.warning("B1: homepage fetch failed: %s", e)
return
finding = evaluate_reachability(page_html, homepage_url)
state["reachability_finding"] = finding
state["reachability_html"] = _render_block(finding)
logger.info(
"B1 Reachability: passed=%s severity=%s reason=%s",
finding["passed"], finding.get("severity"),
finding.get("severity_reason"),
)
def _render_block(finding: dict) -> str:
"""Render the reachability finding as an audit-mail HTML block."""
if finding["passed"]:
return ""
sev = (finding.get("severity") or "").upper()
color = "#dc2626" if sev == "HIGH" else "#f59e0b"
notes_html = "".join(
f"<li>{html.escape(n)}</li>" for n in finding.get("notes") or []
)
anchor = finding.get("reopen_anchor") or {}
anchor_html = ""
if anchor:
anchor_html = (
"<p style='margin:8px 0 0;font-size:13px;color:#475569;'>"
"Gefundener Footer-Link: "
f"<code>{html.escape((anchor.get('text') or '')[:80])}</code> "
f"→ <code>{html.escape((anchor.get('href') or '')[:120])}</code> "
f"(target_class: {html.escape(anchor.get('target_class') or '')})"
"</p>"
)
return (
f"<div style='margin:24px 0;padding:16px;border-left:4px solid {color};"
"background:#fef2f2;border-radius:4px;'>"
f"<h2 style='margin:0 0 8px;color:{color};font-size:16px;'>"
"COOKIE-CONSENT-UX-001 — Mobile Consent-Reachability</h2>"
f"<p style='margin:0 0 8px;font-size:14px;'><strong>Severity:</strong> "
f"{sev} ({html.escape(finding.get('severity_reason') or '')})</p>"
"<p style='margin:0 0 4px;font-size:14px;'>"
"Art. 7 Abs. 3 DSGVO: Widerruf muss so einfach wie Erteilung sein. "
"Auf Mobile-Safari konnten wir folgendes Problem feststellen:</p>"
f"<ul style='margin:8px 0 0 20px;font-size:14px;color:#7f1d1d;'>"
f"{notes_html}</ul>"
f"{anchor_html}"
"</div>"
)
@@ -0,0 +1,189 @@
"""B3 wiring — Cross-doc retention consistency check + HTML block.
Combines three sources of retention truth per cookie:
- DSI text (state["doc_texts"]["dse"] or "cookie")
- cookie-table `duration` from cmp_vendors[i]["cookies"][j]
- actual cookie expiry from banner_result["cookies_detailed"][k]
and produces per-cookie findings + a TH-RETENTION theme summary. Only
renders an HTML block when there are findings to show; the block is
sorted by severity (HIGH first) and shows the top-10 mismatches.
"""
from __future__ import annotations
import html
import logging
import time
from compliance.services.retention_comparator import (
build_retention_theme_summary,
compare_retention,
extract_retention_claims,
)
logger = logging.getLogger(__name__)
def _actual_max_age_seconds(cookie: dict) -> float | None:
"""Get cookie Max-Age in seconds.
Playwright gives us `expires` as a Unix timestamp (seconds-since-
epoch). Some sources give `max_age` directly. -1 / 0 means session
cookie (no expiry) — return None to signal that.
"""
ma = cookie.get("max_age")
if isinstance(ma, (int, float)) and ma > 0:
return float(ma)
exp = cookie.get("expires")
if isinstance(exp, (int, float)) and exp > 0:
delta = exp - time.time()
if delta > 0:
return float(delta)
return None
def run_b3(state: dict) -> None:
"""Cross-doc retention check + render HTML. Mutates state in place."""
doc_texts = state["doc_texts"]
cmp_vendors = state["cmp_vendors"]
banner_result = state["banner_result"]
dsi_text = doc_texts.get("dse") or doc_texts.get("cookie") or ""
if not dsi_text:
return
cookie_records: list[dict] = []
cookie_names: list[str] = []
vendor_names: list[str] = []
for v in cmp_vendors or []:
vname = (v.get("name") or "").strip()
if vname:
vendor_names.append(vname)
for c in (v.get("cookies") or []):
cname = (c.get("name") or "").strip()
if not cname:
continue
duration = (c.get("duration") or c.get("persistence")
or c.get("expiry") or "")
cookie_names.append(cname)
cookie_records.append({
"name": cname,
"vendor": vname,
"table_duration": duration,
"actual_max_age": None,
})
if not cookie_records:
return
# Match actual max_age from banner_result.cookies_detailed
if banner_result:
cookies_detailed = banner_result.get("cookies_detailed") or []
by_name: dict[str, dict] = {}
for c in cookies_detailed:
n = (c.get("name") or "").lower()
if n:
by_name[n] = c
for rec in cookie_records:
nm = rec["name"].lower()
if nm in by_name:
rec["actual_max_age"] = _actual_max_age_seconds(by_name[nm])
claims = extract_retention_claims(dsi_text, cookie_names, vendor_names)
findings: list[dict] = []
for rec in cookie_records:
finding = compare_retention(
cookie_name=rec["name"],
table_duration=rec["table_duration"],
actual_max_age_seconds=rec["actual_max_age"],
dsi_claims=claims,
vendor_name=rec["vendor"] or None,
)
findings.append(finding)
summary = build_retention_theme_summary(findings)
state["retention_findings"] = findings
state["retention_theme_summary"] = summary
state["retention_html"] = _render_block(summary, findings)
logger.info(
"B3 Retention: %d findings, %d passed, %d failed, %d incomplete",
summary["total"], summary["passed"], summary["failed"],
summary["incomplete"],
)
def _fmt_days(d: float | None) -> str:
if d is None:
return ""
if d < 1:
return f"{int(d * 24)}h"
if d < 30:
return f"{int(d)}d"
if d < 365:
return f"{int(d / 30)}mo"
return f"{d / 365:.1f}y"
def _render_block(summary: dict, findings: list[dict]) -> str:
if summary["total"] == 0:
return ""
failed_findings = [f for f in findings if not f.get("matches")
and f.get("severity_reason") != "incomplete"]
if not failed_findings:
return "" # all OK, no block needed
# Sort by severity (HIGH first) then diff_days desc
sev_rank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
failed_findings.sort(key=lambda f: (
sev_rank.get((f.get("severity") or "").upper(), 9),
-(f.get("diff_days") or 0),
))
rows = []
for f in failed_findings[:10]:
sev = (f.get("severity") or "").upper()
color = ("#dc2626" if sev == "HIGH"
else "#f59e0b" if sev == "MEDIUM" else "#64748b")
rows.append(
"<tr>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>"
f"<code>{html.escape(f.get('cookie_name') or '')}</code></td>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>"
f"{html.escape((f.get('vendor_name') or ''))}</td>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>"
f"DSI: {_fmt_days(f.get('dsi_days'))}"
f"Tabelle: {_fmt_days(f.get('table_days'))}"
f"Realität: {_fmt_days(f.get('actual_days'))}</td>"
f"<td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;"
f"color:{color};font-weight:600;'>"
f"{sev} ({html.escape(f.get('mismatch_type') or '')})</td>"
"</tr>"
)
total = summary["total"]
passed = summary["passed"]
failed = summary["failed"]
incomplete = summary["incomplete"]
return (
"<div style='margin:24px 0;padding:16px;border-left:4px solid #dc2626;"
"background:#fefce8;border-radius:4px;'>"
"<h2 style='margin:0 0 8px;color:#854d0e;font-size:16px;'>"
"TH-RETENTION — Speicherdauer-Konsistenz (DSI ↔ Cookie-Tabelle ↔ Realität)"
"</h2>"
"<p style='margin:0 0 8px;font-size:14px;color:#3f3f46;'>"
f"<strong>{total}</strong> Cookies verglichen: "
f"<strong style='color:#15803d;'>{passed} ✓</strong> / "
f"<strong style='color:#dc2626;'>{failed} ✗</strong> / "
f"<strong style='color:#64748b;'>{incomplete} ?</strong></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;'>Cookie</th>"
"<th style='text-align:left;padding:6px 10px;'>Vendor</th>"
"<th style='text-align:left;padding:6px 10px;'>Werte</th>"
"<th style='text-align:left;padding:6px 10px;'>Mismatch</th>"
"</tr></thead>"
f"<tbody>{''.join(rows)}</tbody>"
"</table>"
"</div>"
)
@@ -0,0 +1,93 @@
"""Module-level constants + shared job state for the compliance-check
route.
`_compliance_check_jobs` is the SINGLE source of truth for in-flight
job progress. Other modules MUST import the same object — never
re-declare it — otherwise progress updates land in a detached dict.
"""
from __future__ import annotations
# Internal hostname of the consent-tester container.
CONSENT_TESTER_URL = "http://bp-compliance-consent-tester:8094"
# In-memory job registry. Keyed by check_id. Values:
# {"status": "running"|"completed"|"failed"|"skipped_tdm",
# "progress": str, "progress_pct": int, "result": dict, ...}
# Read/written by:
# - agent_compliance_check_routes (start/status/_run/_update)
# - saving_scan_routes (start)
# - agent_migration_routes (status mirror)
_compliance_check_jobs: dict[str, dict] = {}
# Canonical doc types in the same order the frontend
# ComplianceCheckTab renders them. The route pads `results` to always
# include an entry for each — missing rows are flagged as 'Nicht
# eingereicht' or 'Auf der Website nicht gefunden'.
#
# DSB-Kontakt is NOT canonical: per GDPR practice the DSB is named
# inside the DSI/datenschutz document (email or contact block), not as
# a separate page. We check 'DSB benannt' as a sub-check of the DSE.
_ALL_DOC_TYPES = [
"dse", "impressum", "social_media", "cookie",
"agb", "nutzungsbedingungen", "widerruf",
]
# Human-readable labels per doc_type. Used in the report + emails.
_DOC_TYPE_LABELS = {
"dse": "Datenschutzerklaerung",
"datenschutz": "Datenschutzerklaerung",
"privacy": "Datenschutzerklaerung",
"impressum": "Impressum",
"agb": "AGB",
"widerruf": "Widerrufsbelehrung",
"cookie": "Cookie-Richtlinie",
"avv": "Auftragsverarbeitung",
"loeschkonzept": "Loeschkonzept",
"dsfa": "Datenschutz-Folgenabschaetzung",
"social_media": "Social Media Datenschutz",
"nutzungsbedingungen": "Nutzungsbedingungen",
"dsb": "DSB-Kontakt",
# P74: Legal-Notice / Rechtliche Hinweise (IP, Forward-Looking, Risiko)
"legal_notice": "Rechtliche Hinweise",
# P96: Digital Services Act-Pflichtangaben (Art. 12+17 DSA)
"dsa": "DSA-Pflichtangaben",
# P97: Lizenzhinweise Dritter (OSS-Compliance)
"lizenzhinweise": "Lizenzhinweise Dritter",
}
# Title/URL keywords → canonical doc_type. Order matters: most-specific first.
_DISCOVERY_RULES: list[tuple[str, tuple[str, ...]]] = [
("cookie", ("cookie", "kuche", "biscuit", "cookies-")),
("widerruf", ("widerruf", "rueckgabe", "rückgabe", "cancellation",
"right-of-withdrawal", "ruecktritts", "rücktritts")),
("social_media", ("social-media", "soziale-medien", "social_media",
"social-media-policy")),
# P23: 'terms-and-conditions' kann Allgemeine Geschaeftsbedingungen ODER
# Nutzungsbedingungen meinen. Discovery-Funktion klassifiziert spaeter
# praeziser per Titel + Inhalt. Hier nur Url-Hint:
("agb", ("/agb", "geschaeftsbedingungen", "geschäftsbedingungen",
"general-terms")),
("nutzungsbedingungen", ("nutzungsbedingung", "nutzungsbedingungen",
"terms-of-use", "terms-and-conditions",
"nutzungsordnung", "terms-of-service",
"allgemeine-nutzungsbedingungen")),
("dsb", ("datenschutzbeauftragt", "data-protection-officer",
"dpo-contact", "/dsb")),
("impressum", ("impressum", "imprint", "legal-notice", "site-notice",
"anbieterkennzeichnung", "legal-disclaimer-pool")),
("dse", ("data-privacy", "datenschutz", "data-protection",
"privacy-policy", "privacy-notice", "dsgvo",
"data_privacy", "datenschutzinformation")),
]
# Compound TLDs that count as 2 labels when extracting the second-level
# domain (e.g. shop.example.co.uk → 'example', not 'co').
_COMPOUND_TLDS = {
"co.uk", "co.jp", "co.nz", "co.kr", "co.za", "co.in",
"com.au", "com.br", "com.mx", "com.tr", "com.sg",
}
@@ -0,0 +1,230 @@
"""Auto-discovery of missing canonical doc-types.
For each canonical type the user did NOT submit, try to find it on the
homepage of the URLs they DID submit. Also follow same-owner subdomains
mentioned in the submitted text (BMW Group → bmwgroup.com etc.).
Discovered docs are classified by `_classify_discovered_doc` and merged
back into `doc_entries`; entries that stayed empty get
`discovery_attempted=True` so the padding step can differentiate
"Nicht eingereicht" from "Auf der Website nicht gefunden".
"""
from __future__ import annotations
import logging
import re
from urllib.parse import urlparse
import httpx
from ._constants import _ALL_DOC_TYPES, CONSENT_TESTER_URL
from ._helpers import _classify_discovered_doc, _update
logger = logging.getLogger(__name__)
async def _autodiscover_missing(
check_id: str,
doc_entries: list[dict],
doc_texts: dict[str, str],
url_text_cache: dict[str, str],
) -> None:
"""For each canonical doc_type the user did not submit, try to find
the corresponding document on the homepage of the site they DID submit.
Modifies doc_entries in place: fills text/url/word_count and sets
`auto_discovered=True`. Marks `discovery_attempted=True` on every
missing entry (even when nothing was found) so the report can
distinguish 'Nicht eingereicht' from 'Auf der Website nicht gefunden'.
"""
# VW-Fix: nur Doc-Types mit substantieller Text-Ausbeute zaehlen
# als 'submitted'. Wenn der User eine URL eingegeben hat aber die
# 404 liefert (VW cookie-richtlinie.html), oder der Crawler weniger
# als 200 Zeichen extrahiert (SPA-Shell), als 'missing' behandeln
# damit der Discovery-Pass alternative URLs probiert.
_MIN_USEFUL_CHARS = 200
submitted_types = {
e["doc_type"] for e in doc_entries
if len((e.get("text") or "").strip()) >= _MIN_USEFUL_CHARS
}
# Markiere die fehlgeschlagenen URL-Submissions damit der Discovery
# ihre URL nicht erneut probiert (waere sinnlos).
failed_urls: set[str] = {
(e.get("url") or "").strip()
for e in doc_entries
if (e.get("url") or "").strip()
and len((e.get("text") or "").strip()) < _MIN_USEFUL_CHARS
}
if failed_urls:
logger.info(
"VW-Fix: %d eingegebene URLs lieferten <%d Zeichen — Discovery "
"soll Alternativen probieren: %s",
len(failed_urls), _MIN_USEFUL_CHARS,
", ".join(list(failed_urls)[:3]),
)
# Map alias types to canonical
submitted_canon = {
"dse" if t in ("datenschutz", "privacy") else t for t in submitted_types
}
# Missing = canonical types the user did NOT submit
missing = set(_ALL_DOC_TYPES) - submitted_canon
if not missing:
return
# Pick the most common base (scheme://netloc) from submitted URLs.
bases: dict[str, int] = {}
for e in doc_entries:
u = (e.get("url") or "").strip()
if u and "://" in u:
p = urlparse(u)
base = f"{p.scheme}://{p.netloc}"
bases[base] = bases.get(base, 0) + 1
if not bases:
# No submitted URL at all — nothing to crawl from. Add empty
# placeholders (with discovery_attempted=False) so the padding
# step renders them as 'Nicht eingereicht' (not 'Nicht gefunden').
for dt in missing:
doc_entries.append({
"doc_type": dt, "url": "", "text": "", "word_count": 0,
"auto_discovered": False, "discovery_attempted": False,
})
return
# Build crawl plan: primary base + any related domains mentioned in
# the submitted texts that share the owner's SLD. Example: BMW Group
# text mentions bmwgroup.com and bmwgroup.jobs in addition to bmw.de.
primary_base = max(bases, key=bases.get) + "/"
crawl_bases: list[str] = [primary_base]
primary_netloc = urlparse(primary_base).netloc.lower().lstrip("www.")
owner_token = primary_netloc.split(".")[0] # 'bmw'
if owner_token and len(owner_token) >= 3:
domain_re = re.compile(
r"https?://([a-z0-9][a-z0-9\-]*\.)*" + re.escape(owner_token)
+ r"[a-z0-9\-]*\.[a-z]{2,}",
re.IGNORECASE,
)
seen_bases = {primary_base}
for entry in doc_entries:
text = entry.get("text") or ""
for m in domain_re.finditer(text):
p = urlparse(m.group(0))
base = f"{p.scheme}://{p.netloc}/"
base_netloc = p.netloc.lower().lstrip("www.")
if base_netloc == primary_netloc:
continue
if base in seen_bases:
continue
seen_bases.add(base)
crawl_bases.append(base)
if len(crawl_bases) >= 3:
break
if len(crawl_bases) >= 3:
break
_update(
check_id,
f"Suche fehlende Dokumente auf {', '.join(urlparse(b).netloc for b in crawl_bases)}...",
18,
)
discovered: list[dict] = []
disc_payloads: list[dict] = []
disc_cookie_texts: list[str] = []
for base in crawl_bases:
try:
async with httpx.AsyncClient(timeout=300.0) as client: # P90: 180s -> 300s
resp = await client.post(
f"{CONSENT_TESTER_URL}/dsi-discovery",
json={"url": base, "max_documents": 15},
timeout=300.0, # P90: 180s -> 300s
)
if resp.status_code != 200:
logger.warning("auto-discovery: HTTP %d for %s",
resp.status_code, base)
continue
body = resp.json()
discovered.extend(body.get("documents", []) or [])
disc_payloads.extend(body.get("cmp_payloads") or [])
cmp_text = body.get("cmp_cookie_text") or ""
if cmp_text:
disc_cookie_texts.append(cmp_text)
logger.info("auto-discovery on %s: %d docs, %d CMP payloads, "
"cmp_cookie_text=%d words", base,
len(body.get("documents", []) or []),
len(body.get("cmp_payloads") or []),
len(cmp_text.split()))
except Exception as e:
# P90: verbose exception fuer Diagnose
logger.warning("auto-discovery failed for %s: %s (%s)",
base, str(e) or "(empty)", type(e).__name__)
# Classify each discovered doc into a canonical doc_type
by_type: dict[str, dict] = {}
for d in discovered:
title = (d.get("title") or "").lower()
url = (d.get("url") or "").lower()
wc = d.get("word_count") or 0
if wc < 100:
continue
canon = _classify_discovered_doc(title, url)
if canon and canon in missing and canon not in by_type:
by_type[canon] = d
# Append/Update entry for every missing canonical type. Auto-discovered
# ones get the text/URL filled; ungratched ones stay empty so the
# padding step renders them as 'Auf der Website nicht gefunden'.
# VW-Fix: wenn schon ein leerer entry existiert (URL gesetzt, aber
# fetch hat 0/Mini-Text geliefert), in-place updaten statt duplizieren.
filled = 0
for dt in missing:
existing = next((e for e in doc_entries
if e.get("doc_type") == dt), None)
new_entry: dict = existing if existing else {
"doc_type": dt, "url": "", "text": "", "word_count": 0,
"auto_discovered": False, "discovery_attempted": True,
"cmp_payloads": [],
}
new_entry["discovery_attempted"] = True
d = by_type.get(dt)
if d:
full = d.get("full_text") or d.get("text_preview") or ""
# For cookie: prefer the CMP-reconstructed text when it's
# substantially richer than the auto-discovered DOM extraction.
# BMW homepage CMP yields ~1800 words of authoritative policy;
# DOM extraction typically yields ~600 words of site chrome.
if dt == "cookie" and disc_cookie_texts:
cmp_merged = "\n\n".join(disc_cookie_texts)
if len(cmp_merged.split()) > len(full.split()):
logger.info(
"cookie: using CMP-reconstructed text (%d words) "
"instead of DOM (%d words)",
len(cmp_merged.split()), len(full.split()),
)
full = cmp_merged
if len(full.split()) >= 100:
new_entry["text"] = full
# Behalte die original URL als "rejected_url" damit Audit
# zeigt 'X war 404, wir haben Y gefunden'.
if existing and (existing.get("url") or "").strip() in failed_urls:
new_entry["rejected_url"] = existing.get("url")
new_entry["url"] = d.get("url", "")
new_entry["word_count"] = len(full.split())
new_entry["auto_discovered"] = True
if dt == "cookie" and disc_payloads:
new_entry["cmp_payloads"] = disc_payloads
doc_texts[dt] = full
filled += 1
logger.info(
"auto-discovered %s on %s: %s (%d words)%s",
dt, base, d.get("url", "")[:80], new_entry["word_count"],
" [REPLACED failed URL]" if existing else "",
)
if not existing:
doc_entries.append(new_entry)
logger.info(
"auto-discovery: filled %d/%d missing types from %s",
filled, len(missing), base,
)
@@ -0,0 +1,142 @@
"""URL → text fetch helper for the compliance-check pipeline.
Tries the consent-tester service first (Playwright, full JS render +
CMP capture). On any failure or empty result, falls back to a direct
HTTP GET with an identifiable User-Agent and per-domain rate limiting.
For cookie/dse/social_media doc types we cap discovery to 1 sub-page
(the policy itself is authoritative). For Impressum/AGB/Widerruf and
similar enterprise-split pages we follow up to 3 sub-pages.
"""
from __future__ import annotations
import logging
import re as _re
import httpx
from ._constants import CONSENT_TESTER_URL
logger = logging.getLogger(__name__)
async def _fetch_text(url: str, doc_type: str = "") -> tuple[str, list[dict]]:
"""Fetch text from URL via consent-tester, with HTTP fallback.
Returns (text, cmp_payloads). cmp_payloads is the raw CMP JSON captured
during navigation (ePaaS, OneTrust, …) — empty when no CMP fired or
HTTP fallback was used. Backend turns payloads into structured vendor
records for the VVT table in the email.
"""
# 1. Consent-tester (Playwright-based, full JS rendering).
# max_documents depends on doc_type:
# - cookie/dse/social_media: self-extract (often + CMP capture) is
# authoritative, sub-pages dilute the policy text. max=1.
# - impressum/agb/widerruf/nutzungsbedingungen/dsb: BMW & similar
# enterprise sites split this across 3-4 short sub-pages
# (Versicherungsvermittler, Aufsicht, Berufsrecht). max=3 follows
# them. The 15s networkidle bail (dsi_helpers) keeps timing safe.
short_extract_types = {"cookie", "dse", "datenschutz", "privacy", "social_media"}
max_docs = 1 if (doc_type or "") in short_extract_types else 3
try:
# P90: 120s reicht nicht fuer BMW-Impressum (Auto-Discovery folgt
# 3 Sub-Docs). 240s gibt Spielraum. Mercedes faellt aktuell mit
# 120s auch oft an Akamai-Latenz.
async with httpx.AsyncClient(timeout=240.0) as client:
resp = await client.post(
f"{CONSENT_TESTER_URL}/dsi-discovery",
json={"url": url, "max_documents": max_docs},
timeout=240.0,
)
if resp.status_code == 200:
payload = resp.json()
docs = payload.get("documents", [])
cmp_payloads = payload.get("cmp_payloads") or []
cmp_cookie_text = payload.get("cmp_cookie_text") or ""
# D — wenn der consent-tester HTML-Tabellen aus dem DOM
# extrahiert hat, in die cmp_payloads als "generic_table"
# einschleusen damit das Backend sie via cookies_table_parser
# verarbeiten kann.
for doc in (docs or []):
for tbl in (doc.get("tables") or []):
if not tbl or len(tbl) < 3:
continue
cmp_payloads.append({
"kind": "html_table",
"url": doc.get("url", ""),
"rows": tbl,
})
if docs:
texts = []
for doc in docs:
t = doc.get("full_text", "") or doc.get("text_preview", "") or ""
if t and len(t) > 50:
texts.append(t)
merged = "\n\n".join(texts)
# For cookie/dse/social_media: when CMP reconstruction is
# substantially richer than DOM extraction, use it. This
# fixes the BMW case where DOM yields ~600 words of
# navigation but the ePaaS payload reconstructs to ~1800
# words of actual cookie policy.
if (doc_type in short_extract_types
and cmp_cookie_text
and len(cmp_cookie_text.split()) > len(merged.split())):
logger.info(
"Preferring CMP-reconstructed text for %s on %s "
"(%d words CMP vs %d words DOM)",
doc_type, url,
len(cmp_cookie_text.split()),
len(merged.split()),
)
merged = cmp_cookie_text
if merged and len(merged.split()) > 100:
if len(texts) > 1:
logger.info("Merged %d docs from %s (%d words)",
len(texts), url, len(merged.split()))
return merged, cmp_payloads
# P90-Bug-Fix: auch wenn DSE-Text zu kurz fuer 100-Wort-
# Schwelle ist, die captured CMP-Payloads NICHT verwerfen.
# BMW-Bug: DSE liefert 10 Wort SPA-Shell, aber ePaaS-JSON
# (393KB) wurde captured. Backend braucht die fuer
# extract_vendors_from_payloads (VVT-Tabelle).
if cmp_payloads:
logger.info(
"P90: keeping %d CMP payloads for %s despite "
"short text (%d words) — HTTP fallback runs in parallel",
len(cmp_payloads), url,
len((merged or cmp_cookie_text).split()),
)
fallback_text = merged or cmp_cookie_text or ""
return fallback_text, cmp_payloads
except Exception as e:
# P90: verbose exception fuer Diagnose (war vorher empty)
logger.warning("Consent-tester fetch failed for %s: %s (%s)",
url, str(e) or "(empty)", type(e).__name__)
# 2. Fallback: direct HTTP fetch (works for SSR pages like BMW).
# P7: kenntlicher UA + per-Domain Rate-Limit.
try:
from compliance.services.compliance_user_agent import (
default_request_headers, DomainRateLimiter,
)
async with httpx.AsyncClient(
timeout=30.0, follow_redirects=True,
headers=default_request_headers(),
) as client:
async with DomainRateLimiter(url):
resp = await client.get(url)
if resp.status_code == 200 and "text/html" in resp.headers.get("content-type", ""):
html = resp.text
# Strip HTML tags, decode entities
text = _re.sub(r"<script[^>]*>.*?</script>", " ", html, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<style[^>]*>.*?</style>", " ", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<[^>]+>", " ", text)
text = _re.sub(r"\s+", " ", text).strip()
if len(text.split()) > 100:
logger.info("HTTP fallback for %s: %d words", url, len(text.split()))
return text, []
except Exception as e:
logger.warning("HTTP fallback failed for %s: %s", url, e)
return "", []
@@ -0,0 +1,228 @@
"""Pure helpers for the compliance-check route — no I/O, no async.
Grouped here because each is small and they share the same constants
imports. Splitting further would not improve readability.
"""
from __future__ import annotations
import logging
from urllib.parse import urlparse
from ._constants import (
_ALL_DOC_TYPES,
_COMPOUND_TLDS,
_DISCOVERY_RULES,
_DOC_TYPE_LABELS,
_compliance_check_jobs,
)
logger = logging.getLogger(__name__)
def _update(check_id: str, msg: str, pct: int | None = None) -> None:
"""Update the in-memory job entry with a progress message + pct."""
job = _compliance_check_jobs[check_id]
job["progress"] = msg
if pct is not None:
job["progress_pct"] = max(0, min(100, int(pct)))
def _doc_type_label(doc_type: str) -> str:
return _DOC_TYPE_LABELS.get(doc_type, doc_type.upper())
def _classify_discovered_doc(title: str, url: str) -> str | None:
"""Map a discovered doc (by its title + URL) to one of our 8 canonical types."""
haystack = f"{title} {url}"
for canon, keywords in _DISCOVERY_RULES:
if any(kw in haystack for kw in keywords):
return canon
return None
def _extract_domain(doc_entries: list[dict]) -> str | None:
"""Extract base domain (without www) from first URL."""
for entry in doc_entries:
url = entry.get("url", "")
if url and "://" in url:
host = urlparse(url).netloc.lower()
if host.startswith("www."):
host = host[4:]
return host or None
return None
def _company_name_from_url(doc_entries: list[dict]) -> str | None:
"""Derive a display company name from the entered URLs.
Heuristic: take the second-level domain (e.g. "bmw" from "www.bmw.de"),
uppercase short acronyms (<=4 chars, no hyphens), title-case the rest.
Examples:
www.bmw.de -> BMW
mercedes-benz.de -> Mercedes-Benz
shop.example.co.uk -> Example
juris.de -> Juris
"""
for entry in doc_entries:
url = entry.get("url", "")
if not url or "://" not in url:
continue
host = urlparse(url).netloc.lower()
if host.startswith("www."):
host = host[4:]
parts = host.split(".")
if len(parts) < 2:
continue
# Handle compound TLDs (.co.uk etc.)
if len(parts) >= 3 and ".".join(parts[-2:]) in _COMPOUND_TLDS:
sld = parts[-3]
else:
sld = parts[-2]
if not sld:
continue
if len(sld) <= 4 and "-" not in sld:
return sld.upper()
return "-".join(p.capitalize() for p in sld.split("-"))
return None
def _get_skip_types(profile) -> dict[str, str]:
"""Doc_types to skip entirely with a per-type reason message.
Heute primaer fuer OEM-Konfigurator-Pattern (BMW/Audi/Mercedes):
wenn die Site kein Direkt-Vertrieb macht, sind AGB/Widerruf/
Nutzungsbedingungen nicht Pflicht auf der Website — sie werden
beim Vertragshaendler ausgehaendigt.
"""
if getattr(profile, "no_direct_sales", False):
msg = (
"Nicht anwendbar — die Webseite schliesst keinen Direkt-"
"Kaufvertrag (OEM-Konfigurator-Pattern, Vertrag laeuft "
"ueber Vertragshaendler). AGB/Widerruf werden beim "
"Haendler ausgehaendigt."
)
return {
"agb": msg,
"widerruf": msg,
"nutzungsbedingungen": msg,
}
return {}
def _apply_profile_filter(result, profile, doc_type: str):
"""Adjust INFO-level checks based on business profile context.
For example: ODR check only relevant for B2C online shops.
"""
for check in result.checks:
cid = check.id.lower()
# ODR/OS-Link: relevant ONLY for B2C online shops. The check's
# default hint is written for B2B (it explains why it's not
# relevant) — for B2C we must replace it with action-oriented
# guidance, otherwise the report contradicts itself.
if "odr" in cid or "os-link" in cid or "streitbeilegung" in check.label.lower():
if profile.needs_odr:
if not check.passed:
check.hint = (
"Als B2C-Anbieter muessen Sie nach Art. 14 EU-VO 524/2013 "
"auf die OS-Plattform (https://ec.europa.eu/consumers/odr) "
"verlinken — klickbarer Link, nicht nur Text. Zusaetzlich "
"§36 VSBG: angeben, ob Sie an Verbraucher-"
"Streitbeilegungsverfahren teilnehmen (oder nicht)."
)
else:
check.skipped = True
check.hint = "Nicht relevant (kein B2C Online-Shop)"
# Widerruf: Flag entire document as unnecessary for B2B
if doc_type == "widerruf" and profile.business_type not in ("b2c", "unknown"):
check.severity = "INFO"
if not check.passed:
check.hint = (
"Als B2B-Unternehmen benoetigen Sie keine Widerrufsbelehrung "
"(§355 BGB gilt nur fuer Verbrauchervertraege). "
"Empfehlung: Entfernen Sie die Widerrufsbelehrung von "
"Ihrer Website, da sie Verwirrung stiften kann."
)
# Regulated profession: check for Kammer info
if "kammer" in cid or "berufsordnung" in check.label.lower():
if not profile.is_regulated_profession:
check.skipped = True
check.hint = "Nicht relevant (kein regulierter Beruf)"
return result
def _pad_results_with_missing(
results: list,
discovery_attempted: set[str] | None = None,
) -> list:
"""Ensure every canonical doc_type has an entry in the results list.
Doc_types the user did not submit AND auto-discovery did not find get
a placeholder DocCheckResult. The error message distinguishes:
- 'Auf der Website nicht gefunden' (discovery was attempted)
- 'Nicht eingereicht' (no submitted URLs to crawl from)
Preserves the canonical ordering from _ALL_DOC_TYPES so the report
layout is stable.
"""
from ..agent_doc_check_routes import DocCheckResult
attempted = discovery_attempted or set()
by_type: dict[str, object] = {}
for r in results:
canon = "dse" if r.doc_type in ("datenschutz", "privacy") else r.doc_type
by_type[canon] = r
ordered: list = []
for dt in _ALL_DOC_TYPES:
if dt in by_type:
ordered.append(by_type[dt])
continue
if dt in attempted:
msg = ("Auf der Website nicht gefunden — bitte URL des "
"Dokuments manuell eintragen, falls vorhanden")
else:
msg = "Nicht eingereicht — Quelle nicht angegeben"
ordered.append(DocCheckResult(
label=_doc_type_label(dt),
url="",
doc_type=dt,
word_count=0,
completeness_pct=0,
correctness_pct=0,
checks=[],
findings_count=0,
error=msg,
scenario="missing",
))
extras = [r for r in results
if (r.doc_type if r.doc_type not in ("datenschutz", "privacy") else "dse")
not in _ALL_DOC_TYPES]
ordered.extend(extras)
return ordered
def _result_to_dict(r) -> dict:
"""Convert DocCheckResult to JSON-serializable dict."""
fields = ("id", "label", "passed", "severity", "matched_text",
"level", "parent", "skipped", "hint")
return {
"label": r.label, "url": r.url, "doc_type": r.doc_type,
"word_count": r.word_count, "completeness_pct": r.completeness_pct,
"correctness_pct": r.correctness_pct,
"checks": [{f: getattr(c, f) for f in fields} for c in r.checks],
"findings_count": r.findings_count, "error": r.error,
"scenario": getattr(r, "scenario", ""),
}
def _build_profile_html(profile) -> str:
from ..agent_doc_check_report import build_profile_html
return build_profile_html(profile)
@@ -0,0 +1,69 @@
"""Thin orchestrator — runs the 6 phases of the compliance check.
The original `_run_compliance_check` was a 1620-line monolith. It is
now decomposed into six phases (A=resolve, B=profile+check,
C=banner+extract, D=report-build [D1 raw vendors, D2 finalize,
D3-top/mid/bot blocks], E=email, F=persist), each in its own module.
State flows through a single mutable `dict` (see `_state.new_state`).
This intentionally trades type safety for additive flexibility: the
report-building phase routinely adds new optional keys for each new
HTML block, and a typed dataclass would freeze the schema before the
new blocks could land.
"""
from __future__ import annotations
import logging
from ._b1_wiring import run_b1
from ._b3_wiring import run_b3
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_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
from ._phase_e_email import run_phase_e
from ._phase_f_persist import run_phase_f
from ._state import new_state
logger = logging.getLogger(__name__)
async def run_compliance_check(check_id: str, req) -> None:
"""Background task: check all documents with business-profile context."""
state = new_state(check_id, req)
try:
# Phase A: TDM gate + Step 1 (resolve / discover / split / dedup)
continue_run = await run_phase_a(state)
if not continue_run:
return # TDM denied — job already marked skipped_tdm
# Phase B: Step 2 (profile detect) + Step 3 (per-doc checks)
await run_phase_b(state)
# Phase C: Step 3b-d (banner + cross-check + TCF) + Step 4
await run_phase_c(state)
# Phase D-1/D-2: Step 5 vendor extraction + finalize
await run_phase_d1(state)
await run_phase_d2(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)
# Phase D-3 top/mid/bot: Step 5 HTML blocks
await run_phase_d3_top(state)
await run_phase_d3_mid(state)
await run_phase_d3_bot(state)
# Phase E: Step 6 send mail (with A1 ZIP attachment)
run_phase_e(state)
# Phase F: Step 7 persist + audit log + unified findings
run_phase_f(state)
except Exception as e:
logger.error("Compliance check %s failed: %s",
check_id, e, exc_info=True)
_compliance_check_jobs[check_id]["status"] = "failed"
_compliance_check_jobs[check_id]["error"] = str(e)[:500]
@@ -0,0 +1,232 @@
"""Phase A — TDM gate + text resolution + section split + dedup.
Covers (in the original `_run_compliance_check`):
- TDM-reservation pre-check (§ 44b UrhG)
- Step 1 Resolve texts (URL fetch / pasted text / auto-reclassify)
- Step 1a Auto-discovery of missing canonical doc_types
- Step 1b Section splitting (shared URL → multiple doc_types,
DSI → Cookie/Social-Media auto-fill)
- Step 1c Cross-document keyword search
- P15 Dedup of doc_types referencing the same source document
Returns True to continue, False if the run was aborted (TDM denied).
"""
from __future__ import annotations
import logging
from ._constants import _compliance_check_jobs
from ._discovery import _autodiscover_missing
from ._fetch import _fetch_text
from ._helpers import _update
logger = logging.getLogger(__name__)
async def run_phase_a(state: dict) -> bool:
"""Run TDM gate + Step 1 + Step 1a-c + P15 dedup. Mutate state in place."""
check_id = state["check_id"]
req = state["req"]
# Reset anchor-locator cache per run (avoid cross-run leak)
try:
from compliance.services.doc_anchor_locator import reset_cache
reset_cache()
except Exception:
pass
# P7: TDM-Reservation-Check der Base-Domain (§ 44b UrhG).
# Bei reserved/denied: Run sofort beenden, kein Crawl.
try:
from compliance.services.tdm_reservation_check import (
check_tdm_reservation, is_crawl_allowed,
)
first_url = next(
(d.url for d in req.documents if d.url), "",
)
if first_url:
tdm = await check_tdm_reservation(first_url)
_compliance_check_jobs[check_id]["tdm"] = tdm
# P12: Bei tdm_override + Reason wird NICHT abgebrochen,
# sondern nur dokumentiert. Override ohne Reason wird ignoriert.
override_active = (
req.tdm_override
and len((req.tdm_override_reason or "").strip()) >= 10
)
if not is_crawl_allowed(tdm) and not override_active:
_compliance_check_jobs[check_id]["status"] = "skipped_tdm"
_compliance_check_jobs[check_id]["error"] = (
f"TDM-Vorbehalt fuer {tdm.get('domain')} erkannt "
f"(status={tdm.get('status')}) — Crawl nach § 44b "
f"UrhG nicht zulaessig. Signals: "
f"{[s.get('src') for s in tdm.get('signals', [])]}"
)
_compliance_check_jobs[check_id]["progress_pct"] = 100
logger.info("TDM-skip check_id=%s domain=%s status=%s",
check_id, tdm.get("domain"), tdm.get("status"))
return False
if override_active and not is_crawl_allowed(tdm):
_compliance_check_jobs[check_id]["tdm_override"] = {
"reason": req.tdm_override_reason.strip()[:500],
"original_status": tdm.get("status"),
}
logger.warning(
"TDM-Override aktiv: check_id=%s domain=%s "
"status=%s reason=%r",
check_id, tdm.get("domain"), tdm.get("status"),
req.tdm_override_reason.strip()[:80],
)
except Exception as e:
logger.warning("TDM-check failed (proceeding): %s", e)
# Step 1: Resolve texts (fetch from URL if needed) — 0-30%
_update(check_id, "Texte werden geladen...", 1)
doc_texts: dict[str, str] = {}
doc_entries: list[dict] = []
# Cache fetched URLs to detect duplicates
url_text_cache: dict[str, str] = {}
n_docs = max(1, len(req.documents))
# User-pasted-Tabellen-Vendors (kein LLM noetig) — werden weiter
# unten in cmp_vendors gemerged.
pasted_table_vendors: list[dict] = []
for i, doc in enumerate(req.documents):
pct = int(1 + (i / n_docs) * 29)
_update(check_id, f"Texte laden {i+1}/{n_docs}: {doc.doc_type}...", pct)
text = (doc.text or "").strip()
input_source = "url"
cmp_payloads: list[dict] = []
if text:
input_source = "text"
if doc.url:
input_source = "text+url" # User hat beide gefuellt
logger.info(
"doc_type=%s: User hat URL UND Text geliefert — "
"Text gewinnt, URL wird als Quellen-Referenz behalten",
doc.doc_type,
)
elif doc.url:
url_key = doc.url.strip().rstrip("/").lower()
if url_key in url_text_cache:
text = url_text_cache[url_key]
else:
text, cmp_payloads = await _fetch_text(doc.url, doc_type=doc.doc_type)
if text:
url_text_cache[url_key] = text
# Auto-Reclassify-Check: wenn der user Text in das falsche
# Doc-Type-Feld kopiert hat (z.B. Impressum-Text in DSE),
# erkennen und ggf. umtaggen.
actual_doc_type = doc.doc_type
reclassify_hint: dict | None = None
if input_source.startswith("text") and len(text) >= 500:
try:
from compliance.services.doc_type_classifier import (
detect_mismatch,
)
reclassify_hint = detect_mismatch(doc.doc_type, text)
if reclassify_hint and reclassify_hint["action"] == "reclassify":
actual_doc_type = reclassify_hint["detected"]
logger.info(
"doc_type AUTO-RECLASSIFY: deklariert=%s "
"erkannt=%s (score %d vs %d) — uebernehme erkannten Typ",
doc.doc_type, actual_doc_type,
reclassify_hint["detected_score"],
reclassify_hint["declared_score"],
)
except Exception as e:
logger.warning("doc_type_classifier failed: %s", e)
# Cookie-Tabelle: wenn User Tabelle reinkopiert hat, deterministisch
# parsen (kein LLM noetig) und Vendors gleich ableiten.
if input_source.startswith("text") and actual_doc_type == "cookie":
try:
from compliance.services.cookies_table_parser import (
parse_cookie_table,
)
tab_vendors = parse_cookie_table(text)
if tab_vendors:
pasted_table_vendors.extend(tab_vendors)
logger.info(
"Cookie-Tabelle erkannt im pasted Text — "
"%d Vendors / %d Cookies deterministisch geparst",
len(tab_vendors),
sum(len(v.get("cookies", [])) for v in tab_vendors),
)
except Exception as e:
logger.warning("cookies_table_parser failed: %s", e)
if text:
doc_texts[actual_doc_type] = text
doc_entries.append({
"doc_type": actual_doc_type,
"declared_doc_type": doc.doc_type,
"url": doc.url,
"text": text,
"word_count": len(text.split()) if text else 0,
"auto_discovered": False,
"discovery_attempted": False,
"cmp_payloads": cmp_payloads,
"input_source": input_source,
"reclassify_hint": reclassify_hint,
})
# Step 1a-bis: AUTO-DISCOVERY
await _autodiscover_missing(
check_id, doc_entries, doc_texts, url_text_cache,
)
# Step 1b: Section splitting — two cases:
# 1. Same URL used for multiple doc_types → split by heading
# 2. DSI text contains Cookie/Social-Media sections → auto-fill empty rows
from compliance.services.section_splitter import (
split_shared_texts, auto_fill_from_dsi, cross_search_documents,
)
split_shared_texts(doc_entries, url_text_cache)
auto_fill_from_dsi(doc_entries)
# Step 1c: Cross-document search — find doc_types in wrong documents (30-35%)
_update(check_id, "Dokumente werden uebergreifend durchsucht...", 32)
placement_findings = cross_search_documents(doc_entries)
# Refresh doc_texts after all splitting/searching
for entry in doc_entries:
if entry.get("text"):
doc_texts[entry["doc_type"]] = entry["text"]
# P15: Dedupe — wenn mehrere Doc-Types DASSELBE Dokument referenzieren
# (z.B. Safetykon: User gibt /datenschutz fuer dse + cookie + widerruf),
# behalten wir nur den primaeren Doc-Type. Andere: leeren + note.
# Priorität: dse > impressum > cookie > widerruf > agb > nutzungsbedingungen
_DOC_PRIORITY = ["dse", "impressum", "cookie", "widerruf", "agb",
"nutzungsbedingungen", "social_media", "dsb"]
seen_text_hash: dict[int, str] = {}
for dt in _DOC_PRIORITY:
entry = next((e for e in doc_entries if e.get("doc_type") == dt
and e.get("text")), None)
if not entry:
continue
text_hash = hash((entry.get("text") or "").strip()[:1000])
if text_hash in seen_text_hash:
primary = seen_text_hash[text_hash]
logger.info(
"P15 dedup: doc_type=%s referenziert dasselbe Dokument "
"wie %s (URL=%s) -> als Duplikat markiert.",
dt, primary, entry.get("url", "")[:60],
)
entry["text"] = ""
entry["word_count"] = 0
entry["url"] = ""
entry["dup_of"] = primary
doc_texts.pop(dt, None)
else:
seen_text_hash[text_hash] = dt
state["doc_texts"] = doc_texts
state["doc_entries"] = doc_entries
state["url_text_cache"] = url_text_cache
state["pasted_table_vendors"] = pasted_table_vendors
state["placement_findings"] = placement_findings
return True
@@ -0,0 +1,183 @@
"""Phase B — Business-profile detection + per-document checks.
Covers (in the original `_run_compliance_check`):
- Step 2 Detect business profile (with optional homepage merge for
P16 keywords)
- Step 3 Run regex + MC + LLM checks on each submitted document
(`_check_single`), applying skip rules + profile filter
+ placement findings
"""
from __future__ import annotations
import logging
import os
import re as _re
from dataclasses import asdict
import httpx
from ._helpers import (
_apply_profile_filter,
_doc_type_label,
_get_skip_types,
_update,
)
from ._single_check import _check_single
logger = logging.getLogger(__name__)
async def run_phase_b(state: dict) -> None:
"""Detect business profile + check each document. Mutates state in place."""
check_id = state["check_id"]
req = state["req"]
doc_texts = state["doc_texts"]
doc_entries = state["doc_entries"]
placement_findings = state["placement_findings"]
# Step 2: Detect business profile (35-40%)
from compliance.services.business_profiler import detect_business_profile
_update(check_id, "Geschaeftsmodell wird erkannt...", 37)
# P16: Homepage-Text mit fuer Profile-Detection (no_direct_sales
# B2B-Indikatoren wie "CE-Zertifizierung" / "Schulungen" stehen oft
# nur im Homepage-Menue, nicht im Pflichttext).
profile_input = dict(doc_texts)
try:
base_url = ""
for e in doc_entries:
if e.get("url"):
from urllib.parse import urlparse
p = urlparse(e["url"])
if p.scheme and p.netloc:
base_url = f"{p.scheme}://{p.netloc}/"
break
if base_url:
async with httpx.AsyncClient(
timeout=8.0, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 HeadlessChrome/120.0.0.0"},
) as _hc:
_hr = await _hc.get(base_url)
if _hr.status_code == 200 and "text/html" in _hr.headers.get(
"content-type", ""):
_html = _hr.text[:60000]
_html = _re.sub(r"<script[^>]*>.*?</script>", " ",
_html, flags=_re.DOTALL | _re.IGNORECASE)
_html = _re.sub(r"<style[^>]*>.*?</style>", " ",
_html, flags=_re.DOTALL | _re.IGNORECASE)
_html = _re.sub(r"<[^>]+>", " ", _html)
_html = _re.sub(r"\s+", " ", _html).strip()
if len(_html.split()) > 30:
profile_input["__homepage"] = _html[:20000]
logger.info("P16 homepage merged for profile: %d words",
len(_html.split()))
except Exception as e:
logger.debug("homepage fetch for profile failed: %s", e)
profile = await detect_business_profile(profile_input)
profile_dict = asdict(profile)
# Step 3: Check each document
from ..agent_doc_check_routes import CheckItem, DocCheckResult
results: list[DocCheckResult] = []
total_findings = 0
use_agent_flag = req.use_agent or os.getenv(
"COMPLIANCE_USE_AGENT", "false",
).lower() == "true"
# Filter out doc_types that don't apply to this business profile
skip_types = _get_skip_types(profile)
# Derive business_scope hints for the MC filter (O1 — Doc-type Scope-Flag).
# MCs that explicitly require a feature (e.g. 'biometric_processing',
# 'ai_decision_making', 'child_targeting') get dropped when the
# detected profile doesn't declare it.
business_scope: set[str] = set()
for svc in (getattr(profile, "detected_services", []) or []):
business_scope.add(str(svc).lower())
if (getattr(profile, "business_type", "") or "").lower() == "b2c":
business_scope.add("b2c")
if getattr(profile, "has_online_shop", False):
business_scope.add("ecommerce")
if getattr(profile, "is_regulated_profession", False):
business_scope.add("regulated_profession")
# Document checks: 40-80%
n_entries = max(1, len(doc_entries))
for i, entry in enumerate(doc_entries):
text = entry["text"]
doc_type = entry["doc_type"]
label = _doc_type_label(doc_type)
url = entry["url"]
if doc_type in skip_types:
results.append(DocCheckResult(
label=label, url=url, doc_type=doc_type,
error=skip_types[doc_type],
))
continue
pct = int(40 + (i / n_entries) * 40)
_update(check_id, f"Pruefen {i+1}/{n_entries}: {label}...", pct)
if not text or len(text) < 50:
# P15: duplicate doc that was deduped against a primary doc
if entry.get("dup_of"):
results.append(DocCheckResult(
label=label, url="", doc_type=doc_type,
error=f"Nicht separat vorhanden — wird im Dokument "
f"'{_doc_type_label(entry['dup_of'])}' "
f"mit-geprueft.",
))
continue
# P24: DSB-Kontakt ist Pflichtangabe in der DSE (Art. 13(1)(b)
# DSGVO) — wenn kein separates DSB-Dokument vorliegt, ist das
# KEIN Fehler. DSB-Pruefung passiert ohnehin in der DSE.
if doc_type == "dsb" and not (entry.get("url") or "").strip():
results.append(DocCheckResult(
label=label, url="", doc_type=doc_type,
error="Nicht separat vorhanden — DSB-Kontaktdaten "
"werden in der Datenschutzerklaerung als "
"Pflichtangabe nach Art. 13(1)(b) DSGVO geprueft.",
))
continue
# Empty entry — either from auto-discovery padding (no URL
# to fetch) or from a fetch that returned nothing. If there
# was a URL we keep the error so the user knows the fetch
# failed; otherwise let the padding step label it
# 'Nicht eingereicht' / 'Auf der Website nicht gefunden'.
if (entry.get("url") or "").strip():
results.append(DocCheckResult(
label=label, url=url, doc_type=doc_type,
error="Kein Text vorhanden oder zu kurz",
))
continue
result = await _check_single(
text, doc_type, label, url,
entry["word_count"], use_agent_flag,
business_scope=business_scope,
business_profile={"no_direct_sales": getattr(profile, "no_direct_sales", False)},
)
# Apply profile context filter
result = _apply_profile_filter(result, profile, doc_type)
# Add placement findings — but only if the regex checks confirm
# the text doesn't match. If completeness >= 50%, the text IS the
# right doc_type despite missing cross-search keywords.
if result.completeness_pct < 50:
for pf in placement_findings:
if pf.get("doc_type") == doc_type:
result.checks.insert(0, CheckItem(**{
k: v for k, v in pf.items() if k != "doc_type"
}))
results.append(result)
total_findings += result.findings_count
state["profile"] = profile
state["profile_dict"] = profile_dict
state["business_scope"] = business_scope
state["results"] = results
state["total_findings"] = total_findings
@@ -0,0 +1,129 @@
"""Phase C — Banner scan + Cookie/DSE cross-check + TCF check + profile extract.
Covers (in the original `_run_compliance_check`):
- Step 3b Cookie-banner scan via consent-tester /scan (homepage,
3-phase consent test)
- Step 3c Cross-check banner findings vs. cookie-policy text
- Step 3d TCF vendor vs. DSI cross-check + VVT entries
- Step 4 Extract profile hints from documents
- Step 4b Determine scenario per document (skip / regenerate / fix /
import)
- Step 4c Pad missing canonical doc_types so the report always shows
every checklist row
"""
from __future__ import annotations
import logging
import httpx
from ._constants import CONSENT_TESTER_URL
from ._helpers import _pad_results_with_missing, _update
logger = logging.getLogger(__name__)
async def run_phase_c(state: dict) -> None:
"""Run banner scan + cross-checks + profile extraction. Mutates state."""
check_id = state["check_id"]
req = state["req"]
doc_texts = state["doc_texts"]
doc_entries = state["doc_entries"]
results = state["results"]
profile_dict = state["profile_dict"]
# Step 3b: Banner-Check (automatic, uses first URL or homepage)
banner_result = None
banner_url = req.documents[0].url if req.documents and req.documents[0].url else ""
# Use the homepage (strip path) for banner check
if banner_url:
from urllib.parse import urlparse
parsed = urlparse(banner_url)
banner_url = f"{parsed.scheme}://{parsed.netloc}"
if banner_url:
_update(check_id, "Cookie-Banner wird geprueft...", 82)
try:
async with httpx.AsyncClient(timeout=900.0) as client: # P50: +10min for vendor-detail-phase
resp = await client.post(
f"{CONSENT_TESTER_URL}/scan",
json={"url": banner_url, "timeout_per_phase": 10},
)
if resp.status_code == 200:
banner_result = resp.json()
except Exception as e:
logger.warning(
"Banner check failed: %s (%s)", e or "<empty>", type(e).__name__,
)
# Step 3c: Cross-check Banner vs Cookie-Richtlinie (88-90%)
if banner_result and "cookie" in doc_texts:
from compliance.services.banner_cookie_cross_check import (
cross_check_banner_vs_cookie,
)
from ..agent_doc_check_routes import CheckItem
_update(check_id, "Banner vs. Cookie-Richtlinie abgleichen...", 89)
cross_findings = cross_check_banner_vs_cookie(
banner_result, doc_texts["cookie"],
)
if cross_findings:
for r in results:
if r.doc_type == "cookie":
for cf in cross_findings:
r.checks.append(CheckItem(**cf))
l2 = [c for c in r.checks if c.level == 2 and not c.skipped]
l2p = sum(1 for c in l2 if c.passed)
r.correctness_pct = round(l2p / len(l2) * 100) if l2 else 0
# Step 3d: TCF Vendor cross-check against DSI
tcf_vendors = banner_result.get("tcf_vendors", []) if banner_result else []
vvt_entries: list[dict] = []
if tcf_vendors and "dse" in doc_texts:
_update(check_id, f"{len(tcf_vendors)} TCF-Verarbeiter vs. DSI abgleichen...", 91)
from compliance.services.banner_cookie_cross_check import (
cross_check_vendors_vs_dsi,
)
from compliance.services.vendor_vvt_mapper import map_vendors_to_vvt
from ..agent_doc_check_routes import CheckItem
vendor_findings = cross_check_vendors_vs_dsi(tcf_vendors, doc_texts["dse"])
if vendor_findings:
for r in results:
if r.doc_type == "dse":
for vf in vendor_findings:
r.checks.append(CheckItem(**vf))
vvt_entries = map_vendors_to_vvt(tcf_vendors)
# Step 4: Extract profile hints from documents (92-95%)
_update(check_id, "Profil wird aus Dokumenten extrahiert...", 93)
from compliance.services.profile_extractor import (
extract_profile_from_documents,
)
extracted_profile = extract_profile_from_documents(doc_texts, profile_dict)
# Step 4b: Determine scenario per document
for r in results:
if r.error:
r.scenario = "skip"
elif r.completeness_pct < 30:
r.scenario = "regenerate"
elif r.completeness_pct < 95:
r.scenario = "fix"
else:
r.scenario = "import"
# Step 4c: Always render all 8 canonical doc types. Missing types
# are differentiated:
# - Discovery was tried but found nothing -> 'Auf der Website
# nicht gefunden' (suggest user provides URL manually)
# - No submitted URLs at all -> 'Nicht eingereicht'
attempted = {
e["doc_type"] for e in doc_entries if e.get("discovery_attempted")
}
results = _pad_results_with_missing(results, discovery_attempted=attempted)
state["banner_result"] = banner_result
state["banner_url"] = banner_url
state["tcf_vendors"] = tcf_vendors
state["vvt_entries"] = vvt_entries
state["extracted_profile"] = extracted_profile
state["results"] = results
@@ -0,0 +1,315 @@
"""Phase D-1 — Vendor-extraction raw stages.
Covers (in the original Step 5 of `_run_compliance_check`):
- Aggregate cmp_payloads from all doc_entries + banner_result (P30/P48)
- Fallback: use DSE text when cookie was deduped (P17-D)
- Extract structured vendor records from CMP payloads
- LLM-cascade fallback when structured extract yields < 5 vendors (P52)
- Phase-G vendor-details append (P57)
- HTML-table DOM parse (Stage D)
- Crawled cookie-table parse (Stage B)
- Tesseract OCR over evidence slices (Stage C) — also captures the
cookie_evidence_slices used by A1 e-mail attachment
"""
from __future__ import annotations
import logging
from ._helpers import _company_name_from_url, _update
logger = logging.getLogger(__name__)
async def run_phase_d1(state: dict) -> None:
"""Vendor-extract raw stages. Mutates state in place."""
check_id = state["check_id"]
doc_entries = state["doc_entries"]
doc_texts = state["doc_texts"]
banner_result = state["banner_result"]
pasted_table_vendors = state["pasted_table_vendors"]
cmp_vendors: list[dict] = []
cookie_payloads: list[dict] = []
cookie_text = ""
cookie_evidence_slices: list[dict] | None = None
cookie_evidence_meta: dict | None = None
try:
from compliance.services.vendor_extractor import (
extract_vendors_from_payloads,
)
# P30: aggregate cmp_payloads from ALL doc_entries — sites
# like Mercedes load Usercentrics only on the homepage, so the
# JSON gets captured during DSE/Impressum discovery, not in the
# cookies.html fetch. Dedup by URL since the same payload is
# captured on every page load.
seen_cmp_urls: set[str] = set()
for e in doc_entries:
for p in (e.get("cmp_payloads") or []):
p_url = p.get("url") or ""
if p_url and p_url in seen_cmp_urls:
continue
seen_cmp_urls.add(p_url)
cookie_payloads.append(p)
if e.get("doc_type") == "cookie" and e.get("text"):
cookie_text = e["text"]
# P48: also pull cmp_payloads from the Banner-Scan (homepage 3-phase
# consent test). Mercedes' Usercentrics-JSON is captured there even
# when not in DSI-Discovery of static legal pages.
if banner_result:
for p in (banner_result.get("cmp_payloads") or []):
p_url = p.get("url") or ""
if p_url and p_url in seen_cmp_urls:
continue
seen_cmp_urls.add(p_url)
cookie_payloads.append(p)
if cookie_payloads:
logger.info("P48: %d CMP-payloads available for vendor-extract "
"(after Banner-Scan merge)", len(cookie_payloads))
# P17-D: Fallback wenn cookie via P15 deduped wurde — nutze DSE-Text
# sofern Cookie-Begriffe drin sind, damit LLM-Vendor-Extract trotzdem
# greifen kann.
if not cookie_text and not cookie_payloads:
dse_t = doc_texts.get("dse", "")
if dse_t and any(w in dse_t.lower() for w in
("cookie", "tracking", "google analytics", "consent")):
cookie_text = dse_t
logger.info("P17-D: vendor-extract Fallback auf DSE (Cookie deduped)")
owner_name = _company_name_from_url(doc_entries) or ""
if cookie_payloads:
cmp_vendors = extract_vendors_from_payloads(
cookie_payloads, owner_name=owner_name,
)
# P52: LLM-Fallback nicht nur wenn 0 Vendors, sondern auch wenn die
# strukturierten Quellen < 5 Vendors lieferten und der Cookie-Text
# substantiell ist.
if (len(cmp_vendors) < 5
and cookie_text and len(cookie_text.split()) >= 500):
from compliance.services.vendor_llm_extractor import (
extract_vendors_via_llm,
)
from compliance.services.vendor_classifier import classify
_update(check_id, "Vendor-Liste per LLM extrahieren...", 94)
llm_vendors = await extract_vendors_via_llm(cookie_text)
existing_names = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
added_llm = 0
for v in llm_vendors:
nm = (v.get("name") or "").strip()
if not nm or nm.lower() in existing_names:
continue
v["recipient_type"] = classify(
vendor_name=nm,
category=v.get("category", ""),
owner_name=owner_name,
)
v.setdefault("source", "llm_cascade")
cmp_vendors.append(v)
existing_names.add(nm.lower())
added_llm += 1
if added_llm:
logger.info("P52 LLM-Cascade: +%d Vendors (total: %d)",
added_llm, len(cmp_vendors))
# P57: Phase G vendor_details als zusätzliche Vendor-Quelle.
if banner_result:
vd_list = banner_result.get("vendor_details") or []
vd_list = [v for v in vd_list if v.get("name") != "__TDM_OPTOUT__"]
existing_names = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
added = 0
for d in vd_list:
n = (d.get("name") or "").strip()
if not n or n.lower() in existing_names:
continue
if n.lower() in ("technisch erforderlich", "analyse und statistik",
"marketing", "alles auswählen",
"alles auswaehlen"):
continue
from compliance.services.vendor_classifier import classify
cmp_vendors.append({
"name": n,
"country": "",
"purpose": d.get("description", "")[:500],
"category": "",
"opt_out_url": d.get("opt_out_url", ""),
"privacy_policy_url": d.get("privacy_url", ""),
"persistence": d.get("retention", ""),
"cookies": d.get("cookies", []),
"processing_company": d.get("processing_company", ""),
"address": d.get("address", ""),
"purposes": d.get("purposes", []),
"technologies": d.get("technologies", []),
"recipient_type": classify(
vendor_name=n, category="", owner_name=owner_name,
),
})
existing_names.add(n.lower())
added += 1
if added:
logger.info("P57: added %d new vendors from Phase G (total: %d)",
added, len(cmp_vendors))
# D — HTML-Tabellen aus DOM
for pl in (cookie_payloads or []):
if pl.get("kind") != "html_table":
continue
rows = pl.get("rows") or []
if len(rows) < 3:
continue
try:
from compliance.services.cookies_table_parser import (
parse_cookie_table as _parse_ct_d,
)
table_text = "\n".join(rows)
d_vendors = _parse_ct_d(table_text)
if d_vendors:
existing_d = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
added_d = 0
for v in d_vendors:
nm = (v.get("name") or "").strip()
if not nm or nm.lower() in existing_d:
continue
v.setdefault("source", "html_table_dom")
cmp_vendors.append(v)
existing_d.add(nm.lower())
added_d += 1
if added_d:
logger.info("D HTML-Table-DOM-Parse: +%d Vendors aus "
"%d-Zeilen-Tabelle (total: %d)",
added_d, len(rows), len(cmp_vendors))
except Exception as e:
logger.warning("html_table parse failed: %s", e)
# B — cookies_table_parser auch auf gecrawltem Cookie-Text
if cookie_text and len(cookie_text) >= 500:
try:
from compliance.services.cookies_table_parser import (
parse_cookie_table as _parse_ct,
parse_flat_cookie_text as _parse_flat,
)
crawled_table_vendors = _parse_ct(cookie_text)
if not crawled_table_vendors:
crawled_table_vendors = _parse_flat(cookie_text)
if crawled_table_vendors:
existing = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
added_c = 0
for v in crawled_table_vendors:
nm = (v.get("name") or "").strip()
if not nm or nm.lower() in existing:
continue
v.setdefault("source", "table_crawled")
cmp_vendors.append(v)
existing.add(nm.lower())
added_c += 1
if added_c:
logger.info("B Crawled-Tabellen-Parse: +%d Vendors "
"(total: %d)", added_c, len(cmp_vendors))
except Exception as e:
logger.warning("crawled-table-parse failed: %s", e)
# C — Screenshot + Tesseract-OCR (auch Quelle für A1 ZIP-Anhang)
cookie_url_for_shot = ""
for _e in doc_entries:
if _e.get("doc_type") == "cookie" and _e.get("url"):
cookie_url_for_shot = _e["url"]; break
if cookie_url_for_shot:
try:
from compliance.services.cookie_screenshot_ocr import (
capture_cookie_evidence_slices,
cookies_to_vendor_records,
ocr_slices_extract_cookies,
)
from compliance.services.cookies_table_parser import (
_guess_vendor as _gv,
)
_update(check_id,
"Cookie-Richtlinie wird fotografiert "
"(lueckenlose Beweiskette)...", 92)
ev = await capture_cookie_evidence_slices(
cookie_url_for_shot, check_id=check_id,
viewport_h=1024, overlap_px=200, max_slices=40,
)
if ev.get("slices"):
cookie_evidence_slices = ev["slices"]
cookie_evidence_meta = {
"total_height_px": ev.get("total_height_px"),
"width_px": ev.get("width_px"),
"accepted_banner": ev.get("accepted_banner"),
"expanded": ev.get("expanded"),
"url": ev.get("url"),
"slice_count": len(ev["slices"]),
}
_update(check_id, "Tesseract OCR über alle Slices...", 93)
ocr_cookies, ocr_stats = ocr_slices_extract_cookies(
ev["slices"],
)
if ocr_cookies:
ocr_vendors = cookies_to_vendor_records(
ocr_cookies, guess_vendor_fn=_gv,
)
existing = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
added_v = 0
for v in ocr_vendors:
nm = (v.get("name") or "").strip()
if not nm:
continue
if nm.lower() in existing:
for ex in cmp_vendors:
if (ex.get("name") or "").strip().lower() == nm.lower():
ex_names = {
(c.get("name") or "").lower()
for c in (ex.get("cookies") or [])
}
for c in (v.get("cookies") or []):
if c["name"].lower() not in ex_names:
ex.setdefault("cookies", []).append(c)
ex_names.add(c["name"].lower())
cur_src = ex.get("source", "")
if "tesseract_ocr" not in cur_src:
ex["source"] = (cur_src + ";tesseract_ocr").strip(";")
break
continue
cmp_vendors.append(v)
existing.add(nm.lower())
added_v += 1
logger.info(
"C Tesseract-OCR: +%d Vendors / %d Cookies "
"(über %d Slices, total: %d)",
added_v, len(ocr_cookies),
ocr_stats.get("slices", 0), len(cmp_vendors),
)
except Exception as e:
logger.warning("Tesseract-OCR pipeline failed: %s (%s)",
str(e) or "(no msg)", type(e).__name__)
# User-pasted Cookie-Tabelle (deterministisch, kein LLM):
# die hat IMMER Vorrang weil 100% genau.
if pasted_table_vendors:
existing = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
added_p = 0
for v in pasted_table_vendors:
nm = (v.get("name") or "").strip()
if not nm or nm.lower() in existing:
continue
cmp_vendors.append(v)
existing.add(nm.lower())
added_p += 1
if added_p:
logger.info("Pasted-Tabellen-Merge: +%d Vendors (total: %d)",
added_p, len(cmp_vendors))
except Exception as e:
logger.warning("VVT vendor extraction skipped: %s", e)
state["cmp_vendors"] = cmp_vendors
state["cookie_payloads"] = cookie_payloads
state["cookie_text"] = cookie_text
state["cookie_evidence_slices"] = cookie_evidence_slices
state["cookie_evidence_meta"] = cookie_evidence_meta
@@ -0,0 +1,250 @@
"""Phase D-2 — Vendor finalize: enrich + normalize + library fallback.
Covers (in the original Step 5 of `_run_compliance_check`):
- Cookie-Library-Fallback (P52 Lite) — when < 20 vendors but many
after-accept cookies, resolve via library
- Vendor-Normalizer (Google-Familie dedup, garbage filter)
- Detail-modal enrichment from Phase G (P50) + TDM-opt-out sentinel
- Cookie-Behavior-Validator (P59b) — 3-Tier severity findings
- Implicit cookies detection (P61) — GTM brings GA/GCL/DoubleClick
- validate_vendor_urls + score_vendors + cookie-function classify
- Vendor-Redundanz (O4) + EU-Alternativen + Cost/Savings
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
async def run_phase_d2(state: dict) -> None:
"""Vendor finalize stages + redundancy. Mutates state in place."""
cmp_vendors = state["cmp_vendors"]
cookie_text = state.get("cookie_text", "")
banner_result = state["banner_result"]
banner_url = state["banner_url"]
profile = state["profile"]
business_scope = state["business_scope"]
tdm_opt_out_notice = ""
cookie_behavior_findings: list[dict] = []
redundancy_report = None
try:
from compliance.services.cookie_link_validator import (
score_vendors, validate_vendor_urls,
)
# Cookie-Library-Fallback (P52 Lite): wenn weiterhin wenige
# Vendors aber viele after_accept-Cookies, aus Library auflösen.
# VW-Lehre: 6 LLM-Grob-Vendors reichen NICHT — die Library
# holt 30+ weitere aus den Cookie-Namen + Cookie-Doc-Pattern.
# Schwelle: immer probieren wenn < 20 Vendors.
if banner_result and len(cmp_vendors) < 20:
try:
from compliance.services.cookie_to_vendor_fallback import (
fallback_vendors_for_run,
)
from database import SessionLocal as _SLfb
_fb_db = _SLfb()
try:
extra = fallback_vendors_for_run(
_fb_db, banner_result, len(cmp_vendors),
cookie_doc_text=cookie_text,
)
if extra:
existing_names = {(v.get("name") or "").strip().lower()
for v in cmp_vendors}
for v in extra:
if v["name"].lower() in existing_names:
continue
cmp_vendors.append(v)
logger.info(
"Cookie-Library-Fallback: cmp_vendors %d -> %d",
len(cmp_vendors) - len(extra), len(cmp_vendors),
)
finally:
_fb_db.close()
except Exception as e:
logger.warning("Cookie-Library-Fallback skipped: %s", e)
# Vendor-Normalizer: Dedup (Google-Familie etc) + Garbage-Filter
try:
from compliance.services.vendor_normalizer import (
normalize_vendors as _norm_v,
)
cmp_vendors = _norm_v(cmp_vendors)
except Exception as e:
logger.warning("vendor_normalizer skipped: %s", e)
# P50: enrich vendors with per-vendor detail-modal-extracts
if cmp_vendors and banner_result:
vendor_details = banner_result.get("vendor_details") or []
# P50f: filter out TDM-opt-out sentinel
tdm_sentinel = next((v for v in vendor_details
if v.get("name") == "__TDM_OPTOUT__"), None)
if tdm_sentinel:
tdm_opt_out_notice = tdm_sentinel.get("description", "")
logger.info("P50f: TDM opt-out — skipped detail-enrichment for vendors")
vendor_details = [v for v in vendor_details
if v.get("name") != "__TDM_OPTOUT__"]
if vendor_details:
details_by_name = {}
for d in vendor_details:
n = (d.get("name") or "").strip().lower()
if n:
details_by_name[n] = d
enriched = 0
for v in cmp_vendors:
key = (v.get("name") or "").strip().lower()
d = details_by_name.get(key)
if not d:
for dn, dv in details_by_name.items():
if key in dn or dn in key:
d = dv
break
if not d:
continue
if not v.get("country") and (d.get("processing_company") or d.get("address")):
addr = d.get("address", "")
if re.search(r"\b(deutschland|germany|berlin|m(?:ue|ü)nchen|hamburg|stuttgart)\b", addr, re.I):
v["country"] = "DE"
elif re.search(r"\bireland|irland|dublin\b", addr, re.I):
v["country"] = "IE"
elif re.search(r"\busa|united states|california|new york|delaware\b", addr, re.I):
v["country"] = "US"
if not v.get("purpose"):
v["purpose"] = d.get("description", "")[:500]
if not v.get("opt_out_url"):
v["opt_out_url"] = d.get("opt_out_url", "")
if not v.get("privacy_policy_url"):
v["privacy_policy_url"] = d.get("privacy_url", "")
if not v.get("cookies"):
v["cookies"] = d.get("cookies", [])
v["purposes"] = d.get("purposes", [])
v["technologies"] = d.get("technologies", [])
if not v.get("persistence"):
v["persistence"] = d.get("retention", "")
v["processing_company"] = d.get("processing_company", "")
v["address"] = d.get("address", "")
enriched += 1
logger.info("P50: enriched %d/%d vendors with detail-modal data",
enriched, len(cmp_vendors))
# P59b: Cookie-Behavior-Validator
if banner_result:
cookies_detailed = banner_result.get("cookies_detailed") or []
if cookies_detailed:
cb_session = None
try:
from database import SessionLocal
from compliance.services.cookie_behavior_validator import (
validate_cookie_behavior,
)
from urllib.parse import urlparse
fp_domain = ""
if banner_url:
fp_domain = urlparse(banner_url).netloc.replace("www.", "")
cb_session = SessionLocal()
cookie_behavior_findings = validate_cookie_behavior(
cb_session, cookies_detailed,
network_requests=[], # TODO Layer B in P59d
first_party_domain=fp_domain,
)
if cookie_behavior_findings:
sevs = {f["severity"] for f in cookie_behavior_findings}
logger.info(
"P59b: Cookie-Behavior-Check %d findings (severities: %s) "
"ueber %d Cookies",
len(cookie_behavior_findings),
sorted(sevs), len(cookies_detailed),
)
banner_result["cookie_behavior_findings"] = (
cookie_behavior_findings
)
else:
logger.info(
"P59b: Cookie-Behavior-Check 0 findings ueber %d Cookies "
"(library miss / clean)", len(cookies_detailed),
)
except Exception as cb_err:
logger.warning("P59b Cookie-Behavior-Check failed: %s", cb_err)
finally:
if cb_session is not None:
try:
cb_session.close()
except Exception:
pass
# P61: "Untergeschobene Cookies"
if banner_result and cmp_vendors:
try:
from compliance.services.vendor_package_cookies import (
detect_implicit_cookies,
)
declared = [v.get("name", "") for v in cmp_vendors if v.get("name")]
actual_cookies: list[str] = []
for phase_data in (banner_result.get("phases") or {}).values():
if isinstance(phase_data, dict):
for ck in (phase_data.get("cookies") or []):
if isinstance(ck, dict) and ck.get("name"):
actual_cookies.append(ck["name"])
implicit_findings = detect_implicit_cookies(
declared, actual_cookies_set=actual_cookies or None,
)
if implicit_findings:
banner_result["implicit_vendor_findings"] = implicit_findings
logger.info(
"P61: %d implicit vendor-package items detected "
"(%d cookies + %d vendors)",
len(implicit_findings),
sum(1 for f in implicit_findings if f["implicit"]["type"] == "cookie"),
sum(1 for f in implicit_findings if f["implicit"]["type"] == "vendor"),
)
except Exception as p61_err:
logger.warning("P61 implicit-vendor detection failed: %s", p61_err)
if cmp_vendors:
logger.info("VVT: %d vendors extracted, validating links",
len(cmp_vendors))
cmp_vendors = await validate_vendor_urls(cmp_vendors)
cmp_vendors = score_vendors(cmp_vendors)
try:
from compliance.services.cookie_function_classifier import (
annotate_vendor_cookies,
)
cmp_vendors = [annotate_vendor_cookies(v) for v in cmp_vendors]
except Exception as e:
logger.warning("Cookie function classification skipped: %s", e)
except Exception as e:
logger.warning("VVT vendor finalize skipped: %s", e)
# Vendor-Redundanz + EU-Alternativen + Cost/Savings (O4)
try:
from compliance.services.vendor_cost_estimator import infer_company_tier
from compliance.services.vendor_redundancy import (
analyze as analyze_redundancy,
)
if cmp_vendors:
bp_dict = {
"type": getattr(profile, "business_type", ""),
"features": list(business_scope),
}
ctier = infer_company_tier(bp_dict)
redundancy_report = analyze_redundancy(cmp_vendors, company_tier=ctier)
logger.info(
"Redundanz: %d Kategorien mit Mehrfach-Anbietern, "
"Spar-Schaetzung %s pro Jahr (company_tier=%s)",
redundancy_report["summary"]["redundancy_count"],
redundancy_report["summary"]["estimated_saving_pct"],
ctier,
)
except Exception as e:
logger.warning("Vendor redundancy analysis skipped: %s", e)
state["cmp_vendors"] = cmp_vendors
state["tdm_opt_out_notice"] = tdm_opt_out_notice
state["cookie_behavior_findings"] = cookie_behavior_findings
state["redundancy_report"] = redundancy_report
@@ -0,0 +1,220 @@
"""Phase D-3-Bot — Bottom HTML blocks + final composition.
Covers (in the original Step 5):
- P71 JC-vs-AVV Entscheidungsbaum (only when DSE ambig)
- P6/P53/P55 Branchen-Kontext + Site-History
- P106 Internal-Checks-Block
- P85 Banner-Screenshot
- A Audit-Quality-Checks (Banner-Detect-Failure, vendor-extract dünn)
- P82 GF-1-Pager
- Doc-Input-Warnings (User text in falsches Feld gepastet)
- P86 Branchen-Benchmark
- P84 Diff-Mode (since-last-run delta)
- Final HTML composition
NOTE: in the original code `audit_quality_findings` was used by
build_gf_one_pager_html BEFORE it was initialised — a silent
UnboundLocalError caught by the surrounding try/except, so the
gf_one_pager block effectively never rendered. Here we run
audit-quality FIRST so the data is actually available.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
async def run_phase_d3_bot(state: dict) -> None:
"""Bottom blocks + assemble full_html. Mutates state in place."""
check_id = state["check_id"]
req = state["req"]
doc_entries = state["doc_entries"]
doc_texts = state["doc_texts"]
banner_result = state["banner_result"]
cmp_vendors = state["cmp_vendors"]
mc_split = state["mc_split"]
scorecard = state["scorecard"]
prev_scorecard = state.get("prev_scorecard")
mismatches = state.get("mismatches") or []
site_name_for_exec = state.get("site_name_for_exec", "")
domain_for_exec = state.get("domain_for_exec")
html_blocks = state["html_blocks"]
# P71: JC-vs-AVV Entscheidungsbaum
jc_decision_html = ""
try:
from compliance.services.jc_avv_decision import (
build_jc_avv_decision_html,
)
jc_decision_html = build_jc_avv_decision_html(doc_texts.get("dse"))
except Exception as e:
logger.warning("P71 jc_avv_decision skipped: %s", e)
# P6/P53/P55 — Branchen-Kontext + Site-History
industry_ctx_html = ""
try:
from compliance.services.industry_library import (
build_industry_context_block_html, load_site_profile,
)
from database import SessionLocal as _SLib
_ind_db = _SLib()
try:
ind = (req.scan_context or {}).get("industry") if req.scan_context else None
site_prof = load_site_profile(_ind_db, domain_for_exec or "")
industry_ctx_html = build_industry_context_block_html(ind, site_prof)
finally:
_ind_db.close()
except Exception as e:
logger.warning("industry context skipped: %s", e)
# P106 — Internal-Checks-Block
internal_checks_html = ""
try:
from compliance.services.mc_audit_type import (
build_internal_checks_block_html,
)
ic = (mc_split or {}).get("internal_checks") or []
if ic:
internal_checks_html = build_internal_checks_block_html(ic)
logger.info("P106: %d interne Checks (statt FAIL) im Block",
len(ic))
except Exception as e:
logger.warning("P106 internal_checks_html skipped: %s", e)
# P85 — Banner-Screenshot
banner_shot_html = ""
try:
from compliance.services.banner_screenshot_block import (
build_banner_screenshot_html,
)
banner_shot_html = build_banner_screenshot_html(banner_result)
except Exception as e:
logger.warning("P85 banner-screenshot skipped: %s", e)
# A — Audit-Quality-Checks (run BEFORE gf_one_pager so the data is
# available — original code had this inverted, causing
# UnboundLocalError silently caught).
audit_quality_html = ""
audit_quality_findings: list[dict] = []
try:
from compliance.services.audit_quality_checks import (
build_audit_quality_block_html, run_all as run_audit_quality,
)
cookie_text_for_aq = doc_texts.get("cookie") or ""
audit_quality_findings = run_audit_quality(
banner_result, cookie_text_for_aq, cmp_vendors, doc_entries,
)
if audit_quality_findings:
audit_quality_html = build_audit_quality_block_html(audit_quality_findings)
logger.info("audit-quality: %d Vorbehalte erkannt",
len(audit_quality_findings))
except Exception as e:
logger.warning("audit-quality-checks failed: %s", e)
# P82: GF-1-Pager (now has the audit_quality_findings filled)
gf_one_pager_html = ""
try:
from compliance.services.gf_one_pager import build_gf_one_pager_html
gf_one_pager_html = build_gf_one_pager_html(
site_name=site_name_for_exec,
scorecard=scorecard,
previous_scorecard=prev_scorecard,
banner_result=banner_result,
library_mismatch_findings=mismatches,
scan_context=req.scan_context,
audit_quality_findings=audit_quality_findings,
)
except Exception as e:
logger.warning("P82 GF-1-pager skipped: %s", e)
# Doc-Input-Warnings — wenn User Text ins falsche Feld gepastet hat
input_warn_html = ""
try:
from compliance.services.doc_input_warnings import (
build_warnings_block_html, collect_warnings,
)
warns = collect_warnings(doc_entries)
if warns:
input_warn_html = build_warnings_block_html(warns)
logger.info("doc-input-warnings: %d Mismatches gefunden", len(warns))
except Exception as e:
logger.warning("doc-input-warnings skipped: %s", e)
# P86: Branchen-Benchmark
bench_html = ""
try:
from compliance.services.industry_benchmark import (
_extract_score, build_benchmark_html, compute_benchmark,
)
from database import SessionLocal as _SLb
industry = (req.scan_context or {}).get("industry") if req.scan_context else None
curr_score = _extract_score(banner_result)
if industry and curr_score is not None:
_b_db = _SLb()
try:
bench = compute_benchmark(
_b_db, industry, curr_score, check_id,
)
if bench:
bench_html = build_benchmark_html(bench)
finally:
_b_db.close()
except Exception as e:
logger.warning("P86 industry-benchmark skipped: %s", e)
# P84: Diff-Mode
diff_html = ""
try:
from compliance.services.run_diff import (
build_diff_block_html, compute_diff,
)
from database import SessionLocal as _SL
_diff_db = _SL()
try:
diff = compute_diff(
_diff_db, check_id, domain_for_exec or "",
banner_result, scorecard,
)
if diff:
diff_html = build_diff_block_html(diff)
finally:
_diff_db.close()
except Exception as e:
logger.warning("P84 diff-mode skipped: %s", e)
# B1 / B3 cross-cutting findings (own renderers, may be empty).
reachability_html = state.get("reachability_html", "")
retention_html = state.get("retention_html", "")
# Reihenfolge — Sales-optimiert.
# B1 (Reachability) sits next to critical because it's an Art.7-Abs.3
# finding. B3 (Retention) sits next to cookie_audit because both
# are 3-source comparisons of cookie metadata.
full_html = (
gf_one_pager_html + audit_quality_html + input_warn_html
+ bench_html + diff_html
+ html_blocks["critical_html"] + reachability_html
+ html_blocks["scope_disclaimer_html"]
+ html_blocks["exec_summary_html"]
+ html_blocks["cookie_arch_html"] + html_blocks["summary_html"]
+ html_blocks["scanned_html"] + html_blocks["profile_html"]
+ html_blocks["scorecard_html"] + internal_checks_html
+ html_blocks["redundancy_html"]
+ industry_ctx_html
+ banner_shot_html
+ html_blocks["providers_html"] + html_blocks["banner_deep_html"]
+ html_blocks["cookie_audit_html"] + retention_html
+ html_blocks["tcf_authority_html"]
+ html_blocks["entropy_html"]
+ html_blocks["network_trace_html"]
+ html_blocks["library_mismatch_html"]
+ html_blocks["consistency_html"] + html_blocks["signals_html"]
+ html_blocks["solutions_html"]
+ jc_decision_html
+ html_blocks["vvt_html"] + html_blocks["report_html"]
)
state["audit_quality_findings"] = audit_quality_findings
state["full_html"] = full_html
@@ -0,0 +1,221 @@
"""Phase D-3-Mid — Mid HTML blocks (P62/P103/P104/P105/audit/mismatch/signals).
Covers (in the original Step 5):
- P62 Scope-Disclaimer
- P103 Cookie-Value-Entropy + P104 Network-Tracing
- P105 IAB TCF Authority cross-reference
- Cookie-Compliance-Audit (3-Quellen-Vergleich, central USP)
- P102 Cookie-Klassifikations-Pruefung (library mismatch)
- P35/P77/P78 Doc-Text signals
- P92/P94 Banner-Konsistenz
- P73 MC-Solution-Generator (LLM suggestions per HIGH-Fail)
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
async def run_phase_d3_mid(state: dict) -> None:
"""Mid HTML blocks. Mutates state in place."""
doc_entries = state["doc_entries"]
doc_texts = state["doc_texts"]
banner_result = state["banner_result"]
cmp_vendors = state["cmp_vendors"]
fails_by_doc = state["fails_by_doc"]
html_blocks = state["html_blocks"]
# P62: Marketing-Manager-Disclaimer
scope_disclaimer_html = ""
try:
from ..scope_disclaimer import build_scope_disclaimer_html
scope_disclaimer_html = build_scope_disclaimer_html()
except Exception as e:
logger.warning("Scope-disclaimer block skipped: %s", e)
# P103 + P104 — Cookie-Value-Entropy + Network-Tracing
entropy_html = ""
network_trace_html = ""
try:
from compliance.services.cookie_network_tracer import (
build_network_trace_block_html,
trace_cookie_network,
)
from compliance.services.cookie_value_entropy import (
build_entropy_block_html,
check_cookies_for_entropy_mismatch,
)
cookies_detailed = (banner_result or {}).get("cookies_detailed") or []
entropy_findings = check_cookies_for_entropy_mismatch(cookies_detailed)
if entropy_findings:
entropy_html = build_entropy_block_html(entropy_findings)
logger.info("P103 Entropy: %d Findings", len(entropy_findings))
primary_url = ""
for e_ in doc_entries:
if e_.get("url"):
primary_url = e_["url"]; break
net_findings = trace_cookie_network(cookies_detailed, primary_url)
if net_findings:
network_trace_html = build_network_trace_block_html(net_findings)
logger.info("P104 Network-Trace: %d Findings", len(net_findings))
except Exception as e:
logger.warning("P103/P104 entropy/network-trace skipped: %s", e)
# P105 — IAB TCF Authority-Cross-Reference
tcf_authority_html = ""
try:
from compliance.services.tcf_vendor_authority import (
build_tcf_authority_block_html, cross_reference_with_tcf,
)
from database import SessionLocal as _SLtcf
_tcf_db = _SLtcf()
try:
tcf_findings = cross_reference_with_tcf(_tcf_db, cmp_vendors)
if tcf_findings:
tcf_authority_html = build_tcf_authority_block_html(tcf_findings)
logger.info(
"TCF-Authority: %d Vendor-Discrepancies gefunden",
len(tcf_findings),
)
finally:
_tcf_db.close()
except Exception as e:
logger.warning("TCF-Authority-Check skipped: %s", e)
# COOKIE-COMPLIANCE-AUDIT (3-Quellen-Vergleich — central USP)
cookie_audit: dict = {}
cookie_audit_html = ""
try:
from compliance.services.cookie_compliance_audit import (
audit_cookie_compliance, build_cookie_audit_block_html,
)
from database import SessionLocal as _SLca
_ca_db = _SLca()
try:
cookie_audit = audit_cookie_compliance(
_ca_db, doc_texts.get("cookie") or doc_texts.get("dse"),
banner_result,
)
if cookie_audit and (cookie_audit.get("declared_count") or
cookie_audit.get("browser_count")):
cookie_audit_html = build_cookie_audit_block_html(cookie_audit)
logger.info(
"Cookie-Audit: %d deklariert, %d im Browser, "
"%d undokumentiert, %d compliant",
cookie_audit.get("declared_count"),
cookie_audit.get("browser_count"),
len(cookie_audit.get("undeclared_in_browser") or []),
len(cookie_audit.get("compliant") or []),
)
finally:
_ca_db.close()
except Exception as e:
logger.warning("cookie-compliance-audit skipped: %s", e)
# P102: Cookie-Klassifikations-Pruefung
library_mismatch_html = ""
mismatches: list[dict] = []
try:
from compliance.services.cookie_library_mismatch import (
build_mismatch_block_html, detect_mismatches,
)
from database import SessionLocal
cookie_doc_for_check = doc_texts.get("cookie") or doc_texts.get("dse") or ""
all_cookies_seen: list[str] = []
if banner_result:
for ph in (banner_result.get("phases") or {}).values():
if isinstance(ph, dict):
for ck in (ph.get("cookies") or []):
if isinstance(ck, str):
all_cookies_seen.append(ck)
elif isinstance(ck, dict) and ck.get("name"):
all_cookies_seen.append(ck["name"])
if all_cookies_seen and cookie_doc_for_check:
_mm_db = SessionLocal()
try:
mismatches = detect_mismatches(
_mm_db, all_cookies_seen, cookie_doc_for_check,
)
if mismatches:
library_mismatch_html = build_mismatch_block_html(mismatches)
logger.info(
"P102: %d Cookie-Mismatches gefunden", len(mismatches),
)
finally:
_mm_db.close()
except Exception as e:
logger.warning("P102 mismatch detection failed: %s", e)
# P35 + P77 + P78: Textsignal-Checks
signals_html = ""
try:
from compliance.services.doc_text_signals import (
build_signals_block_html, run_all as run_signal_checks,
)
cookie_doc_missing = not bool(doc_texts.get("cookie"))
sig_findings = run_signal_checks(
banner_result, doc_texts, cookie_doc_missing,
)
if sig_findings:
signals_html = build_signals_block_html(sig_findings)
except Exception as e:
logger.warning("P35/P77/P78 signals-check failed: %s", e)
# P92 + P94: Banner-Konsistenz
consistency_html = ""
try:
from compliance.services.banner_consistency_checks import (
build_consistency_block_html, run_all as run_consistency_checks,
)
cookie_doc_for_check = (doc_texts.get("cookie")
or doc_texts.get("dse") or "")
cons_findings = run_consistency_checks(
banner_result or {}, cookie_doc_for_check, cmp_vendors,
doc_texts=doc_texts,
)
if cons_findings:
consistency_html = build_consistency_block_html(cons_findings)
logger.info("P92/P94: %d Konsistenz-Findings", len(cons_findings))
except Exception as e:
logger.warning("P92/P94 consistency-check failed: %s", e)
# P73: MC-Solution-Generator — LLM-Vorschlaege pro HIGH-Fail
solutions_html = ""
try:
from compliance.services.mc_solution_generator import (
build_solutions_block_html, generate_solutions_for_fails,
)
all_solutions: list[dict] = []
for dt, fails in fails_by_doc.items():
if not fails:
continue
doc_txt = doc_texts.get(dt) or doc_texts.get("dse") or ""
if not doc_txt or len(doc_txt) < 500:
continue
sols = await generate_solutions_for_fails(
fails, doc_txt, dt, limit=3,
)
all_solutions.extend(sols)
if len(all_solutions) >= 8:
break
if all_solutions:
solutions_html = build_solutions_block_html(all_solutions[:8])
logger.info("P73: %d MC-Solutions generiert", len(all_solutions))
except Exception as e:
logger.warning("P73 MC-Solution-Generator skipped: %s", e)
html_blocks.update({
"scope_disclaimer_html": scope_disclaimer_html,
"entropy_html": entropy_html,
"network_trace_html": network_trace_html,
"tcf_authority_html": tcf_authority_html,
"cookie_audit_html": cookie_audit_html,
"library_mismatch_html": library_mismatch_html,
"signals_html": signals_html,
"consistency_html": consistency_html,
"solutions_html": solutions_html,
})
state["cookie_audit"] = cookie_audit
state["mismatches"] = mismatches
@@ -0,0 +1,198 @@
"""Phase D-3-Top — Top-of-mail HTML blocks.
Covers (in the original Step 5 of `_run_compliance_check`):
- Summary / Scanned-URLs / Provider-list / Banner-deep / VVT HTML
- MC-scorecard aggregation (all_mc_checks + scorecard) + trend lookup
- P106 mc_audit_type split (internal_checks vs. verifiable_fails)
- Profile HTML / Redundancy HTML
- P1 Executive Summary
- P18 Critical Findings block
- P10 Cookie-Policy-Architecture detection
"""
from __future__ import annotations
import logging
from ._helpers import _build_profile_html, _company_name_from_url, _extract_domain
logger = logging.getLogger(__name__)
async def run_phase_d3_top(state: dict) -> None:
"""Top-of-mail HTML blocks. Mutates state in place."""
req = state["req"]
results = state["results"]
doc_entries = state["doc_entries"]
doc_texts = state["doc_texts"]
banner_result = state["banner_result"]
vvt_entries = state["vvt_entries"]
cmp_vendors = state["cmp_vendors"]
profile = state["profile"]
redundancy_report = state.get("redundancy_report")
from ..agent_doc_check_banner import build_banner_deep_html
from ..agent_doc_check_critical import build_critical_findings_html
from ..agent_doc_check_exec_summary import build_exec_summary_html
from ..agent_doc_check_extras import build_vvt_table_html
from ..agent_doc_check_redundancy import build_redundancy_html
from ..agent_doc_check_report import (
build_html_report,
build_management_summary,
build_provider_list_html,
build_scanned_urls_html,
)
from ..agent_doc_check_scorecard import build_scorecard_html
from compliance.services.mc_scorecard import build_scorecard
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
banner_deep_html = build_banner_deep_html(banner_result)
vvt_html = build_vvt_table_html(cmp_vendors)
# MC scorecard aggregated across ALL docs (DSGVO/TDDDG/BGB/...)
all_mc_checks: list[dict] = []
fails_by_doc: dict[str, list[dict]] = {}
for r in results:
for c in r.checks:
if c.id.startswith("mc-"):
rec = {
"id": c.id, "label": c.label, "passed": c.passed,
"severity": c.severity, "skipped": c.skipped,
"regulation": c.regulation,
"hint": getattr(c, "hint", "") or "",
}
all_mc_checks.append(rec)
if (not c.passed and not c.skipped
and (c.severity or "").upper() in ("CRITICAL", "HIGH")):
fails_by_doc.setdefault(r.doc_type, []).append(rec)
# P106 — Audit-Type-Klassifizierung pro MC
mc_split: dict = {"internal_checks": [], "verifiable_fails": all_mc_checks}
try:
from compliance.services.mc_audit_type import (
annotate_mc_results, split_by_audit_type,
)
annotate_mc_results(all_mc_checks)
mc_split = split_by_audit_type(all_mc_checks)
fails_by_doc = {}
for r in mc_split.get("verifiable_fails") or []:
fails_by_doc.setdefault("dse", []).append(r)
except Exception as e:
logger.warning("P106 mc_audit_type skipped: %s", e)
scorecard = build_scorecard(all_mc_checks) if all_mc_checks else {}
# Trend: load previous scorecard for the same tenant + domain
prev_scorecard: dict | None = None
if scorecard:
try:
from compliance.services.compliance_audit_log import (
list_runs_for_tenant,
)
tenant_id_for_trend = req.recipient or ""
base_domain_for_trend = _extract_domain(doc_entries) or ""
prev_runs = list_runs_for_tenant(
tenant_id_for_trend,
base_domain=base_domain_for_trend,
limit=1,
)
if prev_runs:
prev_scorecard = prev_runs[0].get("scorecard")
except Exception as e:
logger.debug("trend lookup skipped: %s", e)
scorecard_html = (
build_scorecard_html(scorecard, previous_scorecard=prev_scorecard)
if scorecard else ""
)
report_html = build_html_report(results, None, doc_texts)
profile_html = _build_profile_html(profile)
# O4: Vendor-Redundanz / EU-Alternativen + Cost-Savings-Block
redundancy_html = build_redundancy_html(redundancy_report)
# P1: Executive-Summary
url_company_for_exec = _company_name_from_url(doc_entries)
domain_for_exec = _extract_domain(doc_entries)
site_name_for_exec = url_company_for_exec or domain_for_exec or ""
exec_summary_html = build_exec_summary_html(
scorecard=scorecard,
previous_scorecard=prev_scorecard,
cmp_vendors=cmp_vendors,
redundancy_report=redundancy_report,
site_name=site_name_for_exec,
)
# P18: Critical-Findings-Block
critical_html = ""
try:
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:
from compliance.services.cookie_policy_architecture import (
build_architecture_html,
detect_architecture,
)
cookie_doc_url = ""
cookie_doc_text = doc_texts.get("cookie", "")
cookie_cmp_payloads: list[dict] = []
for e in doc_entries:
if (e.get("doc_type") or "").lower() in ("cookie", "cookie_policy"):
cookie_doc_url = e.get("url", "")
cookie_cmp_payloads = e.get("cmp_payloads") or []
break
# P17-A: Fallback wenn Cookie-Doc via P15 deduped wurde
if not cookie_doc_text:
dse_text = doc_texts.get("dse", "")
if dse_text and any(w in dse_text.lower() for w in
("cookie", "tracking", "google analytics",
"consent")):
cookie_doc_text = dse_text
dse_entry = next((e for e in doc_entries
if e.get("doc_type") == "dse"), {})
cookie_doc_url = dse_entry.get("url", "")
cookie_cmp_payloads = dse_entry.get("cmp_payloads") or []
logger.info("P17-A: cookie-arch fallback auf DSE")
if cookie_doc_text:
arch = detect_architecture(
doc_url=cookie_doc_url,
doc_text=cookie_doc_text,
cmp_payloads=cookie_cmp_payloads,
homepage_cmp_payloads=state.get("cookie_payloads") or [],
)
cookie_arch_html = build_architecture_html(arch)
logger.info("cookie-arch: layer=%s versioned=%s risk=%s",
arch["layer_separation"], arch["versioned"],
arch["risk_label"])
except Exception as e:
logger.warning("cookie-architecture detection failed: %s", e)
state["scorecard"] = scorecard
state["prev_scorecard"] = prev_scorecard
state["mc_split"] = mc_split
state["fails_by_doc"] = fails_by_doc
state["site_name_for_exec"] = site_name_for_exec
state["domain_for_exec"] = domain_for_exec
state["html_blocks"] = {
"summary_html": summary_html,
"scanned_html": scanned_html,
"providers_html": providers_html,
"banner_deep_html": banner_deep_html,
"vvt_html": vvt_html,
"scorecard_html": scorecard_html,
"report_html": report_html,
"profile_html": profile_html,
"redundancy_html": redundancy_html,
"exec_summary_html": exec_summary_html,
"critical_html": critical_html,
"cookie_arch_html": cookie_arch_html,
}
@@ -0,0 +1,75 @@
"""Phase E — Send compliance-check email, with A1 ZIP-Anhang.
Original Step 6 of `_run_compliance_check`, extended with the A1
attachment: when the Tesseract pipeline captured evidence slices,
bundle them into evidence-{check_id}.zip (manifest.json +
audit_metadata.json + slice_NNN.png) and attach to the e-mail. The
attachment makes the evidence chain portable so a DSB / lawyer can
hand it to an external auditor or supervisory authority.
"""
from __future__ import annotations
import logging
from compliance.services.smtp_sender import send_email
from ._helpers import _company_name_from_url, _extract_domain, _update
logger = logging.getLogger(__name__)
def run_phase_e(state: dict) -> None:
"""Build site label, optional ZIP attachment, send mail. Mutate state."""
check_id = state["check_id"]
req = state["req"]
results = state["results"]
doc_entries = state["doc_entries"]
full_html = state["full_html"]
cookie_evidence_slices = state.get("cookie_evidence_slices")
cookie_evidence_meta = state.get("cookie_evidence_meta")
# Derive site name primarily from entered URL.
# The extracted_profile.companyName is often noisy (e.g. picks up
# juris.de from legal references). Domain-derived name is more
# predictable for the GF email subject.
doc_count = len([r for r in results if not r.error])
url_company = _company_name_from_url(doc_entries)
domain = _extract_domain(doc_entries)
site_name = url_company or domain or "Unbekannt"
_update(check_id, "E-Mail wird versendet...", 98)
# A1: bundle cookie-evidence slices into a ZIP attachment so the
# audit chain reaches the recipient. Each slice has its own
# SHA-256 + capture timestamp; manifest.json + audit_metadata.json
# make the chain verifiable for an external auditor.
evidence_attachments: list[dict] = []
if cookie_evidence_slices:
try:
from compliance.services.evidence_zip_builder import (
build_evidence_zip,
)
zip_bytes = build_evidence_zip(
slices=cookie_evidence_slices,
meta=cookie_evidence_meta,
check_id=check_id,
)
evidence_attachments.append({
"filename": f"evidence-{check_id[:8]}.zip",
"data": zip_bytes,
"mime": "application/zip",
})
except Exception as e:
logger.warning("A1 evidence-zip build failed: %s", e)
email_result = send_email(
recipient=req.recipient,
subject=f"[COMPLIANCE-CHECK] {site_name}{doc_count} Dokumente geprueft",
body_html=full_html,
attachments=evidence_attachments or None,
)
state["email_result"] = email_result
state["site_name"] = site_name
state["domain"] = domain
state["doc_count"] = doc_count
@@ -0,0 +1,166 @@
"""Phase F — Build response + persist snapshot/audit-log/unified-findings.
Covers (in the original `_run_compliance_check`):
- Step 7 Build response dict, mark job as completed
- P80 Persist raw scan data so we can replay the audit pipeline
without re-crawling (7min → 5sec test cycle)
- SQLite audit log (compliance.api/audit endpoints + trend view A6)
- P5 Unified findings (MC + Pflichtangaben + Vendor + Redundanz
in one searchable table behind /agent/findings/<id>)
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from ._constants import _compliance_check_jobs
from ._helpers import _result_to_dict
logger = logging.getLogger(__name__)
def run_phase_f(state: dict) -> None:
"""Build response + persist. Mutates state in place."""
check_id = state["check_id"]
req = state["req"]
results = state["results"]
profile = state["profile"]
profile_dict = state["profile_dict"]
extracted_profile = state["extracted_profile"]
banner_result = state["banner_result"]
tcf_vendors = state["tcf_vendors"]
vvt_entries = state["vvt_entries"]
cmp_vendors = state["cmp_vendors"]
cookie_audit = state["cookie_audit"]
total_findings = state["total_findings"]
email_result = state["email_result"]
doc_entries = state["doc_entries"]
doc_texts = state["doc_texts"]
redundancy_report = state.get("redundancy_report")
scorecard = state["scorecard"]
site_name = state.get("site_name", "")
domain = state.get("domain", "")
doc_count = state.get("doc_count", 0)
response = {
"check_id": check_id,
"results": [_result_to_dict(r) for r in results],
"business_profile": profile_dict,
"extracted_profile": extracted_profile,
# 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),
"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,
"cookie_audit": cookie_audit if cookie_audit else None,
"total_documents": len(results),
"total_findings": total_findings,
"email_status": email_result.get("status", "failed"),
"checked_at": datetime.now(timezone.utc).isoformat(),
}
_compliance_check_jobs[check_id]["status"] = "completed"
_compliance_check_jobs[check_id]["result"] = response
_compliance_check_jobs[check_id]["progress"] = "Fertig"
_compliance_check_jobs[check_id]["progress_pct"] = 100
# P80: persist raw scan data so we can replay audit pipeline
# without re-crawling (7min -> 5sec test cycle).
try:
from database import SessionLocal
from compliance.services.check_snapshot import save_snapshot
snap_db = SessionLocal()
try:
save_snapshot(
snap_db,
check_id=check_id,
doc_entries=doc_entries,
banner_result=banner_result,
profile=profile,
cmp_vendors=cmp_vendors,
scan_context=req.scan_context, # P79
site_label=site_name,
notes=f"recipient={req.recipient}",
)
finally:
snap_db.close()
except Exception as snap_err:
logger.warning("P80 snapshot save skipped: %s", snap_err)
# Persist to sidecar SQLite audit log — enables /audit endpoints
# (A5 admin tab) and trend view (A6). Best-effort; failures here
# do not affect the user-facing response.
try:
from compliance.services.compliance_audit_log import record_check_run
from compliance.services.mc_scorecard import full_audit_records
audit_rows: list[dict] = []
for r in results:
doc_mc = [c for c in r.checks if c.id.startswith("mc-")]
audit_rows.extend(full_audit_records(
[{"id": c.id, "label": c.label, "passed": c.passed,
"severity": c.severity, "skipped": c.skipped,
"regulation": c.regulation, "matched_text": c.matched_text,
"hint": c.hint, "level": c.level}
for c in doc_mc],
check_id=check_id,
doc_type=r.doc_type,
))
record_check_run(
check_id=check_id,
tenant_id=req.recipient or "",
site_name=site_name,
base_domain=domain or "",
doc_count=doc_count,
scorecard=scorecard,
vvt_summary={
"total": len(cmp_vendors),
"internal": sum(1 for v in cmp_vendors
if (v.get("recipient_type") or "").upper()
in ("INTERNAL", "GROUP_COMPANY")),
"external": sum(1 for v in cmp_vendors
if (v.get("recipient_type") or "").upper()
in ("PROCESSOR", "CONTROLLER")),
},
mc_records=audit_rows,
)
from compliance.services.compliance_audit_log import record_check_payload
record_check_payload(
check_id=check_id,
vendors=cmp_vendors,
profile=extracted_profile,
banner=banner_result,
)
# Unified findings (P5): bundle MC + Pflichtangaben + Vendor +
# Redundanz in one searchable table behind /agent/findings/<id>.
try:
from compliance.services.unified_findings_collector import collect
from compliance.services.unified_findings_store import record_findings
unified = collect(
check_id=check_id,
results=results,
cmp_vendors=cmp_vendors,
redundancy_report=redundancy_report,
doc_texts=doc_texts,
)
record_findings(check_id, unified)
except Exception as e:
logger.warning("Unified findings collect failed: %s", e)
except Exception as e:
logger.warning("Audit persistence skipped: %s", e)
state["response"] = response
@@ -0,0 +1,44 @@
"""Pydantic request/response schemas for the compliance-check route."""
from __future__ import annotations
from pydantic import BaseModel
class ExtractTextRequest(BaseModel):
url: str
class DocumentInput(BaseModel):
doc_type: str # dse, agb, impressum, cookie, widerruf, avv, loeschkonzept, etc.
url: str = ""
text: str = "" # text has priority over URL
class ComplianceCheckRequest(BaseModel):
documents: list[DocumentInput]
use_agent: bool = False
recipient: str = "dsb@breakpilot.local"
# P12: Override fuer TDM-Vorbehalt bei dokumentierter Kunden-Erlaubnis.
# Pflichtfeld tdm_override_reason wenn tdm_override=True
# (z.B. "Auftragsbeziehung Safetykon GmbH, Email Hr. X 18.05.2026").
tdm_override: bool = False
tdm_override_reason: str = ""
# P79: 8-Feld Pre-Scan-Wizard (Branche, B2B/B2C, Direkt-Vertrieb,
# Rechtsform, Konzern, MA, Besondere Daten, Drittland). Wird im
# Snapshot persistiert und filtert die MC-Auswertung (P72).
scan_context: dict | None = None
class ComplianceCheckStartResponse(BaseModel):
check_id: str
status: str = "running"
class ComplianceCheckStatusResponse(BaseModel):
check_id: str
status: str
progress: str = ""
progress_pct: int = 0
result: dict | None = None
error: str = ""
@@ -0,0 +1,118 @@
"""Per-document regex + MC + LLM checks for the compliance-check route.
Each document goes through:
1. regex completeness/correctness checklist
2. Master Control evaluation (all MCs for this doc_type)
3. LLM verification of failed regex checks (overturns where evidence
was missed by the regex)
4. Cookie-only: opt-out + privacy-policy URL health-check
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
async def _check_single(
text: str, doc_type: str, label: str, url: str,
word_count: int, use_agent: bool,
business_scope: set[str] | None = None,
business_profile: dict | None = None,
):
"""Run regex + MC checks on a single document."""
from compliance.services.doc_checks.runner import check_document_completeness
from compliance.services.rag_document_checker import check_document_with_controls
from ..agent_doc_check_routes import CheckItem, DocCheckResult
# Regex checklist
findings = check_document_completeness(text, doc_type, label, url,
business_profile=business_profile)
all_checks: list[CheckItem] = []
completeness = 0
correctness = 0
for f in findings:
if "SCORE" in f.get("code", ""):
for c in f.get("all_checks", []):
all_checks.append(CheckItem(
id=c["id"], label=c["label"], passed=c["passed"],
severity=c["severity"], matched_text=c.get("matched_text", ""),
level=c.get("level", 1), parent=c.get("parent"),
skipped=c.get("skipped", False), hint=c.get("hint", ""),
))
completeness = f.get("completeness_pct", 0)
correctness = f.get("correctness_pct", 0)
# Master Control checks (top 20 by severity to avoid noise)
try:
# max_controls=0 -> evaluate ALL MCs for this doc_type (DB has
# 1874 across 8 types; regex matching is cheap and dominates
# well under 1s per doc). Caps remain on the LLM-enrich step
# (top-10 FAILs) so cost stays bounded.
mc_results = await check_document_with_controls(
text, doc_type, label, max_controls=0, use_agent=use_agent,
business_scope=business_scope,
)
if mc_results:
for mc in mc_results:
all_checks.append(CheckItem(**mc))
l2 = [c for c in all_checks if c.level == 2 and not c.skipped]
l2_passed = sum(1 for c in l2 if c.passed)
correctness = round(l2_passed / len(l2) * 100) if l2 else 0
except Exception as e:
logger.warning("MC check skipped for %s: %s", label, e)
# LLM verification of regex fails
failed = [c for c in all_checks if not c.passed and not c.skipped and c.hint]
if failed:
try:
from compliance.services.doc_checks.llm_verify import verify_failed_checks
overturns = await verify_failed_checks(
text,
[{"id": c.id, "label": c.label, "hint": c.hint} for c in failed],
label,
)
for c in all_checks:
if c.id in overturns and overturns[c.id]["overturned"]:
c.passed = True
c.matched_text = f"[LLM] {overturns[c.id]['evidence']}"
l2_active = [c for c in all_checks if c.level == 2 and not c.skipped]
l2_passed = sum(1 for c in l2_active if c.passed)
if l2_active:
correctness = round(l2_passed / len(l2_active) * 100)
except Exception as e:
logger.warning("LLM verification skipped: %s", e)
# Cookie-policy only: actively HTTP-probe the Opt-Out + Privacy-Policy
# URLs the document advertises. Broken links make individual provider
# entries non-compliant under Art. 7(3) DSGVO.
if doc_type == "cookie":
try:
from compliance.services.cookie_link_validator import (
extract_links, validate_links, build_check_items,
)
links = extract_links(text)
if links:
logger.info("Cookie-link validator: %d urls extracted from %s",
len(links), label)
validated = await validate_links(links)
for item in build_check_items(validated):
all_checks.append(CheckItem(**item))
# Re-compute correctness with the new L2 items
l2_active = [c for c in all_checks if c.level == 2 and not c.skipped]
l2_passed = sum(1 for c in l2_active if c.passed)
if l2_active:
correctness = round(l2_passed / len(l2_active) * 100)
except Exception as e:
logger.warning("Cookie-link validation skipped for %s: %s", label, e)
non_score = [f for f in findings if "SCORE" not in f.get("code", "")]
return DocCheckResult(
label=label, url=url, doc_type=doc_type,
word_count=word_count or len(text.split()),
completeness_pct=completeness, correctness_pct=correctness,
checks=all_checks, findings_count=len(non_score),
)
@@ -0,0 +1,58 @@
"""Shared state for the compliance-check pipeline.
The 7-step pipeline accumulates ~60 named values that flow across
phases (doc_entries, profile, results, banner_result, cmp_vendors,
scorecard, HTML blocks, …). Rather than threading 60 parameters
through each function, we pass one mutable `CheckState` dict.
Phases read what they need with `state[key]` and write their outputs
with `state[key] = value`. This is intentionally untyped: enforcing
strict typing would require freezing the schema before all phases
landed, and the report-building phase routinely adds new optional
keys (P1, P10, P50, P59b, P82, P103, P104, P106, …).
`CheckState.new(check_id, req)` initialises the dict with the few
keys that must exist from the start.
"""
from __future__ import annotations
def new_state(check_id: str, req) -> dict:
"""Create a fresh state dict for a check run.
Pre-populates a few keys that downstream phases assume exist
(e.g. `cmp_vendors` defaulting to `[]`).
"""
return {
"check_id": check_id,
"req": req,
# Phase-1 outputs
"doc_texts": {},
"doc_entries": [],
"url_text_cache": {},
"pasted_table_vendors": [],
"placement_findings": [],
# Phase-2/3/4 outputs
"profile": None,
"profile_dict": {},
"results": [],
"total_findings": 0,
"business_scope": set(),
"banner_result": None,
"banner_url": "",
"tcf_vendors": [],
"vvt_entries": [],
"extracted_profile": {},
# Phase-5 outputs
"cmp_vendors": [],
"cookie_audit": {},
"cookie_evidence_slices": None,
"cookie_evidence_meta": None,
"scorecard": {},
"full_html": "",
"audit_quality_findings": [],
# Phase-6/7 outputs
"email_result": {"status": "skipped"},
"site_name": "",
}