Files
breakpilot-compliance/backend-compliance/compliance/services/doc_staleness_check.py
T
Benjamin Admin d0e3621192 feat(audit): V2 mail render + 5 new findings (B4/B5/B6/B7/B8) + LLM-Plausibility-Phase
Mail Render V2 (compliance/services/mail_render_v2/) — 11-Modul-Subpackage
das einen einheitlichen Audit-Mail-Output erzeugt mit:
  - Header + KPI-Kacheln (Score / Findings / Docs / Vendors)
  - TOC + Sprung-Links
  - 3-Bucket-Trennung: Kritische Befunde / Manuelle Prüfung / Interne Reminder
  - Cookie-Inventar (Name·Vendor·Kategorie·Speicherdauer·Löschfrist·Sitzland·Quelle·Status)
  - Sofortmaßnahmen-Aggregator ("Sitzland ergänzen für 11 Cookies")
  - 24 Legacy-Wrappers — alle alten build_*_html in V2-Sections
  - Scope-Filter: FIN/GOV/MED/INS/EDU/LEG aus Berichten wenn nicht relevant
  - Hint/Action-Dedup: keine doppelten Sätze pro Card mehr
Aktiviert via env MAIL_RENDER_V2=true (Default: legacy renderer).

5 neue deterministische Findings als Phase D-2b/B4/B5/B6/B7/B8:

  B4 vendor_consistency_check — Cross-Doc-Provider-Widerspruch
     (Elli: DSE nennt Vertex AI für Chatbot, /de/cookies nennt Iadvize → HIGH).
     6 Service-Types: chatbot/analytics/tag_manager/pixel/cdn/cmp.

  B5 ai_act_transparency_check — AI Act Art. 50 Transparenzpflicht
     (Elli: Vertex AI vorhanden ohne Pre-Chat-Disclosure → HIGH).
     Plus B5-Erweiterung: Rechtsgrundlage Art-6-Abs-1-lit-f bei AI → MED
     (Einwilligung empfehlen).

  B6 cross_doc_dpo_check — DPO in DSE genannt, nicht im Impressum (LOW).

  B7 doc_staleness_check — Datum-Extraktion aus DSE/AGB/Nutzungsbedingungen.
     Cap: AGB/NB 3y, DSE 2y. Älter → MEDIUM (Elli NB Stand 2018 → HIGH).

  B8 cmp_fingerprint_check — Banner detected, aber CMP-Provider generic
     (kein Usercentrics/OneTrust/Cookiebot/etc → MED).

  B3-Erweiterung detect_intra_doc_contradictions — Widersprüchliche
     Speicherdauer im SELBEN Doc (Elli: Logfile 7d vs 30d → HIGH).

LLM-Plausibility-Phase (Phase D-2b, finding_plausibility_check.py):
  - Läuft AFTER MC pipeline, BEFORE D3 render
  - Prompt mit Beispiel-IDs + 3-Phase-Mapping: exact-ID / position-fallback /
    fuzzy-tail-match
  - Stempelt llm_title / llm_severity / llm_recommendation / llm_drop auf
    jeden FAIL CheckItem
  - V2-Render zeigt "🤖 LLM-Plausibility:" Box pro Finding wenn gestempelt
  - KNOWN ISSUE: qwen3:30b-a3b liefert oft empty content auf format='json' +
    8000-char-excerpt prompts. Pipeline läuft mit stamped=0 weiter. Task #16.

Coverage gegen Elli Ground Truth (zeroclaw/docs/ground-truth/elli_eco_2026-06-06.json,
13 expected findings via WebFetch-Agent-Crawl):
  - 4/4 HIGH-Findings ✓ (COOKIE-CONSENT-UX-001 + WIDERRUFSBELEHRUNG-001 +
    VENDOR-CONSISTENCY-001 + AI-ACT-TRANSPARENCY-001)
  - 4/6 MEDIUM ✓
  - 2/3 LOW ✓
  - Total: 10/13 = 77% (Sprung von 4/13 = 31%)

Restliche 3 Gaps als Task #17: IMPRESSUM-001 (multi-entity USt-IdNr),
TRANSFER-001 (Vendor-Mechanismus DPF/SCC), TH-RETENTION-002 (AI-Retention
pro Datenkategorie).

V2-Mail-Preview in Mailpit: 'v2all@local.test' Subject '[V2 ALL] ELLI'.
Backend healthy, B1+B3+B4+B5+B6+B7+B8 alle live im Orchestrator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 21:19:49 +02:00

134 lines
4.3 KiB
Python

