feat(platform): live-wire AGB v2 + DSE v3 + Architektur-Tab (#29)
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 9s
CI / validate-canonical-controls (push) Successful in 12s
CI / loc-budget (push) Successful in 24s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m11s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 24s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

AGB v2 (decision_method routing, 71%FP->~0) + DSE v3 (4-layer, recovered from container) + Architektur-Tab into /sdk/agent live path. Incl CI robustness (detect-changes.sh + PR-head checkout) + security (hardcoded Qdrant key removed, gitleaks allowlist).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
2026-06-21 12:58:26 +00:00
parent 6b9c7984b4
commit 38a347a82a
44 changed files with 3861 additions and 104 deletions
@@ -0,0 +1,102 @@
"""AGB-Routing-Pipeline (C-lean): nimmt das Keyword-Ergebnis des ChecklistAgent
und routet keyword-durchgefallene Items per `_routing.decision_method` an die
wiederverwendbare Prüfer-Library (Embedding / Reference / LLM). Davor das
Geschäftsmodell-Gate (Applicability). Das Re-Tiering (LOW → Empfehlung) +
Output-Zusammenbau macht der AGBAgent — hier nur die Routing-Entscheidung.
Validiert (7-Firmen-Opus-GT): 71 % FP → ~0. agent.py bleibt dünn, dies ist der
einzige Ort des C-lean-Flows.
"""
from __future__ import annotations
import logging
from compliance.services.checkers.base import (
ControlSpec,
DecisionMethod,
DocContext,
VerificationMethod,
)
from compliance.services.checkers.embedding_checker import EmbeddingChecker
from compliance.services.checkers.llm_checker import LLMChecker
from compliance.services.checkers.reference_checker import ReferenceChecker
from . import _routing
logger = logging.getLogger(__name__)
# Checker sind zustandslos (schwere Imports erst in .check()) → Modul-Singletons.
_EMB = EmbeddingChecker()
_REF = ReferenceChecker()
_LLM = LLMChecker()
def _spec(item_id: str) -> ControlSpec:
"""ControlSpec für ein Item aus der AGB-Routing-Config bauen."""
dm = _routing.decision_method(item_id)
if dm == _routing.REFERENCE:
return ControlSpec(
control_id=item_id, verification_method=VerificationMethod.REFERENCE,
decision_method=DecisionMethod.LINK_RESOLVER,
patterns=[_routing.REFERENCE_PATTERNS[item_id]],
)
if dm == _routing.LLM:
return ControlSpec(
control_id=item_id, verification_method=VerificationMethod.CONTENT,
decision_method=DecisionMethod.LLM,
paraphrases=_routing.PARAPHRASES.get(item_id, []),
topic_regex=_routing.LLM_TOPIC.get(item_id, ""),
question=_routing.LLM_QUESTION.get(item_id, ""),
)
return ControlSpec(
control_id=item_id, verification_method=VerificationMethod.CONTENT,
decision_method=DecisionMethod.EMBEDDING,
paraphrases=_routing.PARAPHRASES.get(item_id, []),
embed_threshold=_routing.EMBED_THRESHOLDS.get(item_id),
)
async def _resolves(item_id: str, text: str, skip_llm: bool):
"""True = Klausel doch vorhanden (Keyword-Finding auflösen). False/None =
Finding behalten (fail-safe: bei Unsicherheit/Service-Ausfall lieber melden)."""
dm = _routing.decision_method(item_id)
if dm == _routing.MERGED:
return True # in ein anderes Item aufgegangen → kein eigenes Finding
doc = DocContext(text=text)
spec = _spec(item_id)
if dm == _routing.REFERENCE:
return (await _REF.check(spec, doc)).present
if dm == _routing.LLM:
if skip_llm:
return None # interaktiv: kein LLM → Keyword-Ergebnis behalten
return (await _LLM.check(spec, doc)).present
return (await _EMB.check(spec, doc)).present
async def run_routed(base_findings: list, text: str, context: dict | None = None):
"""Routet die keyword-durchgefallenen Items.
Returns (kept, resolved_ids, gated_ids):
kept = Findings, die nach Gate+Rescue bestehen bleiben
resolved_ids = per Embedding/Reference/LLM doch als vorhanden erkannt
gated_ids = per Geschäftsmodell nicht anwendbar (N/A)
"""
context = context or {}
skip_llm = bool(context.get("skip_llm"))
model = _routing.detect_business_model(text)
kept, resolved, gated = [], [], []
for f in base_findings:
item_id = f.field_id
if not _routing.is_applicable(item_id, model):
gated.append(item_id)
continue
try:
present = await _resolves(item_id, text, skip_llm)
except Exception as e: # noqa: BLE001 — best-effort, Finding behalten
logger.info("agb routing %s failed: %s", item_id, str(e)[:80])
present = None
if present is True:
resolved.append(item_id)
else:
kept.append(f)
return kept, resolved, gated
@@ -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)
@@ -1,19 +1,60 @@
"""AGBAgent — Allgemeine Geschäftsbedingungen (§§ 305 ff. BGB).
Thin-Subclass von ChecklistAgent über die kuratierte AGB_CHECKLIST (L1
Pflichtangaben + L2 Detailchecks). KEIN Library-Firehose.
ChecklistAgent-Subclass: erst L1/L2-Keyword-Pass, dann **C-lean-Routing** — die
keyword-durchgefallenen Items werden per `decision_method` an die wiederverwendbare
Prüfer-Library geroutet (Embedding / Reference / LLM), davor das Geschäftsmodell-
Gate (Applicability), danach Severity-Re-Tiering (LOW → Empfehlung).
Validiert gegen 7-Firmen-Opus-GT: 71 % FP → ~0. Config in `_routing`, Flow in `_pipeline`.
"""
from __future__ import annotations
from compliance.services.doc_checks.agb_checks import AGB_CHECKLIST
from .._base import AgentInput, AgentOutput, lint_output
from .._checklist_agent import ChecklistAgent
from .._rollup import rollup
from ._pipeline import run_routed
class AGBAgent(ChecklistAgent):
CHECKLIST = AGB_CHECKLIST
agent_id = "agb"
agent_version = "1.0"
agent_version = "2.0" # v2: decision_method-Routing (Embedding/Reference/LLM)
doc_type = "agb"
owned_mc_ids = tuple(c["id"] for c in AGB_CHECKLIST)
async def evaluate(self, agent_input: AgentInput) -> AgentOutput:
# 1) Basis-Keyword-Pass (L1/L2). out.findings = keyword-durchgefallene Items.
out = await super().evaluate(agent_input)
text = (agent_input.text or "").strip()
if len(text) < 100 or not out.findings:
return out # zu kurz / nichts zu routen
# 2) Routing: Gate + Embedding/Reference/LLM-Rescue der Keyword-Misses.
kept, resolved, gated = await run_routed(
out.findings, text, agent_input.context)
resolved_set, gated_set = set(resolved), set(gated)
# 3) Coverage angleichen: rescued → ok, gated → na.
for c in out.mc_coverage:
if c.mc_id in resolved_set:
c.status, c.reason = "ok", "semantisch vorhanden (Routing)"
elif c.mc_id in gated_set:
c.status, c.reason = "na", "für Geschäftsmodell nicht anwendbar"
# 4) Severity-Re-Tiering: HIGH/MEDIUM = Findings, LOW = nur Empfehlung.
out.findings = [f for f in kept if f.severity in ("HIGH", "MEDIUM")]
out.recommendations = rollup(kept)
# 5) Aggregat-Kennzahlen neu (Coverage hat sich verschoben).
cov = out.mc_coverage
out.mc_total = len(cov)
out.mc_ok = sum(1 for c in cov if c.status == "ok")
out.mc_na = sum(1 for c in cov if c.status == "na")
out.mc_high = sum(1 for c in cov if c.status == "high")
out.mc_medium = sum(1 for c in cov if c.status == "medium")
out.mc_low = sum(1 for c in cov if c.status == "low")
out.notes = ((out.notes + " · ") if out.notes else "") + \
f"routed: {len(resolved)} rescued, {len(gated)} n/a"
return lint_output(out)