feat(agents): Specialist-Agents Phase 2 Foundation + Cookie-Policy-Agent

Sprint 1 — Foundation (User-Vorgabe 2026-06-08):

Foundation:
- _base.py: BaseSpecialistAgent ABC + Pydantic Contract
  (AgentInput/AgentOutput/Finding/Recommendation/McCoverage/EscalationLog).
- _base.lint_output(): Disclaimer-Linter verbietet "rechtssicher" /
  "garantiert" / "gesetzeskonform" — scrubbed inline + Log in notes.
- _registry.py: AgentRegistry mit MC-Owner-Mapping (verhindert
  Doppel-Ownership).
- _escalation.py: cascade(local → ovh). qwen2.5:7b default,
  OVH 120b als Stage-2 (deaktiviert wenn OVH_URL leer).
- _rollup.py: deterministisches Dedup ähnlicher actions zu
  Recommendations mit related_finding_ids[].
- _evidence_vault.py: Pro-Run File-Vault für Playwright-Videos,
  Screenshots, CSV. SHA256 + manifest.json. DSR-tauglich (delete_run).

Agenten:
- ImpressumAgent v2 (impressum/agent.py + mcs.py) — konsolidiert
  v1-Pattern-Match + v2-LLM-MVP unter dem neuen Contract. 12 MCs.
- CookiePolicyAgent v1 (cookie_policy/agent.py + mcs.py) — 12 MCs
  zu Cookie-Richtlinie-Vollständigkeit + KB-Layer für
  CMP-Vendor-Cross-Check.

Tests: 25/25 grün (10 Impressum + 9 Vault + 6 Cookie-Policy).