"""B7 — Doc-Staleness: Datum extrahieren + Aktualität bewerten.
Findings, wenn ein rechtliches Dokument (AGB, Nutzungsbedingungen,
Widerruf, DSE) über N Jahre alt ist. Default-Cap: 3 Jahre für AGB/
Nutzungsbedingungen (TERMS-STALENESS-001), 2 Jahre für DSE.
Heuristik für Datumsextraktion:
- "Stand: November 2018" / "Stand November 2018" / "Stand: Dezember 2018"
- "Letzte Aktualisierung: 2018-12-01"
- "Version vom 1.12.2018"
- "Last updated: December 2018"
"""
from __future__ import annotations
import logging
import re
from datetime import datetime
logger = logging.getLogger(__name__)
_MONTHS_DE = {
"januar": 1, "februar": 2, "märz": 3, "maerz": 3, "april": 4,
"mai": 5, "juni": 6, "juli": 7, "august": 8, "september": 9,
"oktober": 10, "november": 11, "dezember": 12,
}
_MONTHS_EN = {
"january": 1, "february": 2, "march": 3, "april": 4, "may": 5,
"june": 6, "july": 7, "august": 8, "september": 9, "october": 10,
"november": 11, "december": 12,
}
# Match patterns like "Stand: Dezember 2018" / "Stand November 2018"
_PAT_STAND = re.compile(
r"(?:stand|version|letzte\s+aktualisierung|last\s+updated|"
r"last\s+revised)\s*[:\-]?\s*"
r"(?:vom\s+)?"
r"(?:(?P<day>\d{1,2})[.\-/])?"
r"(?P<month>"
r"januar|februar|m[äa]rz|april|mai|juni|juli|august|september|"
r"oktober|november|dezember|"
r"january|february|march|april|may|june|july|august|september|"
r"october|november|december|"
r"\d{1,2}"
r")"
r"[.\s\-/]+"
r"(?P<year>\d{4})",
re.I,
)
_AGE_THRESHOLDS_YEARS = {
"agb": 3,
"nutzungsbedingungen": 3,
"widerruf": 2,
"dse": 2,
"impressum": 5, # less critical
"cookie": 2,
}
def _extract_date(text: str) -> tuple[int, int, int] | None:
"""Return (year, month, day) of the most recent revision date."""
if not text:
return None
candidates: list[tuple[int, int, int]] = []
for m in _PAT_STAND.finditer(text):
try:
year = int(m.group("year"))
mon_str = (m.group("month") or "").lower()
day = int(m.group("day") or 1)
if mon_str.isdigit():
month = int(mon_str)
else:
month = (_MONTHS_DE.get(mon_str)
or _MONTHS_EN.get(mon_str))
if not month or not (1 <= month <= 12):
continue
if year < 2000 or year > 2100:
continue
candidates.append((year, month, day))
except (ValueError, TypeError):
continue
if not candidates:
return None
# newest date wins
candidates.sort(reverse=True)
return candidates[0]
def check_staleness(state: dict) -> list[dict]:
"""Run staleness check across legal docs."""
findings: list[dict] = []
doc_texts = state.get("doc_texts") or {}
today = datetime.utcnow()
for doc_type, text in doc_texts.items():
threshold_years = _AGE_THRESHOLDS_YEARS.get(doc_type)
if not threshold_years:
continue
date = _extract_date(text)
if not date:
continue
year, month, day = date
try:
doc_date = datetime(year, month, min(day, 28))
except ValueError:
continue
age_years = (today - doc_date).days / 365.25
if age_years < threshold_years:
continue
sev = "HIGH" if age_years > threshold_years * 2 else "MEDIUM"
findings.append({
"check_id": f"DOC-STALENESS-{doc_type.upper()}",
"doc_type": doc_type,
"severity": sev,
"severity_reason": "incomplete",
"title": (
f"{doc_type.title()} ist {int(age_years)} Jahre alt "
f"(Stand {year:04d}-{month:02d})"
),
"norm": "Sorgfaltspflicht (laufende Anpassung an Rechtsänderungen)",
"doc_date": f"{year:04d}-{month:02d}-{day:02d}",
"age_years": round(age_years, 1),
"threshold_years": threshold_years,
"action": (
f"{doc_type.title()} überprüfen und an aktuelle "
"Gesetzeslage anpassen (DSGVO-Updates, AI Act, DSA, "
"neue BGH-Rechtsprechung). Stand-Datum aktualisieren."
),
})
if findings:
logger.info("B7 staleness: %d findings", len(findings))
return findings