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:
@@ -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)
|
||||
Reference in New Issue
Block a user