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