docs+feat(platform): Pruefer-Matrix-Foundation einfrieren (Evidenz, Mapping, Checker-Library, AGB-Kalibrierung)

Know-how-Freeze der Website-Compliance-Runde (DSE/Cookie/Impressum/AGB). docs: platform_evidence_v1 (Evidenz-/Qualitaetsnachweis, echte Zahlen), nutzungsbedingungen_mapping (neues Modul = Mapping, empirisch belegt), platform_checker_matrix (Meta-Modell verification_method x decision_method), verification_method, platform_validation_v1. code: checkers/ (reusable Pruefer-Library base+reference+embedding+llm, im Container validiert), agb/ (decision_method-Routing + Checker-Prototypen, 71% FP -> ~0 validiert). Dev-only, kein Prod-Push; Benchmark-GTs/Korpora im internen Archiv (data-retention).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-21 09:23:21 +02:00
parent 6b9c7984b4
commit 9d79cf1576
14 changed files with 900 additions and 0 deletions
@@ -0,0 +1,74 @@
"""EMBEDDING-Rescue (decision_method=EMBEDDING) fuer AGB.
Fuer keyword-durchgefallene EMBEDDING-Items: pruefe, ob die Klausel SEMANTISCH
(>= per-Item-Schwelle) im Dokument vorkommt — rettet Recall-FP (Klausel da, anders
formuliert). Referenzvektoren = die Item-Paraphrasen aus `_routing.PARAPHRASES`
(NICHT der mc_classification-Sidecar wie bei DSE, da AGB eine kuratierte
Checkliste statt Library-Controls nutzt).
Deterministisch (festes Embedding-Modell -> gleicher Text -> gleicher Vektor) und
gecacht. Faellt der Embedding-Service aus, liefert die Schicht leer zurueck —
der Keyword-Layer traegt dann (kein Hang, kein Crash).
"""
from __future__ import annotations
import asyncio
import logging
from . import _routing
logger = logging.getLogger(__name__)
# Paraphrasen-Vektoren werden EINMAL pro Prozess eingebettet und gecacht.
_PARA_VEC_CACHE: dict[str, list] = {}
async def _ensure_para_vecs(item_ids: list[str]) -> dict[str, list]:
from compliance.services.mc_embedding_matcher import DIM, _embed_texts
todo = [i for i in item_ids
if i not in _PARA_VEC_CACHE and _routing.PARAPHRASES.get(i)]
for it in todo:
vecs = await _embed_texts(_routing.PARAPHRASES[it])
_PARA_VEC_CACHE[it] = [v for v in vecs if v and len(v) == DIM]
return _PARA_VEC_CACHE
async def embedding_rescue(
text: str,
candidate_ids,
embed_timeout: float = 90.0,
) -> set[str]:
"""Returns die Teilmenge der `candidate_ids`, die semantisch (>= per-Item-
Schwelle) im Text vorkommt. `candidate_ids` = die im Keyword-Layer
DURCHGEFALLENEN Items (Recall-Rescue). Nur EMBEDDING-Items werden behandelt.
"""
cands = [c for c in candidate_ids
if _routing.decision_method(c) == _routing.EMBEDDING
and _routing.PARAPHRASES.get(c)]
if not text or len(text) < 100 or not cands:
return set()
try:
from compliance.services.mc_embedding_matcher import (
DIM, _chunk_text, _cosine, _embed_texts,
)
para_vecs = await _ensure_para_vecs(cands)
chunks = _chunk_text(text)
if not chunks:
return set()
cvecs = [v for v in await asyncio.wait_for(
_embed_texts(chunks), timeout=embed_timeout)
if v and len(v) == DIM]
except (Exception, asyncio.TimeoutError) as e: # Service down -> kein Rescue
logger.info("agb embedding_rescue inaktiv: %s", str(e)[:90])
return set()
if not cvecs:
return set()
rescued: set[str] = set()
for cid in cands:
pv = para_vecs.get(cid) or []
if not pv:
continue
best = max((_cosine(p, c) for p in pv for c in cvecs), default=0.0)
if best >= _routing.EMBED_THRESHOLDS.get(cid, 0.60):
rescued.add(cid)
return rescued
@@ -0,0 +1,74 @@
"""LLM-Judge (decision_method=LLM) fuer die 2 semantisch engen AGB-Items
(delivery_timeframe, warranty_period), bei denen Embedding NICHT trennt.
Retrieval = GANZE Paragraph-Abschnitte (nicht Top-k-Chunks — das war in der
Validierung der Schluessel: Top-4-Chunks verfehlten z.B. die zalando-1-Jahr-
Klausel, der ganze Paragraph nicht). Entscheidung ueber die LLM-Kaskade
(`call_with_cascade`): prod startet bei OVH-120b (stark); dev nur Qwen (schwach,
bekannte Env-Grenze). NUR present/absent — Defekt-Pruefung ist Stage 3.
"""
from __future__ import annotations
import json
import logging
import re
from . import _routing
logger = logging.getLogger(__name__)
_SECTION_SPLIT = re.compile(r"(?m)(?=^\s*(?:§\s*)?\d+[\.\)]\s)")
_SYS = (
"Du bist deutscher AGB-Rechtsexperte. Entscheide, ob die genannte Pflicht in "
"den vorgelegten AGB-Abschnitten vorhanden ist. NUR die Abschnitte zaehlen. "
'Antworte NUR JSON: {"verdict":"ERFUELLT|FEHLT","zitat":"woertlich oder leer",'
'"begruendung":"1 Satz"}.'
)
def _sections(text: str) -> list[str]:
return [s.strip() for s in _SECTION_SPLIT.split(text) if s.strip()]
def relevant_sections(item_id: str, text: str, limit: int = 6) -> list[str]:
"""Ganze Abschnitte zum Thema des Items (Topic-Regex). Fallback: erste Abschnitte."""
secs = _sections(text)
topic = _routing.LLM_TOPIC.get(item_id)
if not topic:
return secs[:limit]
rel = [s for s in secs if re.search(topic, s, re.I)]
return rel[:limit] or secs[:limit]
def _parse(txt: str) -> dict:
out = (txt or "").strip()
if out.startswith("```"):
out = out.split("```", 2)[1]
out = out[4:] if out.startswith("json") else out
a, b = out.find("{"), out.rfind("}")
return json.loads(out[a:b + 1] if 0 <= a < b else out)
async def llm_judge(item_id: str, text: str) -> dict:
"""Returns {present: bool|None, zitat, begruendung, source}.
present=None => Judge konnte nicht entscheiden -> Aufrufer behaelt das
Keyword-Ergebnis (fail-safe Richtung Finding)."""
from compliance.services.llm_cascade import call_with_cascade
question = _routing.LLM_QUESTION.get(item_id, "Ist diese Pflicht im Text vorhanden?")
secs = relevant_sections(item_id, text)
user = json.dumps({"frage": question, "agb_abschnitte": secs}, ensure_ascii=False)
try:
r = await call_with_cascade(_SYS, user, min_confidence=0.6, max_tokens=500)
obj = _parse(r.get("text"))
verdict = obj.get("verdict")
if verdict not in ("ERFUELLT", "FEHLT"):
return {"present": None, "zitat": "", "begruendung": "unklar", "source": r.get("source", "?")}
return {
"present": verdict == "ERFUELLT",
"zitat": (obj.get("zitat") or "")[:200],
"begruendung": (obj.get("begruendung") or "")[:200],
"source": r.get("source", "?"),
}
except Exception as e:
logger.info("agb llm_judge fail %s: %s", item_id, str(e)[:80])
return {"present": None, "zitat": "", "begruendung": "judge_error", "source": "error"}
@@ -0,0 +1,34 @@
"""REFERENCE-Pruefer (verification_method=REFERENCE): ist ein klarer Verweis auf
ein anderes Pflichtdokument vorhanden — und (optional) loest der Link auf?
Fuer AGB: `data_protection` = Verweis auf die Datenschutzerklaerung. Eine AGB soll
KEINE Datenschutz-Inhalte mischen; ein Verweis genuegt (§ ... / best practice).
Deterministisch (Regex), 7/7 gegen Opus-GT — KEIN LLM, kein juristisches Urteil.
Link-Aufloesung (HTTP) ist bewusst NICHT hier: das ist ein Runtime-/Online-Check
(separater Prozess), nicht Teil der deterministischen Text-Pruefung.
"""
from __future__ import annotations
import re
from . import _routing
_URL = re.compile(r"https?://[^\s)\]]+", re.I)
def check_reference(item_id: str, text: str) -> dict:
"""Returns {present: bool, link: str|None}.
present = ein eindeutiger Verweis auf das referenzierte Dokument steht im Text.
link = die in der Naehe gefundene URL (fuer einen spaeteren LINK_CHECK), falls vorhanden.
"""
pat = _routing.REFERENCE_PATTERNS.get(item_id)
if not pat or not text:
return {"present": False, "link": None}
m = re.search(pat, text, re.I)
if not m:
return {"present": False, "link": None}
window = text[max(0, m.start() - 40): m.end() + 200]
url = _URL.search(window) or _URL.search(text)
return {"present": True, "link": url.group(0) if url else None}
@@ -0,0 +1,144 @@
"""AGB-Routing — das verification_method / decision_method-Meta-Modell, angewandt
auf die AGB_CHECKLIST. Siehe docs-src/development/platform_checker_matrix.md.
Pro Checklisten-Item: WELCHER Pruefer (verification_method) und WIE entschieden
wird (decision_method). Single source of truth; `agb_checks.py` bleibt die reine
Pflichtangaben-Liste, dieses Modul ist der additive Routing-Overlay.
Validiert 2026-06-20/21 gegen 7-Firmen-Opus-GT (71 % FP -> ~0):
- 17 Items EMBEDDING (per-Item-Cosinus-Schwelle; 21 recall-FP gekillt, 0 Fehl-Rescue)
- 2 Items LLM (delivery_timeframe, warranty_period; ganze Paragraph-Abschnitte + starkes Modell, present/absent)
- 1 Item REFERENCE (data_protection; DSE-Verweis + Link, 7/7 deterministisch)
- incorporation_clause MERGED in contract (implizit, kein eigener Pruefer)
"""
from __future__ import annotations
# ── decision_method-Werte ────────────────────────────────────────────────
EMBEDDING = "EMBEDDING"
LLM = "LLM"
REFERENCE = "REFERENCE"
MERGED = "MERGED" # in ein anderes Item aufgegangen -> kein eigener Check
# ── Per-Item Embedding-Rescue-Schwellen ───────────────────────────────────
# An der 7-Firmen-GT kalibriert. BEWUSST per-Item: eine globale Schwelle trennt
# bei juristischer Prosa nicht (PASS/FAIL ueberlappen global, trennen per-Item).
# Vorlaeufig (FAIL n=25 klein) -> vor Prod mit mehr Firmen nachkalibrieren.
EMBED_THRESHOLDS: dict[str, float] = {
"scope": 0.58, "contract": 0.58, "payment": 0.60, "payment_methods": 0.58,
"delivery": 0.57, "warranty": 0.58, "termination": 0.60,
"termination_period": 0.60, "termination_form": 0.60, "consumer_rights": 0.55,
"liability": 0.615, "jurisdiction": 0.585, "dispute_odr_link": 0.67,
"choice_of_law_specific": 0.625, "payment_due_date": 0.705,
"salvatory_clause": 0.565, "amendment_clause": 0.635,
}
# ── decision_method je Item (deckt alle 21 Checklisten-IDs ab) ────────────
DECISION_METHOD: dict[str, str] = {cid: EMBEDDING for cid in EMBED_THRESHOLDS}
DECISION_METHOD.update({
"delivery_timeframe": LLM,
"warranty_period": LLM,
"data_protection": REFERENCE,
"incorporation_clause": MERGED, # -> contract
})
# ── Applicability-Gate (VOR allen Pruefern; Geschaeftsmodell entscheidet) ──
ABO_ONLY = {"termination", "termination_period", "termination_form"} # nur Dauerschuld
B2C_ONLY = {"consumer_rights", "dispute_odr_link"} # nicht reines B2B
# ── Referenz-Paraphrasen (Embedding-Rescue + LLM-Section-Ranking) ──────────
PARAPHRASES: dict[str, list[str]] = {
"scope": ["Diese AGB gelten fuer alle Vertraege zwischen dem Anbieter und dem Kunden.",
"Die Angebote richten sich ausschliesslich an Verbraucher, die privat kaufen.",
"Geltungsbereich: fuer die Geschaeftsbeziehung gelten die nachfolgenden Bedingungen."],
"contract": ["Durch Anklicken des Bestellbuttons gibt der Kunde ein verbindliches Angebot ab.",
"Der Vertrag kommt mit Zugang der Bestellbestaetigung zustande.",
"Mit der Bestellung erkennt der Kunde diese AGB als Vertragsbestandteil an."],
"liability": ["Die Haftung fuer leicht fahrlaessige Pflichtverletzungen ist beschraenkt.",
"Wir haften unbeschraenkt fuer Schaeden aus Verletzung von Leben, Koerper, Gesundheit.",
"Bei Verletzung wesentlicher Vertragspflichten Haftung auf vorhersehbaren Schaden begrenzt."],
"jurisdiction": ["Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts.",
"Gerichtsstand fuer alle Streitigkeiten ist der Sitz des Unternehmens.",
"Auf die Vertraege findet deutsches Recht Anwendung."],
"dispute_odr_link": ["Die EU-Kommission stellt eine Plattform zur Online-Streitbeilegung bereit.",
"Zur aussergerichtlichen Streitbeilegung steht die OS-Plattform zur Verfuegung."],
"choice_of_law_specific": ["Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts (CISG).",
"Anwendbar ist das Recht der Bundesrepublik Deutschland."],
"payment": ["Die Preise sind Endpreise inklusive Mehrwertsteuer; Versandkosten gesondert ausgewiesen.",
"Zahlungsbedingungen und Preise richten sich nach den Angaben im Bestellprozess."],
"payment_methods": ["Zur Zahlung stehen Vorkasse, Kreditkarte, Lastschrift, Rechnung und PayPal zur Verfuegung.",
"Folgende Zahlungsarten werden akzeptiert: Ueberweisung, SEPA-Lastschrift, Kreditkarte."],
"payment_due_date": ["Der Kaufpreis ist sofort mit Vertragsschluss faellig.",
"Die Zahlung ist bei Bestellung zu leisten.",
"Der Rechnungsbetrag wird mit Versand der Ware faellig.",
"Bei Kauf auf Rechnung ist der Betrag innerhalb von 14 Tagen zu zahlen."],
"delivery": ["Die Lieferung erfolgt an die vom Kunden angegebene Lieferadresse.",
"Wir liefern innerhalb Deutschlands; die Leistung wird nach Vertragsschluss erbracht."],
"delivery_timeframe": ["Die Lieferzeit betraegt in der Regel 3-5 Werktage.",
"Die Ware wird voraussichtlich innerhalb von 2 bis 4 Werktagen geliefert."],
"warranty": ["Es gelten die gesetzlichen Maengelhaftungsrechte (Gewaehrleistung).",
"Bei Maengeln stehen dem Kunden die gesetzlichen Gewaehrleistungsrechte zu.",
"Fuer Sachmaengel haften wir nach den gesetzlichen Bestimmungen."],
"warranty_period": ["Die Gewaehrleistungsfrist betraegt zwei Jahre ab Lieferung.",
"Die Verjaehrungsfrist fuer Maengelansprueche betraegt zwei Jahre."],
"termination": ["Der Vertrag kann von beiden Parteien ordentlich gekuendigt werden.",
"Das Abonnement kann jederzeit zum Ende der Laufzeit gekuendigt werden."],
"termination_period": ["Die Kuendigungsfrist betraegt einen Monat zum Vertragsende.",
"Der Vertrag ist mit einer Frist von vier Wochen kuendbar."],
"termination_form": ["Die Kuendigung bedarf der Textform und kann per E-Mail erfolgen.",
"Eine Kuendigung ist schriftlich oder per E-Mail moeglich."],
"salvatory_clause": ["Sollten einzelne Bestimmungen unwirksam sein, bleibt die Wirksamkeit der uebrigen unberuehrt.",
"Die Unwirksamkeit einzelner Klauseln beruehrt nicht die Gueltigkeit der uebrigen AGB."],
"amendment_clause": ["Wir behalten uns vor, diese AGB mit Wirkung fuer die Zukunft zu aendern.",
"Aenderungen dieser Bedingungen werden dem Kunden rechtzeitig mitgeteilt."],
"consumer_rights": ["Die gesetzlichen Rechte des Verbrauchers bleiben unberuehrt.",
"Zwingende Verbraucherschutzvorschriften bleiben von diesen Bedingungen unberuehrt."],
}
# ── LLM-Items: Paragraph-Abschnitts-Retrieval + Pruef-Frage ───────────────
LLM_TOPIC: dict[str, str] = {
"delivery_timeframe": r"liefer",
"warranty_period": r"gew(?:ä|ae)hrleist|m(?:ä|ae)ngel|sachm|verj(?:ä|ae)hr|haftungsdauer|garantie",
}
LLM_QUESTION: dict[str, str] = {
"delivery_timeframe": ("Wird eine KONKRETE Lieferzeit/Lieferfrist genannt (z.B. '3-5 Werktage', "
"'innerhalb von 2 Werktagen')? Eine nur allgemeine Lieferregelung ODER ein "
"Verweis 'Lieferzeit im Bestellvorgang' ohne konkrete Frist zaehlt NICHT."),
"warranty_period": ("Wird eine KONKRETE Gewaehrleistungs-/Verjaehrungsfrist als ZAHL genannt "
"(z.B. 'zwei Jahre', 'ein Jahr')? Ein blosser Verweis auf 'gesetzliche "
"Verjaehrungsfristen' ohne Zahl zaehlt NICHT."),
}
# ── REFERENCE-Item data_protection ────────────────────────────────────────
REFERENCE_PATTERNS: dict[str, str] = {
"data_protection": r"datenschutz(erkl(?:ä|ae)rung|bestimmung|hinweis)",
}
def detect_business_model(text: str) -> dict[str, bool]:
"""Deterministischer Geschaeftsmodell-Detektor fuer das Applicability-Gate.
Edge-Case: gemischte Modelle (Webshop + Finanzierung/Service) koennen 'abo'
triggern -> dann greift das termination-Gate nicht; bewusst konservativ
(lieber eine Kuendigungs-Pruefung zu viel als eine echte Luecke uebersehen)."""
tl = text.lower()
consumer = ("widerrufsbelehrung" in tl) or ("widerrufsrecht" in tl and "verbraucher" in tl)
b2b = (not consumer) and any(s in tl for s in (
"geschäftskunden", "ausschließlich an unternehmer", "nur an unternehmer",
"lieferbedingungen für geschäftskunden"))
abo = any(s in tl for s in (
"abonnement", "mindestlaufzeit", "vertragslaufzeit", "verlängert sich",
"monatsabo", "jahresabo")) or ("abo" in tl and "kündig" in tl)
return {"b2b": b2b, "abo": abo, "b2c": not b2b}
def is_applicable(item_id: str, model: dict[str, bool]) -> bool:
"""Gate: gilt das Item fuer dieses Geschaeftsmodell? (False -> N/A, nicht pruefen)."""
if item_id in ABO_ONLY and not model.get("abo"):
return False
if item_id in B2C_ONLY and model.get("b2b"):
return False
return True
def decision_method(item_id: str) -> str:
"""decision_method fuer ein Item; Default EMBEDDING (Prosa-Rescue)."""
return DECISION_METHOD.get(item_id, EMBEDDING)