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:
@@ -13,6 +13,7 @@ the map). Once the tabs are the source of truth, B18's v1 path retires.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from compliance.services.specialist_agents import REGISTRY, AgentInput
|
||||
@@ -27,6 +28,8 @@ logger = logging.getLogger(__name__)
|
||||
# topic key (matches state["doc_texts"]) -> registered agent_id
|
||||
_TOPIC_AGENTS: dict[str, str] = {
|
||||
"impressum": "impressum",
|
||||
"agb": "agb", # v2: AGBAgent mit decision_method-Routing (71% FP -> ~0)
|
||||
"dse": "dse", # v3: 4-Layer (Regex-Boost/Keyword/BGE-M3-Recall/Semantic)
|
||||
}
|
||||
|
||||
_MIN_TEXT = 100
|
||||
@@ -112,14 +115,17 @@ async def run_agent_outputs(state: dict) -> None:
|
||||
)
|
||||
|
||||
outputs: dict[str, dict] = state.get("agent_outputs") or {}
|
||||
for topic, agent_id in _TOPIC_AGENTS.items():
|
||||
|
||||
async def _run_one(topic: str, agent_id: str):
|
||||
"""Einen Topic-Agent laufen lassen + sein Tab-Event sofort emittieren
|
||||
(Zwischenbefund). Fängt eigene Fehler → ein Agent reißt den Run nicht ab."""
|
||||
text = (doc_texts.get(topic) or "").strip()
|
||||
if len(text) < _MIN_TEXT:
|
||||
continue
|
||||
return None
|
||||
agent = REGISTRY.get(agent_id)
|
||||
if agent is None:
|
||||
logger.warning("agent_outputs: agent '%s' not registered", agent_id)
|
||||
continue
|
||||
return None
|
||||
try:
|
||||
out = await agent.evaluate(AgentInput(
|
||||
doc_type=topic,
|
||||
@@ -128,15 +134,25 @@ async def run_agent_outputs(state: dict) -> None:
|
||||
company_name=company_name,
|
||||
origin_domain=origin_domain,
|
||||
))
|
||||
outputs[topic] = out.model_dump(mode="json")
|
||||
emit(check_id, {"type": "topic", "topic": topic,
|
||||
"output": outputs[topic]})
|
||||
dump = out.model_dump(mode="json")
|
||||
emit(check_id, {"type": "topic", "topic": topic, "output": dump})
|
||||
logger.info(
|
||||
"agent_outputs[%s]: %d findings, confidence %.2f",
|
||||
topic, len(out.findings), out.confidence,
|
||||
)
|
||||
return topic, dump
|
||||
except Exception as e: # noqa: BLE001 — best-effort, never break the run
|
||||
logger.warning("agent_outputs[%s] failed: %s", topic, e)
|
||||
return None
|
||||
|
||||
# Topic-Agenten laufen NEBENLÄUFIG (ihre Embedding-/LLM-Waits überlappen) und
|
||||
# füllen ihren Tab via SSE, sobald sie fertig sind — kein Warten aufs Schlusslicht.
|
||||
results = await asyncio.gather(
|
||||
*(_run_one(topic, agent_id) for topic, agent_id in _TOPIC_AGENTS.items())
|
||||
)
|
||||
for r in results:
|
||||
if r:
|
||||
outputs[r[0]] = r[1]
|
||||
|
||||
if outputs:
|
||||
state["agent_outputs"] = outputs
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Pruefer-Library — gemeinsames Interface. Siehe docs platform_checker_matrix.md.
|
||||
|
||||
Ein Checker prueft EINEN Control gegen EIN Dokument und liefert: vorhanden / fehlt
|
||||
/ unklar (+ Evidence). Module (DSE/Impressum/AGB/...) liefern nur Control-Metadaten
|
||||
ueber `ControlSpec` (verification_method + decision_method + checker-spezifische
|
||||
Config); die Engine routet method-agnostisch zum passenden Checker.
|
||||
|
||||
Ziel der Plattform: 14k Controls -> 7 Pruefertypen -> wenige Pruefer. Ein neues
|
||||
Modul wird damit ein Klassifizierungs-, kein Forschungsproblem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class VerificationMethod:
|
||||
"""Achse 1 — WELCHER Pruefer-Typ (Kategorie)."""
|
||||
FIELD = "FIELD"
|
||||
REFERENCE = "REFERENCE"
|
||||
BEHAVIOR = "BEHAVIOR"
|
||||
PRESENTATION = "PRESENTATION"
|
||||
CONTENT = "CONTENT"
|
||||
PROCESS = "PROCESS"
|
||||
TECHNICAL = "TECHNICAL"
|
||||
CONTRACTUAL = "CONTRACTUAL"
|
||||
|
||||
|
||||
class DecisionMethod:
|
||||
"""Achse 2 — WIE entschieden wird (konkreter Mechanismus)."""
|
||||
REGEX = "REGEX"
|
||||
EMBEDDING = "EMBEDDING"
|
||||
LLM = "LLM"
|
||||
LINK_RESOLVER = "LINK_RESOLVER"
|
||||
PLAYWRIGHT = "PLAYWRIGHT"
|
||||
AUDIT = "AUDIT"
|
||||
SCANNER = "SCANNER"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlSpec:
|
||||
"""Routing-Metadaten + checker-spezifische Config eines Controls. Module fuellen
|
||||
nur die fuer ihren decision_method relevanten Felder."""
|
||||
control_id: str
|
||||
verification_method: str
|
||||
decision_method: str
|
||||
label: str = ""
|
||||
severity: str = "MEDIUM"
|
||||
patterns: list[str] = field(default_factory=list) # FIELD/REGEX, REFERENCE
|
||||
paraphrases: list[str] = field(default_factory=list) # CONTENT (EMBEDDING/LLM)
|
||||
embed_threshold: Optional[float] = None # EMBEDDING (per-Control)
|
||||
topic_regex: str = "" # LLM: Section-Retrieval
|
||||
question: str = "" # LLM: Pruef-Frage
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocContext:
|
||||
"""Das zu pruefende Artefakt. `text` = Volltext; `url`/`rendered` fuer
|
||||
PRESENTATION/BEHAVIOR (Playwright) — spaeter."""
|
||||
text: str = ""
|
||||
url: str = ""
|
||||
rendered: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
present: Optional[bool] # True=erfuellt, False=fehlt, None=unklar (fail-safe)
|
||||
evidence: str = ""
|
||||
confidence: float = 0.0
|
||||
source: str = "" # welcher Pruefer/Tier geantwortet hat
|
||||
detail: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Checker(Protocol):
|
||||
"""Alle Pruefer haben dieselbe Signatur -> die Engine ist method-agnostisch und
|
||||
routet nur ueber ctrl.verification_method / ctrl.decision_method."""
|
||||
verification_method: str
|
||||
|
||||
async def check(self, ctrl: ControlSpec, doc: DocContext) -> CheckResult:
|
||||
...
|
||||
@@ -0,0 +1,51 @@
|
||||
"""CONTENT-Pruefer / decision_method=EMBEDDING.
|
||||
|
||||
Ist die Pflicht SEMANTISCH im Text vorhanden? Max-Cosinus (Doc-Chunks x Control-
|
||||
Paraphrasen) >= per-Control-Schwelle. Deterministisch (festes Embedding-Modell)
|
||||
und gecacht. Rettet Recall-FP (Klausel da, anders formuliert).
|
||||
|
||||
Faellt der Embedding-Service aus, liefert der Checker present=None (unklar) — der
|
||||
Aufrufer behaelt dann das Keyword-Ergebnis (kein Hang, kein Crash).
|
||||
(Validiert an AGB: 17 Items, per-Item-Schwelle, 0 Fehl-Rescue.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from .base import CheckResult, ControlSpec, DocContext, VerificationMethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Paraphrasen-Vektoren je Control einmal einbetten + cachen.
|
||||
_PARA_CACHE: dict[str, list] = {}
|
||||
|
||||
|
||||
class EmbeddingChecker:
|
||||
verification_method = VerificationMethod.CONTENT
|
||||
|
||||
async def check(self, ctrl: ControlSpec, doc: DocContext) -> CheckResult:
|
||||
text = doc.text or ""
|
||||
paras = ctrl.paraphrases or []
|
||||
thr = ctrl.embed_threshold if ctrl.embed_threshold is not None else 0.60
|
||||
if not paras or len(text) < 100:
|
||||
return CheckResult(present=None, source="embedding")
|
||||
try:
|
||||
from compliance.services.mc_embedding_matcher import (
|
||||
DIM, _chunk_text, _cosine, _embed_texts,
|
||||
)
|
||||
if ctrl.control_id not in _PARA_CACHE:
|
||||
pv = await _embed_texts(paras)
|
||||
_PARA_CACHE[ctrl.control_id] = [v for v in pv if v and len(v) == DIM]
|
||||
pvecs = _PARA_CACHE[ctrl.control_id]
|
||||
chunks = _chunk_text(text)
|
||||
cvecs = [v for v in await asyncio.wait_for(
|
||||
_embed_texts(chunks), timeout=90.0) if v and len(v) == DIM]
|
||||
except (Exception, asyncio.TimeoutError) as e:
|
||||
logger.info("embedding checker inaktiv %s: %s", ctrl.control_id, str(e)[:80])
|
||||
return CheckResult(present=None, source="embedding")
|
||||
if not pvecs or not cvecs:
|
||||
return CheckResult(present=None, source="embedding")
|
||||
best = max((_cosine(p, c) for p in pvecs for c in cvecs), default=0.0)
|
||||
return CheckResult(present=best >= thr, confidence=round(best, 3),
|
||||
source="embedding")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""CONTENT/CONTRACTUAL-Pruefer / decision_method=LLM.
|
||||
|
||||
present/absent ueber die LLM-Kaskade (`call_with_cascade`; prod: OVH-120b zuerst).
|
||||
Retrieval = GANZE Paragraph-Abschnitte zum Topic (nicht Top-k-Chunks — das war in
|
||||
der AGB-Validierung der Schluessel). KEIN DEFECT — Korrektheits-/Defekt-Pruefung
|
||||
ist ein separater Modus. present=None bei Fehler (fail-safe: Aufrufer behaelt
|
||||
Keyword-Ergebnis). (Validiert an AGB delivery/warranty.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .base import CheckResult, ControlSpec, DocContext, VerificationMethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SECTION = re.compile(r"(?m)(?=^\s*(?:§\s*)?\d+[\.\)]\s)")
|
||||
_SYS = (
|
||||
"Du bist deutscher Compliance-Rechtsexperte. Entscheide, ob die genannte "
|
||||
"Pflicht in den vorgelegten 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(text) if s.strip()]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class LLMChecker:
|
||||
verification_method = VerificationMethod.CONTENT
|
||||
|
||||
async def check(self, ctrl: ControlSpec, doc: DocContext) -> CheckResult:
|
||||
text = doc.text or ""
|
||||
if len(text) < 50:
|
||||
return CheckResult(present=None, source="llm")
|
||||
secs = _sections(text)
|
||||
if ctrl.topic_regex:
|
||||
rel = [s for s in secs if re.search(ctrl.topic_regex, s, re.I)][:6] or secs[:6]
|
||||
else:
|
||||
rel = secs[:6]
|
||||
question = ctrl.question or f"Ist die Pflicht '{ctrl.label}' im Text vorhanden?"
|
||||
try:
|
||||
from compliance.services.llm_cascade import call_with_cascade
|
||||
r = await call_with_cascade(
|
||||
_SYS,
|
||||
json.dumps({"frage": question, "abschnitte": rel}, ensure_ascii=False),
|
||||
min_confidence=0.6, max_tokens=500,
|
||||
)
|
||||
obj = _parse(r.get("text"))
|
||||
verdict = obj.get("verdict")
|
||||
zitat = (obj.get("zitat") or "")[:120]
|
||||
if verdict not in ("ERFUELLT", "FEHLT"):
|
||||
return CheckResult(present=None, evidence=zitat, source=r.get("source", "?"))
|
||||
return CheckResult(
|
||||
present=verdict == "ERFUELLT", evidence=zitat,
|
||||
confidence=float(r.get("confidence") or 0.0),
|
||||
source=r.get("source", "llm"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info("llm checker fail %s: %s", ctrl.control_id, str(e)[:80])
|
||||
return CheckResult(present=None, source="error")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""REFERENCE-Pruefer (verification_method=REFERENCE, decision_method=LINK_RESOLVER).
|
||||
|
||||
Ist ein klarer Verweis auf ein anderes Pflichtdokument vorhanden (+ optional: loest
|
||||
der Link auf)? Deterministisch. Bsp: 'Details in unserer Datenschutzerklaerung'.
|
||||
KEIN LLM, kein juristisches Urteil. (Validiert an AGB data_protection: 7/7.)
|
||||
|
||||
Die tatsaechliche HTTP-Aufloesung des Links ist ein optionaler Runtime-Schritt
|
||||
(online), nicht Teil dieser deterministischen Text-Pruefung — die URL wird hier
|
||||
nur extrahiert und in `detail['link']` zurueckgegeben.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .base import CheckResult, ControlSpec, DocContext, VerificationMethod
|
||||
|
||||
_URL = re.compile(r"https?://[^\s)\]]+", re.I)
|
||||
|
||||
|
||||
class ReferenceChecker:
|
||||
verification_method = VerificationMethod.REFERENCE
|
||||
|
||||
async def check(self, ctrl: ControlSpec, doc: DocContext) -> CheckResult:
|
||||
text = doc.text or ""
|
||||
pats = ctrl.patterns or []
|
||||
if not pats or not text:
|
||||
return CheckResult(present=False, source="reference")
|
||||
for p in pats:
|
||||
m = re.search(p, text, re.I)
|
||||
if m:
|
||||
window = text[max(0, m.start() - 40): m.end() + 200]
|
||||
url = _URL.search(window) or _URL.search(text)
|
||||
link = url.group(0) if url else None
|
||||
return CheckResult(
|
||||
present=True,
|
||||
evidence=" ".join(m.group(0).split())[:120],
|
||||
confidence=1.0,
|
||||
source="reference",
|
||||
detail={"link": link},
|
||||
)
|
||||
return CheckResult(present=False, source="reference")
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Applicability-Gate fuer den DSE-Scan.
|
||||
|
||||
Schliesst Controls aus dem DSE-FINDINGS-Scan aus, die laut
|
||||
`compliance.control_classification` NICHT gegen eine DSE laufen
|
||||
('DSE' nicht in applicable_artifacts) UND sicher klassifiziert sind
|
||||
(needs_review=false). Diese werden NICHT geloescht, sondern als
|
||||
*organisatorische Checkliste* zurueckgegeben (Routing zu VVT/TOM/Audit).
|
||||
|
||||
Fail-safe: unsichere Klassifikationen (needs_review=true) bleiben im
|
||||
Findings-Scan. Defensiv: fehlt die Tabelle (z.B. Prod ohne Migration),
|
||||
liefert das Gate ein leeres Dict -> es wird NICHT gefiltert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def load_dse_gate(db_url: str = "") -> dict[str, dict[str, Any]]:
|
||||
"""Liefert {control_id: meta} fuer Controls, die aus dem DSE-Findings-Scan
|
||||
auszuschliessen sind (hochsicher organisatorisch). Leeres Dict = kein Filter.
|
||||
"""
|
||||
dsn = (db_url or os.getenv("DATABASE_URL")
|
||||
or os.getenv("COMPLIANCE_DATABASE_URL") or "")
|
||||
if not dsn:
|
||||
return {}
|
||||
try:
|
||||
import asyncpg
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT control_id, obligation_type, check_intent,
|
||||
applicable_artifacts, reference_allowed
|
||||
FROM compliance.control_classification
|
||||
WHERE is_active AND NOT needs_review
|
||||
AND NOT ('DSE' = ANY(applicable_artifacts))""")
|
||||
finally:
|
||||
await conn.close()
|
||||
except Exception as e: # Tabelle fehlt / DB nicht erreichbar -> kein Filter
|
||||
logger.info("dse classification gate inaktiv: %s", str(e)[:90])
|
||||
return {}
|
||||
return {
|
||||
r["control_id"]: {
|
||||
"obligation_type": r["obligation_type"],
|
||||
"check_intent": r["check_intent"],
|
||||
"applicable_artifacts": list(r["applicable_artifacts"] or []),
|
||||
"reference_allowed": r["reference_allowed"],
|
||||
}
|
||||
for r in rows if r["control_id"]
|
||||
}
|
||||
|
||||
|
||||
def apply_gate(
|
||||
controls: list[dict[str, Any]],
|
||||
gate: dict[str, dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Teilt geladene Controls in (findings_controls, organizational).
|
||||
|
||||
findings_controls: laufen normal durch den DSE-Scan.
|
||||
organizational: aus dem Scan genommen, als Checkliste ausgegeben
|
||||
(control_id + title + Klassifikations-Metadaten fuer das Routing).
|
||||
"""
|
||||
kept: list[dict[str, Any]] = []
|
||||
organizational: list[dict[str, Any]] = []
|
||||
for c in controls:
|
||||
cid = c.get("control_id")
|
||||
meta = gate.get(cid) if cid else None
|
||||
if meta:
|
||||
organizational.append({
|
||||
"control_id": cid,
|
||||
"title": c.get("title"),
|
||||
**meta,
|
||||
})
|
||||
else:
|
||||
kept.append(c)
|
||||
return kept, organizational
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Deterministische semantische Recall-Schicht für den DSE-Check.
|
||||
|
||||
WARUM: Reines Keyword-Matching hat schlechten Recall (eine Pflicht lässt sich
|
||||
auf viele Arten formulieren). Der frühere Regex-Boost war zu stumpf (über-passt
|
||||
auf vollständigen Dokumenten). BGE-M3-Embeddings erkennen den SINN — und sind
|
||||
dabei DETERMINISTISCH: ein Embedding-Modell ist eine feste Funktion, gleicher
|
||||
Text → gleicher Vektor → gleiches Pass/Fail bei fester Schwelle. Reproduzierbar,
|
||||
auditierbar, kein Keyword-Katalog, kein generatives LLM zur Checkzeit.
|
||||
|
||||
Design:
|
||||
- Doc wird EINMAL pro Dokument-Hash eingebettet (teuer: ~37s/64k-Doc), die
|
||||
Per-Control-Scores werden gecacht (/data) → Folge-Checks sind instant.
|
||||
- Reachability-Guard: ist der Embedding-Service nicht erreichbar, liefert die
|
||||
Schicht leer zurück (der deterministische Keyword-Layer trägt) — KEIN Hang.
|
||||
- Schwelle ist die einzige Stellschraube (DSE-Default 0.65, an BMW-GT kalibriert;
|
||||
braucht Mehr-Firmen-Kalibrierung gegen Overfitting — bewusst konservativ).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DSE-Schwelle: an BMW-Haiku-GT vermessen (PASS-Median 0.648 / FAIL-Median 0.612).
|
||||
# 0.65 = präzisionsfreundlich (wenig Über-Pass). Per ENV überschreibbar für
|
||||
# spätere Mehr-Firmen-Kalibrierung, ohne Code-Änderung.
|
||||
DSE_EMBED_THRESHOLD = float(os.getenv("DSE_EMBED_THRESHOLD", "0.65"))
|
||||
_CACHE_PATH = os.getenv("DSE_EMBED_CACHE", "/data/dse_embed_cache.json")
|
||||
_SIDECAR_DB = os.getenv("MC_CLASS_DB", "/data/mc_classification.db")
|
||||
|
||||
|
||||
def _doc_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", "ignore")).hexdigest()[:20]
|
||||
|
||||
|
||||
def _load_cache() -> dict:
|
||||
try:
|
||||
with open(_CACHE_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(cache: dict) -> None:
|
||||
try:
|
||||
# LRU-Kappung: max 30 Dokumente im Cache (Scores sind klein)
|
||||
if len(cache) > 30:
|
||||
for k in list(cache.keys())[:-30]:
|
||||
cache.pop(k, None)
|
||||
tmp = _CACHE_PATH + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f)
|
||||
os.replace(tmp, _CACHE_PATH)
|
||||
except Exception as e:
|
||||
logger.warning("dse embed-cache save failed: %s", e)
|
||||
|
||||
|
||||
def _load_control_vecs(cids: Iterable[str]) -> dict[str, list[float]]:
|
||||
from compliance.services.mc_embedding_matcher import _blob_to_vec
|
||||
cid_list = [c for c in cids if c]
|
||||
if not cid_list:
|
||||
return {}
|
||||
try:
|
||||
with sqlite3.connect(_SIDECAR_DB) as c:
|
||||
ph = ",".join("?" * len(cid_list))
|
||||
rows = c.execute(
|
||||
f"SELECT control_id, embedding FROM mc_classification "
|
||||
f"WHERE control_id IN ({ph}) AND doc_type='dse' "
|
||||
f"AND check_type='text' AND embedding IS NOT NULL",
|
||||
cid_list,
|
||||
).fetchall()
|
||||
return {cid: _blob_to_vec(b) for cid, b in rows}
|
||||
except Exception as e:
|
||||
logger.warning("dse control-vec load failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
async def _embedding_reachable(timeout: float = 2.0) -> bool:
|
||||
"""Schneller TCP-Connect zum Embedding-Service. Verhindert, dass ein toter
|
||||
Service den Check blockiert (macmini-Lehrer-Last hat das Embedding früher
|
||||
verstopft)."""
|
||||
url = os.getenv("EMBEDDING_URL", "http://embedding-service:8087")
|
||||
hostport = url.split("://", 1)[-1].split("/", 1)[0]
|
||||
host, _, port = hostport.partition(":")
|
||||
port = int(port or "8087")
|
||||
try:
|
||||
fut = asyncio.open_connection(host, port)
|
||||
reader, writer = await asyncio.wait_for(fut, timeout=timeout)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("dse embedding-service nicht erreichbar (%s) — "
|
||||
"deterministischer Layer trägt", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _compute_scores(text: str, all_cids: list[str]) -> dict[str, float]:
|
||||
"""Bettet das Dokument EINMAL ein und liefert max-Cosinus je Control."""
|
||||
from compliance.services.mc_embedding_matcher import (
|
||||
_chunk_text, _cosine, _embed_texts, DIM,
|
||||
)
|
||||
mc_vecs = _load_control_vecs(all_cids)
|
||||
if not mc_vecs:
|
||||
return {}
|
||||
chunks = _chunk_text(text)
|
||||
if not chunks:
|
||||
return {}
|
||||
chunk_vecs = await _embed_texts(chunks)
|
||||
chunk_vecs = [v for v in chunk_vecs if v and len(v) == DIM]
|
||||
if not chunk_vecs:
|
||||
return {}
|
||||
return {
|
||||
cid: round(float(max((_cosine(mv, cv) for cv in chunk_vecs),
|
||||
default=0.0)), 4)
|
||||
for cid, mv in mc_vecs.items()
|
||||
}
|
||||
|
||||
|
||||
async def embedding_recall(
|
||||
text: str,
|
||||
candidate_cids: Iterable[str],
|
||||
threshold: float | None = None,
|
||||
embed_timeout: float = 90.0,
|
||||
) -> set[str]:
|
||||
"""Returns die candidate control_ids, die semantisch (>= Schwelle) im Doc
|
||||
vorkommen. Deterministisch + gecacht. Leeres Set, wenn Service down/Fehler.
|
||||
|
||||
candidate_cids: die im Keyword-Layer DURCHGEFALLENEN Controls (Recall-Rescue).
|
||||
"""
|
||||
cands = [c for c in candidate_cids if c]
|
||||
if not text or len(text) < 100 or not cands:
|
||||
return set()
|
||||
thr = DSE_EMBED_THRESHOLD if threshold is None else threshold
|
||||
|
||||
h = _doc_hash(text)
|
||||
cache = _load_cache()
|
||||
scores = cache.get(h)
|
||||
|
||||
if scores is None:
|
||||
if not await _embedding_reachable():
|
||||
return set()
|
||||
try:
|
||||
scores = await asyncio.wait_for(
|
||||
_compute_scores(text, cands), timeout=embed_timeout)
|
||||
except (Exception, asyncio.TimeoutError) as e:
|
||||
logger.warning("dse embedding_recall skipped: %s", e)
|
||||
return set()
|
||||
if not scores:
|
||||
return set()
|
||||
cache[h] = scores
|
||||
_save_cache(cache)
|
||||
logger.info("dse embedding_recall: doc %s eingebettet (%d Scores)",
|
||||
h, len(scores))
|
||||
else:
|
||||
logger.info("dse embedding_recall: Cache-Treffer doc %s", h)
|
||||
|
||||
cand_set = set(cands)
|
||||
return {cid for cid, s in scores.items()
|
||||
if cid in cand_set and s >= thr}
|
||||
@@ -1,29 +1,286 @@
|
||||
"""DSEAgent — Datenschutzerklärung / Datenschutzinformation (Art. 13/14 DSGVO).
|
||||
"""DSE-Agent v3 — Datenschutzerklärung / Datenschutzinformation (Art. 13/14
|
||||
DSGVO), baut auf doc_check_controls (267 text-MCs aus DB).
|
||||
|
||||
Thin-Subclass von ChecklistAgent über die kuratierte ART13_CHECKLIST (KEIN
|
||||
90k-Library-Firehose). Einzige Spezialität: Drittland wird bei dokumentiertem
|
||||
Drittlandtransfer (Scan-Kontext) zu HIGH angehoben.
|
||||
Volle Parität zu impressum/ + cookie_policy/ (User-Vorgabe 2026-06-17):
|
||||
Layer 0 — Regex-Boost (kuratierte Art-13-Patterns aus mcs.py / ART13_CHECKLIST)
|
||||
Layer 1 — Keyword-Match aus pass_criteria der DSE-DB-MCs (deterministisch)
|
||||
Layer 2 — BGE-M3 Embedding-Match
|
||||
Layer 3 — Semantic-Validator (LLM) für offene HIGH/MEDIUM-Fails + Auto-Learning
|
||||
|
||||
Die kuratierten Patterns gehen NICHT verloren — sie boosten (Layer 0) die DB-
|
||||
Controls (z.B. präzises "keine Drittlandübermittlung" → drittland-MC PASS, kein
|
||||
False-Positive). DSE-Spezialität bleibt: Drittland → HIGH bei dokumentiertem
|
||||
Transfer (scan_context).
|
||||
|
||||
Output-Layer (Linter / Rollup / Methodik-UI) bleibt 1:1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.services.doc_checks.dse_checks import ART13_CHECKLIST
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .._base import AgentInput
|
||||
from .._checklist_agent import ChecklistAgent
|
||||
from .._base import (
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
BaseSpecialistAgent,
|
||||
EscalationLog,
|
||||
EvidenceSource,
|
||||
Finding,
|
||||
McCoverage,
|
||||
Severity,
|
||||
SourceType,
|
||||
lint_output,
|
||||
)
|
||||
from .._pattern_library import record as record_pattern
|
||||
from .._rollup import rollup
|
||||
from .._semantic_validator import build_rename_action, validate_present
|
||||
from .mcs import MC_IDS, MCS
|
||||
from .regex_boost import BOOST_KEYWORDS
|
||||
from .v3_engine import run_v3_pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DSEAgent(ChecklistAgent):
|
||||
CHECKLIST = ART13_CHECKLIST
|
||||
_SEV_TO_ENUM = {
|
||||
"CRITICAL": Severity.HIGH,
|
||||
"HIGH": Severity.HIGH,
|
||||
"MEDIUM": Severity.MEDIUM,
|
||||
"LOW": Severity.LOW,
|
||||
"INFO": Severity.INFO,
|
||||
}
|
||||
|
||||
# Drittland-Vokabeln für die scan_context-Heraufstufung (Art. 13(1)(f)).
|
||||
_THIRD_COUNTRY_KW = tuple(set(
|
||||
BOOST_KEYWORDS.get("third_country", ())
|
||||
+ BOOST_KEYWORDS.get("third_country_mechanism", ())
|
||||
))
|
||||
|
||||
|
||||
def _build_measure(label: str, norm: str) -> str:
|
||||
"""Maßnahme (Imperativ) statt Pruef-Frage als action."""
|
||||
base = (label or "").strip().rstrip(".")
|
||||
if not base:
|
||||
return ("Datenschutz-Pflichtangabe ergänzen und gegen Art. 13/14 "
|
||||
"DSGVO prüfen.")
|
||||
msg = f"Pflichtangabe ergänzen: {base}."
|
||||
if norm:
|
||||
msg += f" Rechtsgrundlage: {norm}."
|
||||
return msg
|
||||
|
||||
|
||||
def _is_third_country_topic(result: dict) -> bool:
|
||||
"""Ist dieses DB-MC thematisch ein Drittland-Control?"""
|
||||
parts: list[str] = [str(result.get("label") or "").lower()]
|
||||
for c in (result.get("_pass_criteria") or []):
|
||||
if c:
|
||||
parts.append(str(c).lower())
|
||||
blob = " ".join(parts)
|
||||
hits = sum(1 for kw in _THIRD_COUNTRY_KW if kw in blob)
|
||||
return hits >= 1
|
||||
|
||||
|
||||
class DSEAgent(BaseSpecialistAgent):
|
||||
agent_id = "dse"
|
||||
agent_version = "1.0"
|
||||
agent_version = "3.0"
|
||||
doc_type = "dse"
|
||||
owned_mc_ids = tuple(c["id"] for c in ART13_CHECKLIST)
|
||||
owned_mc_ids = MC_IDS
|
||||
|
||||
def _severity_override(self, c: dict, agent_input: AgentInput):
|
||||
def _third_country_transfer(self, agent_input: AgentInput) -> bool:
|
||||
sc = (agent_input.context or {}).get("scan_context") or {}
|
||||
tc = str(sc.get("third_country_transfer", "")).lower() in (
|
||||
return str(sc.get("third_country_transfer", "")).lower() in (
|
||||
"yes", "true", "1", "ja")
|
||||
if tc and c["id"] in ("third_country", "third_country_mechanism"):
|
||||
return "HIGH"
|
||||
return None
|
||||
|
||||
async def evaluate(self, agent_input: AgentInput) -> AgentOutput:
|
||||
start = datetime.now(timezone.utc)
|
||||
text = (agent_input.text or "").strip()
|
||||
scope = set(agent_input.business_scope or [])
|
||||
coverage: list[McCoverage] = []
|
||||
findings: list[Finding] = []
|
||||
esc_logs: list[EscalationLog] = []
|
||||
notes_parts: list[str] = []
|
||||
|
||||
if len(text) < 100:
|
||||
for mc in MCS:
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="skipped",
|
||||
label=mc.label, reason="text too short",
|
||||
))
|
||||
return self._finalize(
|
||||
start, findings, esc_logs, coverage,
|
||||
confidence=0.0,
|
||||
notes="DSE-Text zu kurz oder leer.",
|
||||
)
|
||||
|
||||
tc_transfer = self._third_country_transfer(agent_input)
|
||||
# Embedding-Recall (Layer 2) läuft IMMER — deterministisch, gecacht
|
||||
# (pro Doc-Hash → Folge-Views instant) und Reachability-gegated
|
||||
# (kein Hang, wenn der Service fehlt). Ersetzt den über-passenden Boost.
|
||||
results, telemetry = await run_v3_pipeline(text, scope)
|
||||
notes_parts.append(
|
||||
f"v3-pipeline: {telemetry.get('total_mcs', 0)} DB-MCs · "
|
||||
f"{telemetry.get('layer_1_pass', 0)} Keyword-Treffer · "
|
||||
f"{telemetry.get('embedding_passes', 0)} semantisch (Embedding)"
|
||||
)
|
||||
if telemetry.get("sector_dropped") or telemetry.get("offtopic_dropped"):
|
||||
notes_parts.append(
|
||||
f"Scope-Filter: {telemetry.get('sector_dropped', 0)} "
|
||||
f"Branchen-MCs, {telemetry.get('offtopic_dropped', 0)} "
|
||||
"themenfremde MCs entfernt"
|
||||
)
|
||||
|
||||
seen: set[str] = set()
|
||||
for r in results:
|
||||
mc_id = r.get("control_id") or ""
|
||||
if not mc_id or mc_id in seen:
|
||||
continue
|
||||
seen.add(mc_id)
|
||||
passed = bool(r.get("passed"))
|
||||
sev = _SEV_TO_ENUM.get(
|
||||
(r.get("severity") or "MEDIUM").upper(), Severity.MEDIUM,
|
||||
)
|
||||
# DSE-Spezialität: Drittland → HIGH bei dokumentiertem Transfer.
|
||||
sev_reason = "db_mc_failed"
|
||||
if tc_transfer and _is_third_country_topic(r):
|
||||
sev = Severity.HIGH
|
||||
sev_reason = "db_mc_failed_third_country_transfer"
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc_id,
|
||||
status="ok" if passed else sev.value.lower(),
|
||||
reason=str(r.get("matched_text") or r.get("hint") or "")[:120],
|
||||
))
|
||||
if passed:
|
||||
continue
|
||||
label = r.get("label") or r.get("hint") or ""
|
||||
norm_str = str(r.get("regulation") or "").strip()
|
||||
if r.get("article"):
|
||||
norm_str = (norm_str + f" Art. {r.get('article')}").strip()
|
||||
if not norm_str:
|
||||
norm_str = "DSGVO Art. 13/14"
|
||||
findings.append(Finding(
|
||||
check_id=f"DSE-DBMC-{mc_id}",
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
field_id=mc_id,
|
||||
severity=sev,
|
||||
severity_reason=sev_reason,
|
||||
title=str(label)[:200] or f"DB-MC {mc_id} nicht erfüllt",
|
||||
norm=norm_str,
|
||||
evidence="",
|
||||
action=_build_measure(str(label), norm_str)[:400],
|
||||
confidence=0.9,
|
||||
sources=[EvidenceSource(
|
||||
source_type=SourceType.MC,
|
||||
source_id=mc_id,
|
||||
detail=str(r.get("source") or "keyword_match")[:120],
|
||||
confidence=0.9,
|
||||
)],
|
||||
))
|
||||
|
||||
# Boost-Coverage: meine Pattern-Treffer (regex-boost field_ids).
|
||||
boost_ids = set(telemetry.get("layer_0_field_ids") or [])
|
||||
for mc in MCS:
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id,
|
||||
status="ok" if mc.field_id in boost_ids else "na",
|
||||
label=mc.label,
|
||||
reason=("regex-boost hit"
|
||||
if mc.field_id in boost_ids
|
||||
else "kein Pattern-Treffer (kein Veto)"),
|
||||
))
|
||||
|
||||
if not (agent_input.context or {}).get("skip_llm"):
|
||||
await self._semantic_demote(text, findings, coverage)
|
||||
|
||||
confs = [f.confidence for f in findings if f.confidence] or [0.95]
|
||||
overall = sum(confs) / len(confs)
|
||||
|
||||
return self._finalize(
|
||||
start, findings, esc_logs, coverage,
|
||||
confidence=overall, notes=" · ".join(notes_parts),
|
||||
)
|
||||
|
||||
async def _semantic_demote(
|
||||
self, text: str, findings: list[Finding],
|
||||
coverage: list[McCoverage],
|
||||
) -> None:
|
||||
"""LLM-Layer für HIGH/MEDIUM-DB-MCs: Label-Mismatch-Check.
|
||||
Bei Fund → HIGH/MEDIUM → LOW + Rename-Action."""
|
||||
candidates = [
|
||||
f for f in findings
|
||||
if f.severity in (Severity.HIGH.value, Severity.MEDIUM.value)
|
||||
and f.severity_reason in (
|
||||
"db_mc_failed", "db_mc_failed_third_country_transfer")
|
||||
]
|
||||
if not candidates:
|
||||
return
|
||||
result = await validate_present(
|
||||
text, [(f.field_id, f.title[:80]) for f in candidates],
|
||||
)
|
||||
if not result:
|
||||
return
|
||||
for finding in candidates:
|
||||
row = result.get(finding.field_id)
|
||||
if not row or not row.get("found"):
|
||||
continue
|
||||
if row.get("confidence", 0) < 0.6:
|
||||
continue
|
||||
label_used = row.get("label_used") or "abweichendes Label"
|
||||
conf = float(row.get("confidence") or 0.8)
|
||||
finding.severity = Severity.LOW.value
|
||||
finding.severity_reason = "label_mismatch"
|
||||
finding.title = (
|
||||
f"Label '{label_used}' weicht von Standard ab"
|
||||
)
|
||||
finding.evidence = str(row.get("evidence") or "")[:200]
|
||||
finding.action = build_rename_action(
|
||||
finding.field_id, label_used,
|
||||
)
|
||||
finding.confidence = conf
|
||||
finding.sources.append(EvidenceSource(
|
||||
source_type=SourceType.LLM_LOCAL,
|
||||
source_id="semantic_validator",
|
||||
detail=f"LLM-confirmed: '{label_used}'",
|
||||
confidence=conf,
|
||||
))
|
||||
for c in coverage:
|
||||
if c.mc_id == finding.field_id:
|
||||
c.status = "low"
|
||||
c.reason = f"label_mismatch: '{label_used}'"
|
||||
try:
|
||||
record_pattern(
|
||||
field_id=finding.field_id,
|
||||
label_used=label_used,
|
||||
confidence=conf,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("pattern-library record failed: %s", e)
|
||||
|
||||
def _finalize(
|
||||
self, start: datetime, findings: list[Finding],
|
||||
esc_logs: list[EscalationLog], coverage: list[McCoverage],
|
||||
confidence: float, notes: str = "",
|
||||
) -> AgentOutput:
|
||||
end = datetime.now(timezone.utc)
|
||||
recs = rollup(findings)
|
||||
out = AgentOutput(
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
started_at=start,
|
||||
finished_at=end,
|
||||
duration_ms=int((end - start).total_seconds() * 1000),
|
||||
findings=findings,
|
||||
recommendations=recs,
|
||||
mc_coverage=coverage,
|
||||
escalation_log=esc_logs,
|
||||
confidence=confidence,
|
||||
notes=notes,
|
||||
mc_total=len(coverage),
|
||||
mc_ok=sum(1 for c in coverage if c.status == "ok"),
|
||||
mc_na=sum(1 for c in coverage if c.status == "na"),
|
||||
mc_high=sum(1 for c in coverage if c.status == "high"),
|
||||
mc_medium=sum(1 for c in coverage if c.status == "medium"),
|
||||
mc_low=sum(1 for c in coverage if c.status == "low"),
|
||||
)
|
||||
return lint_output(out)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""DSE-Tiefenprüfung: LLM-Kaskade auf die UNSCHARFEN Findings.
|
||||
|
||||
User-Architektur (2026-06-18): die deterministische Engine (Keyword + Embedding)
|
||||
triagiert. Eindeutige Fälle (sehr hoher/niedriger Embedding-Score) bleiben
|
||||
deterministisch. Die UNSCHARFE Mitte + grenzwertig-Bestandene gehen durch die
|
||||
Kaskade — denn dort entstehen sowohl 'verpasste Lücken' (schlimmster Fehler) als
|
||||
auch Falsch-Findings (Rework).
|
||||
|
||||
Eskalation auf ANTWORT-UNSICHERHEIT (nicht JSON-Gültigkeit): jedes Tier liefert
|
||||
{erfuellt, confidence, begruendung}. Confidence < Schwelle → nächstes Tier.
|
||||
Tier 1: Qwen 35B (lokal, schnell, billig)
|
||||
Tier 2: OVH gpt-oss-120B
|
||||
Tier 3: Claude — NUR mit Freigabe (allow_claude), sonst 'needs_freigabe'.
|
||||
|
||||
Judging-Leitplanken (User-Vorgaben):
|
||||
- Speicherdauer nur erfüllt bei konkreter Höchstdauer ODER echtem,
|
||||
nachvollziehbarem Kriterium — NICHT zirkulär ('bis Zweck wegfällt').
|
||||
- Ohne ausreichenden Kontext → eher nicht erfüllt (nichts fehlen lassen).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from compliance.services.llm_cascade import (
|
||||
_call_anthropic, _call_ollama, _call_ovh,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Unscharfe Embedding-Zone (kalibriert an 5-Firmen-GT 2026-06-18): außerhalb ist
|
||||
# die Engine sicher genug, innen entscheidet der LLM.
|
||||
FUZZY_LO = float(os.getenv("DSE_FUZZY_LO", "0.50"))
|
||||
FUZZY_HI = float(os.getenv("DSE_FUZZY_HI", "0.72"))
|
||||
# Selbstkonfidenz-Schwelle: darunter → eskalieren.
|
||||
ESC_CONF = float(os.getenv("DSE_ESC_CONF", "0.75"))
|
||||
|
||||
_JUDGE_SYS = (
|
||||
"Du bist ein erfahrener DSGVO-Datenschutz-Auditor. Du prüfst, ob eine "
|
||||
"konkrete Pflicht in einer Datenschutzerklärung (DSE) ERFÜLLT ist. "
|
||||
"Sei streng wie ein Fachanwalt: lieber 'nicht erfüllt' wenn unklar — eine "
|
||||
"übersehene Lücke ist schlimmer als ein Hinweis zu viel. "
|
||||
"Speicherdauer ist NUR erfüllt bei konkreter Höchstdauer ODER einem echten, "
|
||||
"nachvollziehbaren Kriterium; zirkuläre Formeln ('bis der Zweck wegfällt') "
|
||||
"erfüllen die Pflicht NICHT. "
|
||||
'Antworte AUSSCHLIESSLICH als JSON: '
|
||||
'{"erfuellt": true|false, "confidence": 0.0-1.0, "begruendung": "kurz"}'
|
||||
)
|
||||
|
||||
|
||||
def _build_user(doc_text: str, title: str, criteria: list) -> str:
|
||||
crit = "; ".join(str(c) for c in (criteria or []) if c)[:600]
|
||||
return (
|
||||
f"PFLICHT: {title}\n"
|
||||
f"Erfüllt, wenn: {crit}\n\n"
|
||||
f"DATENSCHUTZERKLÄRUNG (Auszug):\n{doc_text[:14000]}\n\n"
|
||||
"Ist die Pflicht im Text inhaltlich erfüllt?"
|
||||
)
|
||||
|
||||
|
||||
def _parse(text: str) -> dict | None:
|
||||
if not text:
|
||||
return None
|
||||
s, e = text.find("{"), text.rfind("}")
|
||||
if s < 0 or e <= s:
|
||||
return None
|
||||
try:
|
||||
o = json.loads(text[s:e + 1])
|
||||
return {
|
||||
"erfuellt": bool(o.get("erfuellt")),
|
||||
"confidence": float(o.get("confidence") or 0.0),
|
||||
"begruendung": str(o.get("begruendung") or "")[:300],
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def judge_control(
|
||||
doc_text: str, title: str, criteria: list, allow_claude: bool = False,
|
||||
) -> dict:
|
||||
"""Tiered judgment mit Selbstkonfidenz-Eskalation. Returns
|
||||
{erfuellt, confidence, source, begruendung, needs_freigabe}."""
|
||||
user = _build_user(doc_text, title, criteria)
|
||||
tiers = [("qwen", _call_ollama), ("ovh_120b", _call_ovh)]
|
||||
best: dict | None = None
|
||||
for name, call in tiers:
|
||||
try:
|
||||
if name == "qwen":
|
||||
txt = await call(_JUDGE_SYS, user, max_tokens=400,
|
||||
timeout=60, think=False)
|
||||
else:
|
||||
txt = await call(_JUDGE_SYS, user, max_tokens=400)
|
||||
except Exception as e:
|
||||
logger.warning("deep_check tier %s failed: %s", name, e)
|
||||
txt = ""
|
||||
p = _parse(txt)
|
||||
if p:
|
||||
p["source"] = name
|
||||
best = p
|
||||
if p["confidence"] >= ESC_CONF:
|
||||
return {**p, "needs_freigabe": False}
|
||||
# Tier 3: Claude — nur mit Freigabe
|
||||
if not allow_claude:
|
||||
if best:
|
||||
return {**best, "needs_freigabe": True}
|
||||
return {"erfuellt": False, "confidence": 0.0, "source": "none",
|
||||
"begruendung": "Unsicher — Anwalt/Claude-Freigabe nötig",
|
||||
"needs_freigabe": True}
|
||||
try:
|
||||
txt = await _call_anthropic(_JUDGE_SYS, user, max_tokens=400)
|
||||
p = _parse(txt)
|
||||
if p:
|
||||
return {**p, "source": "anthropic_claude", "needs_freigabe": False}
|
||||
except Exception as e:
|
||||
logger.warning("deep_check claude failed: %s", e)
|
||||
if best:
|
||||
return {**best, "needs_freigabe": False}
|
||||
return {"erfuellt": False, "confidence": 0.0, "source": "none",
|
||||
"begruendung": "Kein LLM-Ergebnis", "needs_freigabe": False}
|
||||
|
||||
|
||||
def is_fuzzy(score: float, kw_pass: bool) -> bool:
|
||||
"""Unscharf = im Embedding-Graubereich UND nicht durch Keyword klar bestätigt.
|
||||
Klar-bestanden (kw) bleibt deterministisch; klar-hoch/niedrig auch."""
|
||||
if kw_pass:
|
||||
return False
|
||||
return FUZZY_LO <= score <= FUZZY_HI
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Machine-Check-Definitionen für den DSE-Agent (Layer-0 Regex-Boost).
|
||||
|
||||
Eine MC = ein abgegrenztes Art-13/14-DSGVO-Pflichtfeld mit deterministischen
|
||||
Patterns. Quelle der Patterns ist die EINE kuratierte ART13_CHECKLIST
|
||||
(doc_checks/dse_checks.py) — hier nur in das MC-Format gehoben, damit der
|
||||
Regex-Boost (regex_boost.py) und die v3-Engine (v3_engine.py) dieselbe Struktur
|
||||
nutzen wie impressum/ + cookie_policy/. KEINE Pattern-Duplikation: die Patterns
|
||||
bleiben in dse_checks.py, dieses Modul kompiliert sie nur.
|
||||
|
||||
Owner = dse-agent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Pattern
|
||||
|
||||
from compliance.services.doc_checks.dse_checks import ART13_CHECKLIST
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MC:
|
||||
"""Eine Machine-Check-Definition (Boost-Pattern für ein DSE-Feld)."""
|
||||
mc_id: str # DSE-MC-001 ...
|
||||
field_id: str # controller, legal_basis, third_country ...
|
||||
label: str
|
||||
norm: str
|
||||
patterns: tuple[Pattern[str], ...] = field(default_factory=tuple)
|
||||
severity_if_missing: str = "MEDIUM"
|
||||
level: int = 1
|
||||
|
||||
|
||||
_NORM_RE = re.compile(r"\((Art\.[^)]+|§\s*\d+[^)]*)\)")
|
||||
|
||||
|
||||
def _norm_of(label: str) -> str:
|
||||
m = _NORM_RE.search(label or "")
|
||||
return m.group(1).strip() if m else "Art. 13/14 DSGVO"
|
||||
|
||||
|
||||
def _compile(patterns: list[str]) -> tuple[Pattern[str], ...]:
|
||||
out: list[Pattern[str]] = []
|
||||
for p in patterns or ():
|
||||
try:
|
||||
out.append(re.compile(p, re.IGNORECASE | re.MULTILINE))
|
||||
except re.error:
|
||||
continue
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _build_mcs() -> tuple[MC, ...]:
|
||||
"""Hebt die ART13_CHECKLIST in das MC-Format (Boost-Pattern pro Feld)."""
|
||||
mcs: list[MC] = []
|
||||
for i, c in enumerate(ART13_CHECKLIST, start=1):
|
||||
mcs.append(MC(
|
||||
mc_id=f"DSE-MC-{i:03d}",
|
||||
field_id=c["id"],
|
||||
label=c["label"],
|
||||
norm=_norm_of(c["label"]),
|
||||
patterns=_compile(c.get("patterns", [])),
|
||||
severity_if_missing=c.get("severity", "MEDIUM"),
|
||||
level=c.get("level", 1),
|
||||
))
|
||||
return tuple(mcs)
|
||||
|
||||
|
||||
MCS: tuple[MC, ...] = _build_mcs()
|
||||
|
||||
# Public list of all MC-IDs for the Registry / owned_mc_ids.
|
||||
MC_IDS: tuple[str, ...] = tuple(m.mc_id for m in MCS)
|
||||
|
||||
|
||||
def scope_matches(mc: MC, scope: set[str]) -> bool:
|
||||
"""Art-13/14-Pflichten gelten universell für jede DSE — keine Branchen-
|
||||
Gating auf Boost-Ebene (anders als Impressum mit Kammerberufen). Das
|
||||
Sektor-Gate über den control_id-Prefix passiert in der v3-Engine."""
|
||||
return True
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Layer-0 Regex-Boost für den DSE-Agent — die kuratierten Art-13/14-Patterns
|
||||
als deterministische Vor-Stufe vor dem Keyword-Match aus doc_check_controls.
|
||||
|
||||
Analog zu impressum/regex_boost.py + cookie_policy/regex_boost.py:
|
||||
- run_v3_pipeline lädt die 267 text-MCs (doc_type='dse') und macht
|
||||
Keyword-Match aus deren pass_criteria.
|
||||
- MEIN Beitrag (Layer 0): die präzisen Art-13-Patterns (mcs.py / aus
|
||||
ART13_CHECKLIST) laufen ZUERST. Trifft ein Pattern → das thematisch
|
||||
passende DB-MC wird zu PASS geboostet (auch wenn der Keyword-Match unklar
|
||||
war). Mapping: field_id → typische Wörter in der pass_criteria der DB-MC.
|
||||
|
||||
Damit gehen die kuratierten DSE-Patterns nicht verloren, sondern boosten das
|
||||
DB-Control-System (statt es zu ersetzen).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .mcs import MCS, scope_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# field_id (aus ART13_CHECKLIST) → Wörter, wie sie in der pass_criteria der
|
||||
# zugehörigen DSE-DB-MCs vorkommen. Treffen ≥2 dieser Wörter in den criteria
|
||||
# eines DB-MC, gehört es zu diesem Feld → Boost. Die Vokabeln sind an den
|
||||
# real beobachteten DB-Kriterien ausgerichtet (DATA/SEC/AUTH-Controls).
|
||||
BOOST_KEYWORDS: dict[str, tuple[str, ...]] = {
|
||||
"controller": (
|
||||
"verantwortlich", "verantwortliche stelle", "verantwortlichen",
|
||||
"kontaktdaten des verantwortlichen", "name und kontaktdaten",
|
||||
"firmenname", "rechtsform", "anschrift", "ladungsfähige",
|
||||
"identität des verantwortlichen", "controller",
|
||||
),
|
||||
"dpo": (
|
||||
"datenschutzbeauftragt", "datenschutzbeauftragter",
|
||||
"datenschutzbeauftragte", "data protection officer",
|
||||
"kontaktdaten des datenschutz", "benennung", "art. 37",
|
||||
),
|
||||
"purposes": (
|
||||
"zweck", "zwecke", "verarbeitungszweck", "zweck der verarbeitung",
|
||||
"zwecke der verarbeitung", "verarbeitungstätigkeit", "purpose",
|
||||
"zweckbindung", "erhebung",
|
||||
),
|
||||
"legal_basis": (
|
||||
"rechtsgrundlage", "berechtigte interesse", "berechtigtes interesse",
|
||||
"einwilligung", "vertragserfüllung", "vertragserfuellung",
|
||||
"interessenabwägung", "interessenabwaegung", "art. 6",
|
||||
"rechtmäßigkeit", "rechtmaessigkeit", "erforderlich",
|
||||
),
|
||||
"recipients": (
|
||||
"empfänger", "empfaenger", "empfängerkategorien",
|
||||
"empfaengerkategorien", "weitergabe", "übermittlung an",
|
||||
"auftragsverarbeit", "auftragsverarbeiter", "dienstleister",
|
||||
"dritte", "drittempfänger", "kategorien von empfängern",
|
||||
),
|
||||
"third_country": (
|
||||
"drittland", "drittstaat", "drittländer", "drittlaender",
|
||||
"standardvertragsklausel", "angemessenheitsbeschluss",
|
||||
"übermittlung in ein drittland", "geeignete garantien",
|
||||
"schutzgarantien", "data privacy framework", "ewr",
|
||||
"internationale übermittlung", "transfermechanismus",
|
||||
),
|
||||
"third_country_mechanism": (
|
||||
"standardvertragsklausel", "angemessenheitsbeschluss",
|
||||
"geeignete garantien", "data privacy framework",
|
||||
"schutzgarantien", "art. 46", "art. 45",
|
||||
),
|
||||
"retention": (
|
||||
"speicherdauer", "aufbewahrungsfrist", "aufbewahrungsdauer",
|
||||
"löschfrist", "loeschfrist", "speicherbegrenzung", "löschung",
|
||||
"loeschung", "dauer der speicherung", "kriterien für die festlegung",
|
||||
"speicherfrist", "aufbewahrung",
|
||||
),
|
||||
"retention_periods": (
|
||||
"aufbewahrungsfrist", "löschfrist", "speicherdauer",
|
||||
"gesetzliche frist", "handelsrechtlich", "steuerrechtlich",
|
||||
),
|
||||
"rights": (
|
||||
"betroffenenrecht", "betroffenenrechte", "recht auf auskunft",
|
||||
"auskunft", "berichtigung", "löschung", "loeschung",
|
||||
"einschränkung", "einschraenkung", "datenübertragbarkeit",
|
||||
"datenuebertragbarkeit", "widerspruch", "widerruf",
|
||||
"rechte der betroffenen", "art. 15", "art. 17", "art. 21",
|
||||
),
|
||||
"complaint": (
|
||||
"beschwerderecht", "aufsichtsbehörde", "aufsichtsbehoerde",
|
||||
"datenschutzbehörde", "datenschutzbehoerde", "beschwerde",
|
||||
"recht auf beschwerde", "art. 77", "zuständige aufsichtsbehörde",
|
||||
),
|
||||
"rights_art22_profiling": (
|
||||
"automatisierte entscheidung", "profiling", "art. 22",
|
||||
"automatisierte einzelentscheidung", "scoring",
|
||||
),
|
||||
"dse_version_date": (
|
||||
"stand", "letzte aktualisierung", "zuletzt geändert",
|
||||
"gültig ab", "gueltig ab", "version", "versionsdatum",
|
||||
"aktualität", "nachweisbarkeit",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute_regex_boosts(text: str, business_scope: set[str] | None = None) -> set[str]:
|
||||
"""Welche DSE-field_ids haben die kuratierten Patterns erkannt?
|
||||
|
||||
Returns die Menge gehit'ter field_ids, über die später entschieden wird,
|
||||
ob ein DB-MC darüber automatisch passed werden kann. business_scope wird
|
||||
akzeptiert (Signatur-Parität mit impressum), für DSE aber nicht gegated —
|
||||
Art-13-Pflichten sind universell.
|
||||
"""
|
||||
if not text or len(text) < 50:
|
||||
return set()
|
||||
scope = business_scope or set()
|
||||
hits: set[str] = set()
|
||||
for mc in MCS:
|
||||
if not scope_matches(mc, scope):
|
||||
continue
|
||||
if any(p.search(text) for p in mc.patterns):
|
||||
hits.add(mc.field_id)
|
||||
return hits
|
||||
|
||||
|
||||
def boost_matches_db_mc(
|
||||
boosts: set[str],
|
||||
pass_criteria: list,
|
||||
fail_criteria: list | None = None,
|
||||
) -> str | None:
|
||||
"""Hat ein gebooster field_id ≥2 Keyword-Überlapp mit den pass/fail_criteria
|
||||
eines DB-MC? Returns field_id (höchster Match-Count) oder None."""
|
||||
if not boosts:
|
||||
return None
|
||||
crit_parts: list[str] = []
|
||||
for c in (pass_criteria or []):
|
||||
if c:
|
||||
crit_parts.append(str(c).lower())
|
||||
for c in (fail_criteria or []):
|
||||
if c:
|
||||
crit_parts.append(str(c).lower())
|
||||
if not crit_parts:
|
||||
return None
|
||||
crit_text = " ".join(crit_parts)
|
||||
best: tuple[int, str] | None = None
|
||||
for field_id in boosts:
|
||||
kws = BOOST_KEYWORDS.get(field_id) or ()
|
||||
match_count = sum(1 for kw in kws if kw in crit_text)
|
||||
if match_count >= 2:
|
||||
if best is None or match_count > best[0]:
|
||||
best = (match_count, field_id)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def criteria_on_topic(
|
||||
pass_criteria: list | None,
|
||||
fail_criteria: list | None = None,
|
||||
min_hits: int = 2,
|
||||
) -> bool:
|
||||
"""Deterministischer Themen-Gate: gehört ein DB-MC überhaupt ins DSE-
|
||||
Themenfeld (Art 13/14)? ≥min_hits unterschiedliche Schlüsselwörter aus
|
||||
IRGENDEINEM DSE-Feld in den kombinierten criteria. Fängt fremd-getaggte
|
||||
MCs ab. Leere Kriterien → on-topic behalten (konservativ)."""
|
||||
crit_parts: list[str] = []
|
||||
for c in (pass_criteria or []):
|
||||
if c:
|
||||
crit_parts.append(str(c).lower())
|
||||
for c in (fail_criteria or []):
|
||||
if c:
|
||||
crit_parts.append(str(c).lower())
|
||||
if not crit_parts:
|
||||
return True
|
||||
crit_text = " ".join(crit_parts)
|
||||
hits: set[str] = set()
|
||||
for kws in BOOST_KEYWORDS.values():
|
||||
for kw in kws:
|
||||
if kw in crit_text:
|
||||
hits.add(kw)
|
||||
if len(hits) >= min_hits:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,209 @@
|
||||
"""v3-Engine: läuft die 4-Layer-Pipeline auf einem DSE-Text (Art. 13/14 DSGVO).
|
||||
|
||||
Layer 0 — Regex-Boost (die kuratierten Art-13-Patterns aus mcs.py)
|
||||
Layer 1 — MC-Laden + Keyword-Match. Das LADEN delegiert an die Main-Tool-
|
||||
Engine (rag_document_checker._load_controls, doc_type='dse'):
|
||||
eine Quelle der Wahrheit inkl. P72-Scope, check_type='text'
|
||||
(267 von 571) und fits_doc_type/scope_requires aus dem Sidecar.
|
||||
Layer 2 — BGE-M3 Embedding-Match (mc_embedding_matcher, shared)
|
||||
Layer 0 Override — failed MCs, deren criteria zu einem gebooster field_id
|
||||
passen, werden zu PASS überschrieben.
|
||||
|
||||
Zusätzlich am Agent-Rand: subtraktives Sektor-/Themen-Gate (_filter_controls)
|
||||
— das Sektor-Gate (Branchen-Prefix GOV/FIN/MED…) verwirft branchenfremde MCs,
|
||||
das Themen-Gate fremd-getaggte. Analog impressum/v3_engine.py.
|
||||
|
||||
Output: Liste Result-Dicts kompatibel mit rag_document_checker. Der Agent
|
||||
konvertiert sie zu Finding-Objekten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .regex_boost import (
|
||||
compute_regex_boosts,
|
||||
criteria_on_topic,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Branchen-Prefix -> erwarteter Scope-Token. Reuse aus dem Mail-V2-Scope-
|
||||
# Filter, damit Agent-Pfad und Report-Pfad dieselbe Quelle nutzen. Import
|
||||
# defensiv: faellt der Mail-Pfad weg, bleibt der Agent lauffaehig.
|
||||
try:
|
||||
from compliance.services.mail_render_v2._scope_filter import (
|
||||
SECTOR_PREFIXES,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensiver Fallback
|
||||
SECTOR_PREFIXES = {}
|
||||
|
||||
|
||||
async def run_v3_pipeline(
|
||||
text: str,
|
||||
business_scope: set[str],
|
||||
db_url: str = "",
|
||||
skip_embedding: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""Returns (results, telemetry).
|
||||
|
||||
results: pro DB-MC ein dict {control_id, passed, severity, ...}
|
||||
telemetry: counters für Frontend-Anzeige (Layer-Aufschlüsselung)
|
||||
|
||||
skip_embedding: Layer-2 (BGE-M3 Recall) überspringen. Nur für Unit-Tests
|
||||
ohne Embedding-Service. Im Betrieb läuft die Recall-Schicht: sie ist
|
||||
gecacht (pro Doc-Hash) und Reachability-gegated, blockiert also nie.
|
||||
"""
|
||||
if not text or len(text) < 100:
|
||||
return [], {"reason": "text too short"}
|
||||
|
||||
# Layer 0: kuratierte Art-13-Patterns
|
||||
boosts = compute_regex_boosts(text, business_scope)
|
||||
boost_field_ids = sorted(boosts)
|
||||
logger.info("dse v3 Layer-0 boosts: %d hits — %s",
|
||||
len(boost_field_ids), boost_field_ids)
|
||||
|
||||
# Layer 1: MC-Laden DELEGIERT an die Main-Tool-Engine (Scope-Schutz inkl.).
|
||||
try:
|
||||
from compliance.services.rag_document_checker import _load_controls
|
||||
controls = await _load_controls(
|
||||
"dse", db_url, 0, business_scope,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("dse v3 load via main-tool engine failed: %s", e)
|
||||
controls = []
|
||||
_normalize_criteria(controls)
|
||||
# Agent-Rand-Backstop: Sektor-Gate (Branchen-Prefix) + Themen-Gate.
|
||||
controls, drop_stats = _filter_controls(controls, business_scope)
|
||||
# Applicability-Gate: hochsichere organisatorische Controls (laut
|
||||
# control_classification NICHT DSE, needs_review=false) aus dem
|
||||
# FINDINGS-Scan nehmen -> organisatorische Checkliste statt False-FEHLT.
|
||||
# Fail-safe: needs_review bleiben drin. Defensiv: fehlt die Tabelle, kein
|
||||
# Filter (Prod-sicher). Siehe _classification_gate.
|
||||
from ._classification_gate import apply_gate, load_dse_gate
|
||||
gate = await load_dse_gate(db_url)
|
||||
organizational: list[dict[str, Any]] = []
|
||||
if gate:
|
||||
controls, organizational = apply_gate(controls, gate)
|
||||
results: list[dict[str, Any]] = []
|
||||
if controls:
|
||||
try:
|
||||
from compliance.services.rag_document_checker import (
|
||||
_check_mc_deterministic,
|
||||
)
|
||||
text_lower = text.lower().replace("\xad", "")
|
||||
for mc in controls:
|
||||
r = _check_mc_deterministic(text_lower, mc)
|
||||
if r:
|
||||
r["_pass_criteria"] = mc.get("pass_criteria")
|
||||
r["_fail_criteria"] = mc.get("fail_criteria")
|
||||
results.append(r)
|
||||
except Exception as e:
|
||||
logger.warning("layer-1 keyword check failed: %s", e)
|
||||
results = []
|
||||
|
||||
layer_1_pass = sum(1 for r in results if r.get("passed"))
|
||||
|
||||
# Layer 2: DETERMINISTISCHE semantische Recall-Schicht (BGE-M3, gecacht).
|
||||
# Ersetzt den früheren Regex-Boost, der auf vollständigen DSE-Dokumenten
|
||||
# massiv über-passte (BMW: 71/94 Boost-Overrides → 49% Übereinstimmung).
|
||||
# Embedding ist genauer (BMW-GT: KW|EMB@0.65 = 75%) UND deterministisch
|
||||
# (feste Funktion, reproduzierbar — kein Keyword-Katalog, kein LLM).
|
||||
embedding_passes = 0
|
||||
if not skip_embedding:
|
||||
failed_cids = [r.get("control_id") for r in results
|
||||
if r and not r.get("passed") and r.get("control_id")]
|
||||
if failed_cids:
|
||||
try:
|
||||
from ._embedding_recall import embedding_recall
|
||||
semantic = await embedding_recall(text, failed_cids)
|
||||
except Exception as e:
|
||||
logger.warning("dse embedding recall failed: %s", e)
|
||||
semantic = set()
|
||||
for r in results:
|
||||
if (r.get("control_id") in semantic
|
||||
and not r.get("passed")):
|
||||
r["passed"] = True
|
||||
r["matched_text"] = "[semantisch erkannt — Embedding]"
|
||||
r["source"] = (r.get("source") or "") + "+embedding"
|
||||
embedding_passes += 1
|
||||
|
||||
telemetry = {
|
||||
"layer_0_field_hits": len(boost_field_ids),
|
||||
"layer_0_field_ids": boost_field_ids,
|
||||
"layer_1_pass": layer_1_pass,
|
||||
"embedding_passes": embedding_passes,
|
||||
"total_mcs": len(results),
|
||||
"sector_dropped": drop_stats.get("sector_dropped", 0),
|
||||
"offtopic_dropped": drop_stats.get("offtopic_dropped", 0),
|
||||
"gate_excluded": len(organizational),
|
||||
"organizational_checklist": organizational,
|
||||
}
|
||||
logger.info("dse v3 telemetry: %s", telemetry)
|
||||
return results, telemetry
|
||||
|
||||
|
||||
def _filter_controls(
|
||||
controls: list[dict[str, Any]],
|
||||
business_scope: set[str],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
||||
"""Subtraktiver Scope-Filter VOR der Bewertung.
|
||||
|
||||
1. Sektor-Gate — MCs deren control_id-Prefix eine Branche bezeichnet
|
||||
(FIN/GOV/MED/INS/EDU/LEG/REL/POL), die NICHT im business_scope liegt
|
||||
UND die nicht on-topic ist, werden verworfen.
|
||||
2. Themen-Gate — MCs ohne DSE-Themenüberlapp werden verworfen.
|
||||
|
||||
Rein subtraktiv: entfernt nur falsch-positive Kandidaten.
|
||||
"""
|
||||
scope_lc = {s.lower() for s in (business_scope or set())}
|
||||
kept: list[dict[str, Any]] = []
|
||||
sector_dropped = 0
|
||||
offtopic_dropped = 0
|
||||
for c in controls:
|
||||
cid = c.get("control_id") or ""
|
||||
prefix = cid.split("-")[0].upper() if "-" in cid else ""
|
||||
on_topic = criteria_on_topic(c.get("pass_criteria"),
|
||||
c.get("fail_criteria"))
|
||||
required = SECTOR_PREFIXES.get(prefix)
|
||||
# Sektor-Gate nur fuer NICHT-on-topic Controls: ein klar DSE-
|
||||
# thematischer Control (z.B. GOV-Prefix aus der Domain-Erkennung)
|
||||
# darf nicht am Branchen-Prefix scheitern.
|
||||
if required and not (scope_lc & required) and not on_topic:
|
||||
sector_dropped += 1
|
||||
continue
|
||||
if not on_topic:
|
||||
offtopic_dropped += 1
|
||||
continue
|
||||
kept.append(c)
|
||||
if sector_dropped or offtopic_dropped:
|
||||
logger.info(
|
||||
"dse v3 scope-filter: -%d Branchen-MCs, -%d themenfremde MCs "
|
||||
"(scope=%s)", sector_dropped, offtopic_dropped,
|
||||
sorted(scope_lc) or "leer",
|
||||
)
|
||||
return kept, {
|
||||
"sector_dropped": sector_dropped,
|
||||
"offtopic_dropped": offtopic_dropped,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_criteria(controls: list[dict[str, Any]]) -> None:
|
||||
"""asyncpg liefert JSONB-Spalten (pass_criteria/fail_criteria) als
|
||||
Roh-String. In echte Listen parsen, damit Sektor-/Themen-Gate und der
|
||||
Boost-Layer Element-weise iterieren."""
|
||||
import json
|
||||
for c in controls:
|
||||
for key in ("pass_criteria", "fail_criteria"):
|
||||
v = c.get(key)
|
||||
if isinstance(v, list):
|
||||
continue
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
c[key] = parsed if isinstance(parsed, list) else [v]
|
||||
except Exception:
|
||||
c[key] = [v] if v else []
|
||||
else:
|
||||
c[key] = []
|
||||
@@ -1,12 +1,27 @@
|
||||
"""AGBAgent — kuratierte §§-305-ff-BGB-Checkliste (ChecklistAgent-Subclass)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""AGBAgent (v2, routed). Embedding/LLM offline-gestubbt → kein Netzwerk."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import compliance.services.specialist_agents.agb._pipeline as pipeline
|
||||
from compliance.services.checkers.base import CheckResult
|
||||
from compliance.services.specialist_agents import REGISTRY, AgentInput
|
||||
|
||||
|
||||
class _Stub:
|
||||
def __init__(self, present):
|
||||
self._p = present
|
||||
|
||||
async def check(self, ctrl, doc):
|
||||
return CheckResult(present=self._p)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _offline(monkeypatch):
|
||||
monkeypatch.setattr(pipeline, "_EMB", _Stub(None))
|
||||
monkeypatch.setattr(pipeline, "_LLM", _Stub(None))
|
||||
|
||||
|
||||
def _run(text: str):
|
||||
return asyncio.run(
|
||||
REGISTRY.get("agb").evaluate(AgentInput(doc_type="agb", text=text)))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""AGB routed-Pipeline: Gate, Reference-/Embedding-Rescue, LLM-skip, Re-Tiering.
|
||||
Embedding + LLM offline-gestubbt → deterministisch, kein Netzwerk (Reference = echtes Regex)."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import compliance.services.specialist_agents.agb._pipeline as pipeline
|
||||
from compliance.services.checkers.base import CheckResult
|
||||
from compliance.services.specialist_agents._base import AgentInput
|
||||
from compliance.services.specialist_agents.agb.agent import AGBAgent
|
||||
|
||||
|
||||
class _Stub:
|
||||
def __init__(self, present):
|
||||
self._p = present
|
||||
|
||||
async def check(self, ctrl, doc):
|
||||
return CheckResult(present=self._p)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _offline(monkeypatch):
|
||||
monkeypatch.setattr(pipeline, "_EMB", _Stub(None))
|
||||
monkeypatch.setattr(pipeline, "_LLM", _Stub(None))
|
||||
|
||||
|
||||
def _routed(field_ids, text, context=None):
|
||||
findings = [SimpleNamespace(field_id=fid) for fid in field_ids]
|
||||
return asyncio.run(pipeline.run_routed(findings, text, context or {}))
|
||||
|
||||
|
||||
def test_gate_termination_na_for_oneoff_shop():
|
||||
text = "Widerrufsbelehrung: Sie koennen binnen 14 Tagen widerrufen. " * 5
|
||||
kept, resolved, gated = _routed(["termination", "termination_form"], text)
|
||||
assert set(gated) == {"termination", "termination_form"}
|
||||
assert kept == []
|
||||
|
||||
|
||||
def test_reference_rescues_data_protection():
|
||||
text = "Einzelheiten zur Verarbeitung in unserer Datenschutzerklaerung. " * 5
|
||||
kept, resolved, gated = _routed(["data_protection"], text)
|
||||
assert "data_protection" in resolved and kept == []
|
||||
|
||||
|
||||
def test_embedding_rescue_resolves(monkeypatch):
|
||||
monkeypatch.setattr(pipeline, "_EMB", _Stub(True))
|
||||
kept, resolved, gated = _routed(["scope"], "x" * 200)
|
||||
assert "scope" in resolved
|
||||
|
||||
|
||||
def test_llm_skipped_keeps_finding():
|
||||
kept, resolved, gated = _routed(["delivery_timeframe"], "x" * 200, {"skip_llm": True})
|
||||
assert [f.field_id for f in kept] == ["delivery_timeframe"] and resolved == []
|
||||
|
||||
|
||||
def test_evaluate_retiers_low_out_of_findings():
|
||||
text = ("Allgemeine Geschaeftsbedingungen. Vertragsschluss durch Bestellung. "
|
||||
"Haftung beschraenkt. Gerichtsstand Muenchen. ") * 6
|
||||
out = asyncio.run(AGBAgent().evaluate(AgentInput(doc_type="agb", text=text)))
|
||||
assert out.agent == "agb" and out.agent_version == "2.0"
|
||||
assert all(f.severity in ("HIGH", "MEDIUM") for f in out.findings)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""AGB muss im LIVE-Pfad verdrahtet sein (_TOPIC_AGENTS), nicht nur per Snapshot."""
|
||||
from compliance.api.agent_check._agent_outputs import _TOPIC_AGENTS
|
||||
|
||||
|
||||
def test_agb_wired_into_live_topic_agents():
|
||||
assert _TOPIC_AGENTS.get("agb") == "agb"
|
||||
|
||||
|
||||
def test_dse_wired_into_live_topic_agents():
|
||||
assert _TOPIC_AGENTS.get("dse") == "dse"
|
||||
|
||||
|
||||
def test_impressum_still_wired():
|
||||
assert _TOPIC_AGENTS.get("impressum") == "impressum"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Unit-Tests der Prüfer-Library. Embedding + LLM gemockt → kein Netzwerk."""
|
||||
import asyncio
|
||||
|
||||
import compliance.services.llm_cascade as cascade_mod
|
||||
import compliance.services.mc_embedding_matcher as emb_mod
|
||||
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
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_reference_present_and_absent():
|
||||
rc = ReferenceChecker()
|
||||
spec = ControlSpec("data_protection", VerificationMethod.REFERENCE,
|
||||
DecisionMethod.LINK_RESOLVER,
|
||||
patterns=[r"datenschutz(erkl|bestimmung|hinweis)"])
|
||||
r = _run(rc.check(spec, DocContext(
|
||||
text="Details in unserer Datenschutzerklaerung: https://x.de/datenschutz")))
|
||||
assert r.present is True
|
||||
assert r.detail.get("link", "").startswith("https://")
|
||||
r2 = _run(rc.check(spec, DocContext(text="Keine Angabe zum Datenschutz-Thema.")))
|
||||
assert r2.present is False
|
||||
|
||||
|
||||
def test_embedding_threshold(monkeypatch):
|
||||
monkeypatch.setattr(emb_mod, "DIM", 3, raising=False)
|
||||
monkeypatch.setattr(emb_mod, "_chunk_text", lambda t: [t], raising=False)
|
||||
|
||||
async def _embed(texts):
|
||||
return [[1.0, 0.0, 0.0] for _ in texts]
|
||||
|
||||
monkeypatch.setattr(emb_mod, "_embed_texts", _embed, raising=False)
|
||||
ec = EmbeddingChecker()
|
||||
spec = ControlSpec("scope_t", VerificationMethod.CONTENT, DecisionMethod.EMBEDDING,
|
||||
paraphrases=["Geltungsbereich"], embed_threshold=0.58)
|
||||
monkeypatch.setattr(emb_mod, "_cosine", lambda a, b: 0.90, raising=False)
|
||||
r = _run(ec.check(spec, DocContext(text="x" * 200)))
|
||||
assert r.present is True and r.confidence >= 0.58
|
||||
monkeypatch.setattr(emb_mod, "_cosine", lambda a, b: 0.20, raising=False)
|
||||
r2 = _run(ec.check(spec, DocContext(text="x" * 200)))
|
||||
assert r2.present is False
|
||||
|
||||
|
||||
def test_embedding_offline_returns_none(monkeypatch):
|
||||
async def _boom(texts):
|
||||
raise ConnectionError("embedding-service down")
|
||||
|
||||
monkeypatch.setattr(emb_mod, "_embed_texts", _boom, raising=False)
|
||||
ec = EmbeddingChecker()
|
||||
spec = ControlSpec("scope_off", VerificationMethod.CONTENT, DecisionMethod.EMBEDDING,
|
||||
paraphrases=["x"], embed_threshold=0.6)
|
||||
r = _run(ec.check(spec, DocContext(text="y" * 200)))
|
||||
assert r.present is None # fail-safe
|
||||
|
||||
|
||||
def test_llm_present_and_absent(monkeypatch):
|
||||
lc = LLMChecker()
|
||||
spec = ControlSpec("delivery_timeframe", VerificationMethod.CONTENT, DecisionMethod.LLM,
|
||||
topic_regex=r"liefer", question="Konkrete Lieferfrist?")
|
||||
doc = DocContext(text=("1. Lieferung\nDie Ware wird innerhalb von 2 Werktagen "
|
||||
"geliefert.\n") * 4)
|
||||
|
||||
async def _erfuellt(system, user, **kw):
|
||||
return {"text": '{"verdict":"ERFUELLT","zitat":"2 Werktagen","begruendung":"x"}',
|
||||
"source": "qwen", "confidence": 0.7}
|
||||
|
||||
monkeypatch.setattr(cascade_mod, "call_with_cascade", _erfuellt, raising=False)
|
||||
assert _run(lc.check(spec, doc)).present is True
|
||||
|
||||
async def _fehlt(system, user, **kw):
|
||||
return {"text": '{"verdict":"FEHLT"}', "source": "qwen"}
|
||||
|
||||
monkeypatch.setattr(cascade_mod, "call_with_cascade", _fehlt, raising=False)
|
||||
assert _run(lc.check(spec, doc)).present is False
|
||||
@@ -1,65 +1,153 @@
|
||||
"""DSEAgent — kuratierte Art-13/14-Checkliste (kein Library-Firehose)."""
|
||||
"""DSE-Agent v3 — DB-Controls (doc_check_controls) via run_v3_pipeline +
|
||||
kuratierter Art-13-Regex-Boost (Layer 0). Volle Parität zu impressum/cookie.
|
||||
|
||||
Die Tests prüfen die deterministischen Bausteine (regex_boost/mcs) ohne DB und
|
||||
den Agent-Pfad mit gemocktem run_v3_pipeline (CI hat keine DB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import compliance.services.specialist_agents.dse.agent as dse_agent
|
||||
from compliance.services.specialist_agents import REGISTRY, AgentInput
|
||||
from compliance.services.specialist_agents.dse.mcs import MCS, MC_IDS
|
||||
from compliance.services.specialist_agents.dse.regex_boost import (
|
||||
boost_matches_db_mc,
|
||||
compute_regex_boosts,
|
||||
criteria_on_topic,
|
||||
)
|
||||
|
||||
_DSE_SAMPLE = (
|
||||
"Datenschutzerklaerung. Verantwortlich im Sinne der DSGVO ist die Muster "
|
||||
"GmbH, Musterstrasse 1, 12345 Berlin. E-Mail: info@muster.de. "
|
||||
"Datenschutzbeauftragter: dsb@muster.de. Zwecke der Verarbeitung und "
|
||||
"Rechtsgrundlage Art. 6 Abs. 1 lit. f berechtigtes Interesse. Empfaenger "
|
||||
"Ihrer Daten sind Auftragsverarbeiter. Speicherdauer der Daten richtet "
|
||||
"sich nach Aufbewahrungsfristen. Sie haben das Recht auf Auskunft, das "
|
||||
"Recht auf Berichtigung, das Recht auf Loeschung sowie ein "
|
||||
"Widerspruchsrecht. Beschwerde bei der Aufsichtsbehoerde moeglich. Stand: "
|
||||
"Januar 2026. ") * 3
|
||||
|
||||
|
||||
def _run(text: str):
|
||||
return asyncio.run(
|
||||
REGISTRY.get("dse").evaluate(AgentInput(doc_type="dse", text=text)))
|
||||
|
||||
|
||||
# ── Registrierung ────────────────────────────────────────────────────────
|
||||
def test_dse_agent_registered():
|
||||
assert REGISTRY.get("dse") is not None
|
||||
agent = REGISTRY.get("dse")
|
||||
assert agent is not None
|
||||
assert agent.agent_version == "3.0"
|
||||
assert agent.doc_type == "dse"
|
||||
|
||||
|
||||
def test_dse_detects_core_obligations():
|
||||
text = (
|
||||
"Datenschutzerklaerung. Verantwortlich im Sinne der DSGVO ist die "
|
||||
"Muster GmbH, Musterstrasse 1, 12345 Berlin. E-Mail: info@muster.de. "
|
||||
"Datenschutzbeauftragter: dsb@muster.de. Zwecke der Verarbeitung und "
|
||||
"Rechtsgrundlage Art. 6 Abs. 1. Empfaenger Ihrer Daten. Speicherdauer "
|
||||
"der Daten. Ihre Rechte: Auskunft, Loeschung, Widerspruch, Beschwerde "
|
||||
"bei der Aufsichtsbehoerde. ") * 3
|
||||
out = _run(text)
|
||||
assert out.agent == "dse"
|
||||
# 10 L1-Pflichtangaben immer + L2-Details deren Parent vorhanden ist
|
||||
# (fehlende Parents → L2 übersprungen, kein 'na'-Rauschen).
|
||||
assert 10 <= out.mc_total <= 33
|
||||
ok = [c.label for c in out.mc_coverage if c.status == "ok"]
|
||||
assert any("Verantwortlich" in lbl for lbl in ok)
|
||||
assert any("Rechtsgrundlage" in lbl for lbl in ok)
|
||||
def test_owned_mc_ids_match_checklist():
|
||||
# owned_mc_ids = die Boost-Pattern-IDs (aus ART13_CHECKLIST gehoben).
|
||||
assert MC_IDS == tuple(m.mc_id for m in MCS)
|
||||
assert len(MC_IDS) >= 10 # mind. die 10 L1-Pflichtfelder + L2
|
||||
|
||||
|
||||
def test_dse_missing_obligations_are_findings():
|
||||
out = _run("Lorem ipsum dolor sit amet consectetur adipiscing elit. " * 6)
|
||||
assert out.findings
|
||||
assert any(f.severity == "HIGH" for f in out.findings)
|
||||
# ── Layer-0 Regex-Boost (deterministisch, ohne DB) ───────────────────────
|
||||
def test_regex_boost_detects_core_fields():
|
||||
boosts = compute_regex_boosts(_DSE_SAMPLE)
|
||||
# Die zentralen Art-13-Felder müssen erkannt werden.
|
||||
for field in ("controller", "legal_basis", "rights", "complaint",
|
||||
"retention", "dse_version_date"):
|
||||
assert field in boosts, f"{field} nicht erkannt: {sorted(boosts)}"
|
||||
|
||||
|
||||
def test_regex_boost_empty_on_short_text():
|
||||
assert compute_regex_boosts("zu kurz") == set()
|
||||
|
||||
|
||||
def test_criteria_on_topic_accepts_dse_rejects_foreign():
|
||||
dse_crit = ["Rechtsgrundlage gemäß Art. 6 DSGVO benannt",
|
||||
"Speicherdauer und Löschfrist angegeben"]
|
||||
assert criteria_on_topic(dse_crit) is True
|
||||
foreign = ["Bestellbestätigung wird per E-Mail versendet",
|
||||
"Versandkosten werden im Warenkorb angezeigt"]
|
||||
assert criteria_on_topic(foreign) is False
|
||||
# leere Kriterien → konservativ on-topic behalten
|
||||
assert criteria_on_topic([]) is True
|
||||
|
||||
|
||||
def test_boost_matches_db_mc_third_country():
|
||||
boosts = {"third_country", "controller"}
|
||||
crit = ["Standardvertragsklauseln für Drittland benannt",
|
||||
"Geeignete Garantien bei Übermittlung in ein Drittland"]
|
||||
assert boost_matches_db_mc(boosts, crit) == "third_country"
|
||||
# ohne passende Boosts → None
|
||||
assert boost_matches_db_mc(set(), crit) is None
|
||||
|
||||
|
||||
# ── Agent-Pfad mit gemocktem run_v3_pipeline ─────────────────────────────
|
||||
def _mock_v3(results, telemetry=None):
|
||||
async def _fake(text, scope, db_url="", skip_embedding=False):
|
||||
return results, (telemetry or {
|
||||
"total_mcs": len(results), "layer_0_field_hits": 0,
|
||||
"layer_0_field_ids": [], "layer_0_boost_overrides": 0,
|
||||
"sector_dropped": 0, "offtopic_dropped": 0})
|
||||
return _fake
|
||||
|
||||
|
||||
def _run(text, context=None):
|
||||
return asyncio.run(REGISTRY.get("dse").evaluate(
|
||||
AgentInput(doc_type="dse", text=text, context=context or {})))
|
||||
|
||||
|
||||
def test_dse_short_text_skips():
|
||||
out = _run("zu kurz")
|
||||
assert out.confidence == 0.0
|
||||
assert all(c.status == "skipped" for c in out.mc_coverage)
|
||||
assert out.mc_coverage and all(
|
||||
c.status == "skipped" for c in out.mc_coverage)
|
||||
|
||||
|
||||
def test_third_country_high_when_applicable_no_na_detail_short_action():
|
||||
# Text ohne Drittland-Abschnitt + Scan-Kontext drittland=ja:
|
||||
# - third_country (L1) fehlt → HIGH (nicht weiches MEDIUM)
|
||||
# - Transfermechanismus (L2) → KEIN 'na' (übersprungen, Parent deckt ab)
|
||||
# - Titel/Maßnahme kurz (kein 280-Zeichen-Hint als Recommendation-Titel)
|
||||
text = ("Datenschutz. Verantwortlich ist die Muster GmbH, info@muster.de. "
|
||||
"Zwecke und Rechtsgrundlage Art. 6. Speicherdauer. Ihre Rechte. ") * 4
|
||||
out = asyncio.run(REGISTRY.get("dse").evaluate(AgentInput(
|
||||
doc_type="dse", text=text,
|
||||
context={"scan_context": {"third_country_transfer": "yes"}})))
|
||||
tc = [f for f in out.findings if "Drittland" in f.title]
|
||||
assert tc and tc[0].severity == "HIGH"
|
||||
assert not any(c.status == "na" and "Transfermechanismus" in c.label
|
||||
for c in out.mc_coverage)
|
||||
assert all(len(f.action) < 110 for f in out.findings)
|
||||
# Detail-Begründung bleibt als evidence erhalten
|
||||
assert any(f.evidence for f in out.findings)
|
||||
def test_dse_findings_from_failed_db_mc(monkeypatch):
|
||||
results = [{
|
||||
"control_id": "DATA-525-A17", "passed": False, "severity": "HIGH",
|
||||
"label": "Berechtigte Interessen ausweisen", "regulation": None,
|
||||
"article": None, "_pass_criteria": ["berechtigtes interesse benannt"],
|
||||
"matched_text": "", "source": "keyword_match",
|
||||
}, {
|
||||
"control_id": "AUTH-2051-A11", "passed": True, "severity": "LOW",
|
||||
"label": "Prägnante Form", "regulation": None, "article": None,
|
||||
"_pass_criteria": [], "matched_text": "ok",
|
||||
}]
|
||||
monkeypatch.setattr(dse_agent, "run_v3_pipeline", _mock_v3(results))
|
||||
out = _run(_DSE_SAMPLE, context={"skip_llm": True})
|
||||
fids = {f.field_id for f in out.findings}
|
||||
assert "DATA-525-A17" in fids # failed → Finding
|
||||
assert "AUTH-2051-A11" not in fids # passed → kein Finding
|
||||
f = next(f for f in out.findings if f.field_id == "DATA-525-A17")
|
||||
assert f.severity == "HIGH"
|
||||
assert f.norm == "DSGVO Art. 13/14" # NULL-regulation → Fallback-Norm
|
||||
assert len(f.action) < 410
|
||||
|
||||
|
||||
def test_dse_third_country_override_to_high(monkeypatch):
|
||||
# MEDIUM-Drittland-MC → HIGH bei dokumentiertem Transfer (scan_context).
|
||||
results = [{
|
||||
"control_id": "DATA-900-A01", "passed": False, "severity": "MEDIUM",
|
||||
"label": "Drittlandtransfer Schutzgarantien benennen",
|
||||
"regulation": None, "article": None,
|
||||
"_pass_criteria": ["standardvertragsklauseln", "drittland garantien"],
|
||||
"matched_text": "", "source": "keyword_match",
|
||||
}]
|
||||
monkeypatch.setattr(dse_agent, "run_v3_pipeline", _mock_v3(results))
|
||||
out = _run(_DSE_SAMPLE, context={
|
||||
"skip_llm": True,
|
||||
"scan_context": {"third_country_transfer": "yes"}})
|
||||
f = next(f for f in out.findings if f.field_id == "DATA-900-A01")
|
||||
assert f.severity == "HIGH"
|
||||
assert f.severity_reason == "db_mc_failed_third_country_transfer"
|
||||
|
||||
|
||||
def test_dse_no_transfer_keeps_medium(monkeypatch):
|
||||
results = [{
|
||||
"control_id": "DATA-900-A01", "passed": False, "severity": "MEDIUM",
|
||||
"label": "Drittlandtransfer Schutzgarantien benennen",
|
||||
"regulation": None, "article": None,
|
||||
"_pass_criteria": ["standardvertragsklauseln", "drittland garantien"],
|
||||
"matched_text": "", "source": "keyword_match",
|
||||
}]
|
||||
monkeypatch.setattr(dse_agent, "run_v3_pipeline", _mock_v3(results))
|
||||
out = _run(_DSE_SAMPLE, context={"skip_llm": True})
|
||||
f = next(f for f in out.findings if f.field_id == "DATA-900-A01")
|
||||
assert f.severity == "MEDIUM"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests fuer das DSE-Applicability-Gate (_classification_gate).
|
||||
|
||||
Deckt die reine Split-Logik (apply_gate) und das defensive Verhalten von
|
||||
load_dse_gate ohne DB ab. Die DB-Abfrage selbst ist I/O und wird hier nicht
|
||||
gegen eine echte DB getestet (defensiver Pfad: kein DSN -> leeres Dict)."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from compliance.services.specialist_agents.dse._classification_gate import (
|
||||
apply_gate,
|
||||
load_dse_gate,
|
||||
)
|
||||
|
||||
|
||||
def test_apply_gate_splits_findings_and_organizational():
|
||||
controls = [
|
||||
{"control_id": "AUTH-2051-A02", "title": "Speicherdauer nennen"},
|
||||
{"control_id": "AUTH-2049-A01", "title": "VVT fuehren"},
|
||||
]
|
||||
gate = {
|
||||
"AUTH-2049-A01": {
|
||||
"obligation_type": "EVIDENCE",
|
||||
"check_intent": "DIRECT_EVIDENCE",
|
||||
"applicable_artifacts": ["VVT", "AUDIT"],
|
||||
"reference_allowed": "NO",
|
||||
}
|
||||
}
|
||||
kept, organizational = apply_gate(controls, gate)
|
||||
assert [c["control_id"] for c in kept] == ["AUTH-2051-A02"]
|
||||
assert len(organizational) == 1
|
||||
org = organizational[0]
|
||||
assert org["control_id"] == "AUTH-2049-A01"
|
||||
assert org["title"] == "VVT fuehren"
|
||||
assert org["applicable_artifacts"] == ["VVT", "AUDIT"]
|
||||
assert org["check_intent"] == "DIRECT_EVIDENCE"
|
||||
|
||||
|
||||
def test_apply_gate_empty_gate_keeps_all():
|
||||
controls = [{"control_id": "X-1"}, {"control_id": "X-2"}]
|
||||
kept, organizational = apply_gate(controls, {})
|
||||
assert len(kept) == 2
|
||||
assert organizational == []
|
||||
|
||||
|
||||
def test_load_dse_gate_without_dsn_is_defensive():
|
||||
"""Kein DSN + keine Env -> leeres Dict (kein Filter), kein Fehler."""
|
||||
saved = (
|
||||
os.environ.pop("DATABASE_URL", None),
|
||||
os.environ.pop("COMPLIANCE_DATABASE_URL", None),
|
||||
)
|
||||
try:
|
||||
result = asyncio.run(load_dse_gate(""))
|
||||
assert result == {}
|
||||
finally:
|
||||
if saved[0] is not None:
|
||||
os.environ["DATABASE_URL"] = saved[0]
|
||||
if saved[1] is not None:
|
||||
os.environ["COMPLIANCE_DATABASE_URL"] = saved[1]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""DSE Embedding-Recall — deterministische semantische Schicht (gecacht).
|
||||
|
||||
Testet die reine Logik OHNE Embedding-Service: Cache-Treffer-Pfad,
|
||||
Schwellen-Filter, Kandidaten-Schnitt, Reachability-Guard. Das Einbetten selbst
|
||||
(Embedding-Service) ist Integration und wird auf macmini/Prod validiert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import compliance.services.specialist_agents.dse._embedding_recall as er
|
||||
|
||||
|
||||
_TEXT = ("Datenschutzerklaerung der Muster GmbH. " * 20) # > 100 Zeichen
|
||||
|
||||
|
||||
def _seed_cache(tmp_path, scores: dict[str, float]) -> str:
|
||||
p = tmp_path / "dse_embed_cache.json"
|
||||
p.write_text(json.dumps({er._doc_hash(_TEXT): scores}))
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_doc_hash_deterministic():
|
||||
# feste Funktion: gleicher Text → gleicher Hash (Reproduzierbarkeit)
|
||||
assert er._doc_hash(_TEXT) == er._doc_hash(_TEXT)
|
||||
assert er._doc_hash("a") != er._doc_hash("b")
|
||||
|
||||
|
||||
def test_cache_hit_threshold_filter(tmp_path, monkeypatch):
|
||||
# Cache-Treffer: kein Embedding-Service nötig. Nur Scores >= Schwelle UND
|
||||
# in den Kandidaten werden zurückgegeben.
|
||||
scores = {"DATA-1": 0.71, "DATA-2": 0.60, "AUTH-3": 0.68, "SEC-4": 0.50}
|
||||
monkeypatch.setenv("DSE_EMBED_CACHE", _seed_cache(tmp_path, scores))
|
||||
monkeypatch.setattr(er, "_CACHE_PATH", str(tmp_path / "dse_embed_cache.json"))
|
||||
|
||||
cands = ["DATA-1", "DATA-2", "AUTH-3", "SEC-4"]
|
||||
out = asyncio.run(er.embedding_recall(_TEXT, cands, threshold=0.65))
|
||||
# >=0.65: DATA-1 (0.71), AUTH-3 (0.68). NICHT DATA-2 (0.60), SEC-4 (0.50).
|
||||
assert out == {"DATA-1", "AUTH-3"}
|
||||
|
||||
|
||||
def test_cache_hit_candidate_intersection(tmp_path, monkeypatch):
|
||||
# Nur Kandidaten (durchgefallene Controls) zählen — andere ignoriert.
|
||||
scores = {"DATA-1": 0.90, "DATA-2": 0.90}
|
||||
monkeypatch.setattr(er, "_CACHE_PATH", str(tmp_path / "c.json"))
|
||||
(tmp_path / "c.json").write_text(json.dumps({er._doc_hash(_TEXT): scores}))
|
||||
out = asyncio.run(er.embedding_recall(_TEXT, ["DATA-1"], threshold=0.65))
|
||||
assert out == {"DATA-1"} # DATA-2 nicht in Kandidaten
|
||||
|
||||
|
||||
def test_empty_inputs():
|
||||
assert asyncio.run(er.embedding_recall("zu kurz", ["X"])) == set()
|
||||
assert asyncio.run(er.embedding_recall(_TEXT, [])) == set()
|
||||
|
||||
|
||||
def test_service_down_returns_empty(tmp_path, monkeypatch):
|
||||
# Kein Cache + Service nicht erreichbar → leer (deterministischer Layer trägt),
|
||||
# KEIN Hang.
|
||||
monkeypatch.setattr(er, "_CACHE_PATH", str(tmp_path / "none.json"))
|
||||
|
||||
async def _unreachable(timeout=2.0):
|
||||
return False
|
||||
monkeypatch.setattr(er, "_embedding_reachable", _unreachable)
|
||||
out = asyncio.run(er.embedding_recall(_TEXT, ["DATA-1"]))
|
||||
assert out == set()
|
||||
Reference in New Issue
Block a user