Roadmap: SSE-Test-Endpoint + Frontend-Tab → DSE/AGB-Agents →
Cookie-Banner-Themen-Agent → Cross-Doc-Konsistenz-Agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-08 17:40:05 +02:00
parent d6b8bf87c2
commit f4357a2e9b
15 changed files with 2364 additions and 10 deletions
@@ -0,0 +1,8 @@
"""Impressum-Specialist-Agent v2 — konsolidiert Pattern (v1) + LLM (v2-mvp).
Public Entry-Point: ImpressumAgent (inherits BaseSpecialistAgent).
"""
from .agent import ImpressumAgent
__all__ = ["ImpressumAgent"]
@@ -0,0 +1,251 @@
"""Impressum-Agent v2 — konsolidierter BaseSpecialistAgent.
Ablauf:
1. Deterministische MCs durchlaufen → Findings + mc_coverage.
2. Wenn unklare Felder (HIGH/MEDIUM missing) → LLM-Eskalation.
3. LLM-Findings dedupen mit MC-Findings nach field_id.
4. Rollup → Recommendations.
5. Disclaimer-Lint → AgentOutput.
Phase 1 (jetzt): qwen2.5:7b als Stage-1, OVH optional als Stage-2.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from .._base import (
AgentInput,
AgentOutput,
BaseSpecialistAgent,
EscalationLog,
EvidenceSource,
Finding,
McCoverage,
Severity,
SourceType,
lint_output,
)
from .._escalation import cascade
from .._rollup import rollup
from .mcs import MC_IDS, MCS, detect_automotive, scope_matches
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """Du bist ein deutscher Datenschutz-Anwalt mit Fokus
§ 5 TMG / DDG (Anbieterkennzeichnung). Aufgabe: Impressum prüfen und
LÜCKEN aufzählen, die einer regex-basierten Vorprüfung entgangen sind.
WICHTIG:
- KEINE Bewertung "rechtssicher" / "garantiert" / "konform".
- Wenn unsicher: leeres Array zurückgeben statt zu halluzinieren.
- Wörtliches Zitat als evidence bei jeder Lücke.
Antworte NUR mit JSON, Schema:
{"findings": [
{"field_id": "...", "severity": "HIGH|MEDIUM|LOW",
"title": "...", "evidence": "wörtliches Zitat",
"action": "konkrete Empfehlung"}
]}
"""
class ImpressumAgent(BaseSpecialistAgent):
agent_id = "impressum"
agent_version = "2.0"
doc_type = "impressum"
owned_mc_ids = MC_IDS
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 [])
# Auto-detect KFZ
is_automotive = detect_automotive(text)
if is_automotive:
scope.add("automotive")
mc_findings: list[Finding] = []
coverage: list[McCoverage] = []
if len(text) < 50:
# Doc zu kurz → alle als skipped
for mc in MCS:
coverage.append(McCoverage(
mc_id=mc.mc_id, status="skipped",
reason="doc too short or empty",
))
return self._finalize(
start, mc_findings, [], coverage, confidence=0.0,
notes="Impressum-Text zu kurz oder leer.",
)
for mc in MCS:
if not scope_matches(mc, scope, is_automotive):
coverage.append(McCoverage(
mc_id=mc.mc_id, status="na",
reason=f"scope mismatch (needs {mc.requires_scope})",
))
continue
found = any(p.search(text) for p in mc.patterns)
if found:
coverage.append(McCoverage(
mc_id=mc.mc_id, status="ok",
))
continue
# Missing → Finding
sev = self._sev(mc.severity_if_missing)
action = self._build_action(mc, is_automotive)
mc_findings.append(Finding(
check_id=f"IMPRESSUM-AGENT-{mc.field_id.upper()}",
agent=self.agent_id,
agent_version=self.agent_version,
field_id=mc.field_id,
severity=sev,
severity_reason="missing",
title=f"Pflichtangabe '{mc.label}' fehlt im Impressum",
norm=mc.norm,
evidence="",
action=action,
confidence=0.95,
sources=[EvidenceSource(
source_type=SourceType.MC,
source_id=mc.mc_id,
detail=f"regex check {len(mc.patterns)} pattern(s) negative",
)],
))
coverage.append(McCoverage(
mc_id=mc.mc_id,
status=sev.value.lower(),
reason="missing",
))
# Eskalation: für die identifizierten Lücken kann ein LLM
# zusätzliche Tiefen-Findings liefern (z.B. "Geschäftsführer
# genannt, aber ohne Nachname"). Confidence der MC-Findings
# ist hoch — eskalieren wir wegen "weitere subtile Lücken
# finden", nicht weil wir unsicher sind.
esc_findings, esc_logs = await self._maybe_escalate(text, scope)
# Dedup per field_id (MC hat Priorität)
seen_fields = {f.field_id for f in mc_findings if f.field_id}
for f in esc_findings:
if f.field_id and f.field_id in seen_fields:
continue
mc_findings.append(f)
# Confidence: harmonisches Mittel über alle Finding-Confidences
if mc_findings:
confs = [f.confidence for f in mc_findings if f.confidence]
overall = sum(confs) / len(confs) if confs else 0.8
else:
overall = 0.95 # nichts gefunden → alle MCs ok
return self._finalize(
start, mc_findings, esc_logs, coverage, confidence=overall,
)
async def _maybe_escalate(
self, text: str, scope: set[str],
) -> tuple[list[Finding], list[EscalationLog]]:
"""LLM-Stage. Aktivierbar via Agent-Settings (default an)."""
# Hard cap auf Text-Größe für den LLM-Pass
user_prompt = (
f"BUSINESS-SCOPE: {', '.join(sorted(scope))}\n\n"
f"IMPRESSUM-TEXT:\n{text[:4000]}\n\n"
"Liste subtile Lücken nach § 5 TMG. Nur JSON."
)
res, logs = await cascade(_SYSTEM_PROMPT, user_prompt)
if res is None or not isinstance(res.parsed, (dict, list)):
return [], logs
raw = (res.parsed.get("findings")
if isinstance(res.parsed, dict) else res.parsed)
if not isinstance(raw, list):
return [], logs
out: list[Finding] = []
for item in raw:
if not isinstance(item, dict):
continue
fid = str(item.get("field_id") or "unknown")[:40]
sev_raw = str(item.get("severity") or "MEDIUM").upper()
sev = self._sev(sev_raw)
out.append(Finding(
check_id=f"IMPRESSUM-AGENT-LLM-{fid.upper()}",
agent=self.agent_id,
agent_version=self.agent_version,
field_id=fid,
severity=sev,
severity_reason="llm_detected",
title=str(item.get("title") or "")[:200],
norm="§ 5 TMG / DDG (LLM-Analyse)",
evidence=str(item.get("evidence") or "")[:300],
action=str(item.get("action") or "")[:400],
confidence=0.7,
sources=[EvidenceSource(
source_type=res.stage,
source_id=res.model,
detail=f"prompt_chars={len(user_prompt)}",
confidence=0.7,
)],
))
return out, logs
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)
@staticmethod
def _sev(value: str) -> Severity:
v = (value or "").upper()
if v == "HIGH":
return Severity.HIGH
if v == "MEDIUM":
return Severity.MEDIUM
if v == "LOW":
return Severity.LOW
return Severity.INFO
@staticmethod
def _build_action(mc, is_automotive: bool) -> str:
if mc.field_id == "aufsichtsbehoerde" and is_automotive:
return (
"Aufsichtsbehörde im Impressum benennen. Für "
"KFZ-Hersteller/-Vertrieb typisch: Kraftfahrt-"
"Bundesamt (KBA), Fördestraße 16, 24944 Flensburg, "
"www.kba.de. Bei Ladestrom-Vertrieb zusätzlich "
"Bundesnetzagentur (BNetzA)."
)
return (
f"{mc.label} im Impressum ergänzen "
f"(Pflichtangabe nach {mc.norm})."
)
@@ -0,0 +1,234 @@
"""Machine-Check-Definitionen für den Impressum-Agent.
Eine MC = ein abgegrenzter, deterministischer Check über das
Impressum-Dokument. Owner = impressum-agent.
Quelle: § 5 TMG / DDG + § 18 MStV + § 36 VSBG + Art. 14 EU-VO 524/2013.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Pattern
@dataclass(frozen=True)
class MC:
"""Eine Machine-Check-Definition."""
mc_id: str # IMP-MC-001 ...
field_id: str # name_anbieter, handelsregister, ...
label: str
norm: str
patterns: tuple[Pattern[str], ...] = field(default_factory=tuple)
severity_if_missing: str = "MEDIUM" # HIGH | MEDIUM | LOW | INFO
requires_scope: tuple[str, ...] = field(default_factory=tuple)
# Wenn True: bei Scope-Mismatch nicht-applicable melden, sonst skip
explicit_na: bool = True
MCS: tuple[MC, ...] = (
MC(
mc_id="IMP-MC-001",
field_id="name_anbieter",
label="Name + Anschrift des Anbieters",
norm="§ 5 Abs. 1 Nr. 1 TMG",
severity_if_missing="HIGH",
patterns=(
re.compile(
r"\b(?:Anbieter|Diensteanbieter|"
r"Verantwortlich(?:er Anbieter)?)\s*[:.\s]",
re.IGNORECASE,
),
# Label-free fallback: Firma (Rechtsform) + Adresse
re.compile(
r"\b[A-ZÄÖÜ][\w\-\& ]{1,80}?\s+"
r"(?:GmbH|AG|UG|KG|SE|GbR|OHG|Limited|Ltd|LLC)\s*"
r"[\s\S]{0,400}?"
r"\b\d{5}\s+[A-ZÄÖÜ]",
re.IGNORECASE,
),
),
),
MC(
mc_id="IMP-MC-002",
field_id="kontakt_email",
label="Email-Adresse",
norm="§ 5 Abs. 1 Nr. 2 TMG",
severity_if_missing="HIGH",
patterns=(re.compile(r"\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b",
re.IGNORECASE),),
),
MC(
mc_id="IMP-MC-003",
field_id="kontakt_telefon",
label="Telefon",
norm="§ 5 Abs. 1 Nr. 2 TMG",
severity_if_missing="MEDIUM",
patterns=(re.compile(
r"(?:Tel(?:efon)?|Phone)\.?\s*[:.\s]\s*[\+\d][\d\s/\-()]{5,}",
re.IGNORECASE,
),),
),
MC(
mc_id="IMP-MC-004",
field_id="handelsregister",
label="Handelsregister-Eintrag",
norm="§ 5 Abs. 1 Nr. 4 TMG",
severity_if_missing="HIGH",
patterns=(
re.compile(r"\bHR[BA]\s+\d", re.IGNORECASE),
re.compile(r"Handelsregister", re.IGNORECASE),
),
),
MC(
mc_id="IMP-MC-005",
field_id="ust_id",
label="USt-IdNr",
norm="§ 5 Abs. 1 Nr. 6 TMG",
severity_if_missing="MEDIUM",
patterns=(
re.compile(
r"\b(?:USt-?Id(?:Nr)?\.?|VAT(?:-?Id)?)\s*[:.\s]",
re.IGNORECASE,
),
re.compile(r"\bDE\d{9}\b"),
),
),
MC(
mc_id="IMP-MC-006",
field_id="vertretungsberechtigte",
label="Vertretungsberechtigte Person",
norm="§ 5 Abs. 1 Nr. 1 TMG (juristische Personen)",
severity_if_missing="HIGH",
patterns=(
re.compile(
r"(?:Gesch(?:ae|ä)ftsf(?:ue|ü)hrer|"
r"Vertretungsberechtigt|vertreten\s+durch)"
r"\s*[:.\s]",
re.IGNORECASE,
),
re.compile(r"\bManagement\s*[:.\s]\s*[A-ZÄÖÜ]",
re.IGNORECASE),
re.compile(r"\bDirector(?:s|en)?\s*[:.\s]\s*[A-ZÄÖÜ]",
re.IGNORECASE),
),
),
MC(
mc_id="IMP-MC-007",
field_id="vertretungsberechtigte_label_korrekt",
label="Deutsches Label 'Geschäftsführer' statt 'Management'",
norm="§ 5 Abs. 1 Nr. 1 TMG (Deutsch-Pflicht, gerichtsfest)",
severity_if_missing="MEDIUM",
patterns=(re.compile(
r"(?:Gesch(?:ae|ä)ftsf(?:ue|ü)hrer|"
r"Vorstand|"
r"Vertretungsberechtigt|vertreten\s+durch)"
r"\s*[:.\s]",
re.IGNORECASE,
),),
),
MC(
mc_id="IMP-MC-008",
field_id="aufsichtsbehoerde",
label="Aufsichtsbehörde (regulierte Branchen)",
norm="§ 5 Abs. 1 Nr. 3 TMG (Branchen-bedingt)",
severity_if_missing="LOW",
requires_scope=("regulated_profession", "financial_services",
"insurance", "automotive"),
patterns=(
re.compile(r"Aufsichtsbeh(?:ö|oe)rde\s*[:.\s]",
re.IGNORECASE),
re.compile(
r"\bBAFin\b|\bBNetzA\b|\bLKA\b|\bKBA\b|"
r"Kraftfahrt-?Bundesamt|Bundesnetzagentur|"
r"Bundesanstalt\s+f(?:ü|ue)r",
re.IGNORECASE,
),
),
),
MC(
mc_id="IMP-MC-009",
field_id="verantwortlicher_redaktion",
label="Verantwortlicher § 18 MStV (journalistisch-redaktionell)",
norm="§ 18 MStV (bei Blog/News/Magazin/Newsroom Pflicht)",
severity_if_missing="MEDIUM",
requires_scope=("editorial",),
patterns=(re.compile(
r"(?:Verantwortlich(?:er|e)?\s+(?:f(?:ue|ü)r|i\.S\.d\.|"
r"nach|gem(?:ae|ä)ß)\s+§\s*18|"
r"V\.i\.S\.d\.\s*§?\s*18|"
r"redaktionell\s+Verantwortlich)",
re.IGNORECASE,
),),
),
MC(
mc_id="IMP-MC-010",
field_id="verbraucher_streitbeilegung",
label="Verbraucher-Streitbeilegung-Hinweis",
norm="§ 36 VSBG (B2C-Anbieter Pflicht)",
severity_if_missing="MEDIUM",
requires_scope=("ecommerce", "b2c"),
patterns=(re.compile(
r"(?:Verbraucherschlichtungs|VSBG|"
r"Streitbeilegung|"
r"Schlichtungsstelle|"
r"alternative\s+Streit(?:beilegung|schlichtung))",
re.IGNORECASE,
),),
),
MC(
mc_id="IMP-MC-011",
field_id="berufsangaben",
label="Berufsbezeichnung + Berufsrechtliche Angaben",
norm="§ 5 Abs. 1 Nr. 5 TMG (Kammerberufe)",
severity_if_missing="LOW",
requires_scope=("regulated_profession",),
patterns=(re.compile(
r"Berufsbezeichnung|Berufsordnung|Kammer", re.IGNORECASE,
),),
),
MC(
mc_id="IMP-MC-012",
field_id="odr_link",
label="OS-Link auf EU-Plattform",
norm="Art. 14 EU-VO 524/2013 (B2C-Onlineshops)",
severity_if_missing="MEDIUM",
requires_scope=("ecommerce",),
patterns=(re.compile(r"ec\.europa\.eu/consumers/odr",
re.IGNORECASE),),
),
)
# Public list of all MC-IDs for the Registry
MC_IDS: tuple[str, ...] = tuple(m.mc_id for m in MCS)
def scope_matches(mc: MC, scope: set[str], is_automotive: bool) -> bool:
"""Entscheidet ob die MC auf den Business-Scope anwendbar ist."""
if not mc.requires_scope:
return True
if mc.field_id == "aufsichtsbehoerde" and is_automotive:
return True
return any(s in scope for s in mc.requires_scope)
def detect_automotive(text: str) -> bool:
"""KFZ-Hersteller/-Vertrieb → triggert KBA-Hint."""
if re.search(
r"\b(?:KFZ|Fahrzeug(?:e|herstellung|verkauf)?|Automobil|"
r"E-Auto|Elektroauto|Auto-?Konfigurator|"
r"Elektrofahrzeug|Hybrid-?Fahrzeug)\b",
text, re.IGNORECASE,
):
return True
return bool(re.search(
r"\b(?:Tesla|BMW|Mercedes-?Benz|Audi|Volkswagen|Porsche|"
r"Volvo|Stellantis|Skoda|Seat|Cupra|MINI|Smart|"
r"Opel|Ford\s+Deutschland|Hyundai|Kia|Toyota|Mazda|"
r"Nissan|Honda|Subaru|Lexus|Polestar|NIO|BYD|Rivian|"
r"Lucid)\s+(?:Germany|Deutschland|Group|Holding|AG|"
r"GmbH|S(?:E|\.A\.))\b",
text, re.IGNORECASE,
))