feat(dse): kuratierter DSEAgent + Snapshot-Tab (Art. 13/14, kein Firehose)

DSEAgent wrappt die existierende ART13_CHECKLIST (33 kuratierte Pflichtangaben
L1 + Detailchecks L2) → strukturierter AgentOutput, NICHT der 90k-Library-
Firehose (eCall/Gesundheit/Telekom-Lärm). GET /snapshots/{id}/dse-check spiegelt
impressum-check; doc_input_from_snapshot generalisiert. Frontend: generischer
AgentModuleTab (lazy → AgentResultTab) für Impressum + DSE; DSE-Tab in der
Snapshot-Seite. Plus HRB-Pattern \d→\d+ (volle Registernummer als Beleg).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-11 12:46:46 +02:00
parent be93859645
commit 76be96556d
10 changed files with 352 additions and 40 deletions
@@ -0,0 +1,34 @@
/**
* DSE-Analyse-Proxy
* GET /api/sdk/v1/agent/snapshots/{snapshotId}/dse-check
* → backend /api/compliance/agent/snapshots/{snapshotId}/dse-check
*
* Laeuft den kuratierten DSEAgent (Art. 13/14, ART13_CHECKLIST — kein
* Library-Firehose) auf dem gespeicherten DSE-Text (kein Re-Crawl).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL =
process.env.BACKEND_API_URL || process.env.BACKEND_URL ||
'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ snapshotId: string }> },
) {
const { snapshotId } = await params
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}/dse-check`,
{ signal: AbortSignal.timeout(120_000) },
)
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch {
return NextResponse.json(
{ error: 'DSE-Analyse fehlgeschlagen', findings: [] },
{ status: 503 },
)
}
}
@@ -0,0 +1,44 @@
'use client'
/**
* AgentModuleTab — generischer Snapshot-Modul-Tab für einen Doc-Type-Agenten
* (Impressum, DSE, …). Lädt `/snapshots/{id}/{docType}-check` beim Mounten
* (kein Re-Crawl) und rendert den AgentOutput im geteilten AgentResultTab.
* Wird nur gemountet, wenn der Tab aktiv ist → Analyse läuft on-demand.
*/
import React, { useEffect, useState } from 'react'
import { AgentResultTab } from './AgentResultTab'
export function AgentModuleTab(
{ snapshotId, docType, label }:
{ snapshotId: string; docType: string; label: string },
) {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/${docType}-check`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(() => {
if (!cancelled) setData({ error: `${label}-Analyse fehlgeschlagen`, findings: [] })
})
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [snapshotId, docType, label])
if (loading) return <div className="text-sm text-gray-500">{label}-Analyse läuft</div>
if (data?.error) return <div className="text-sm text-red-600">{data.error}</div>
if (data && ((data.findings?.length ?? 0) > 0 || (data.mc_coverage?.length ?? 0) > 0)) {
return <AgentResultTab topicLabel={label} output={data} />
}
return (
<div className="text-sm text-gray-500">
{data?.notes || `Keine ${label}-Auswertung verfügbar.`}
</div>
)
}
@@ -3,8 +3,9 @@
/** /**
* Snapshot-Detail — öffnet einen gespeicherten Check aus der Historie und * Snapshot-Detail — öffnet einen gespeicherten Check aus der Historie und
* zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl), als Modul-Tabs: * zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl), als Modul-Tabs:
* Cookies & Tracking + Impressum (DSE/AGB folgen). Impressum wird beim Öffnen * Cookies & Tracking + Impressum + Datenschutzerklärung (AGB folgen).
* des Tabs nachgeladen (ImpressumAgent auf dem gespeicherten Text). * Doc-Agenten (Impressum/DSE) laufen beim Öffnen des Tabs auf dem gespeicherten
* Text — generisch via AgentModuleTab.
*/ */
import React, { use as useUnwrap, useEffect, useMemo, useState } from 'react' import React, { use as useUnwrap, useEffect, useMemo, useState } from 'react'
@@ -12,7 +13,7 @@ import Link from 'next/link'
import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel' import { CookieLibraryPanel } from '../../_components/CookieLibraryPanel'
import { CookieResultView } from '../../_components/CookieResultView' import { CookieResultView } from '../../_components/CookieResultView'
import { AgentResultTab } from '../../_components/AgentResultTab' import { AgentModuleTab } from '../../_components/AgentModuleTab'
export default function SnapshotDetail( export default function SnapshotDetail(
{ params }: { params: Promise<{ snapshotId: string }> }, { params }: { params: Promise<{ snapshotId: string }> },
@@ -20,8 +21,6 @@ export default function SnapshotDetail(
const { snapshotId } = useUnwrap(params) const { snapshotId } = useUnwrap(params)
const [snap, setSnap] = useState<any>(null) const [snap, setSnap] = useState<any>(null)
const [check, setCheck] = useState<any>(null) // cookie-check const [check, setCheck] = useState<any>(null) // cookie-check
const [impressum, setImpressum] = useState<any>(null) // impressum-check (lazy)
const [impLoading, setImpLoading] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<string>('') const [tab, setTab] = useState<string>('')
@@ -51,29 +50,20 @@ export default function SnapshotDetail(
const docs = snap?.doc_entries || [] const docs = snap?.doc_entries || []
const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0 const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0
const hasImpressum = docs.some( const hasDoc = (dt: string) => docs.some(
(e: any) => e.doc_type === 'impressum' && (e.text || e.content || '').length > 100) (e: any) => e.doc_type === dt && (e.text || e.content || '').length > 100)
const modules = useMemo(() => [ const modules = useMemo(() => [
...(hasCookies ? [{ key: 'cookie', label: 'Cookies & Tracking' }] : []), ...(hasCookies ? [{ key: 'cookie', label: 'Cookies & Tracking' }] : []),
...(hasImpressum ? [{ key: 'impressum', label: 'Impressum' }] : []), ...(hasDoc('impressum') ? [{ key: 'impressum', label: 'Impressum' }] : []),
], [hasCookies, hasImpressum]) ...(hasDoc('dse') ? [{ key: 'dse', label: 'Datenschutzerklärung' }] : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
], [snap])
useEffect(() => { useEffect(() => {
if (!tab && modules.length) setTab(modules[0].key) if (!tab && modules.length) setTab(modules[0].key)
}, [modules, tab]) }, [modules, tab])
// Impressum erst beim Öffnen des Tabs analysieren (ImpressumAgent, ggf. LLM).
useEffect(() => {
if (tab !== 'impressum' || impressum || impLoading) return
setImpLoading(true)
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/impressum-check`)
.then(r => r.json())
.then(setImpressum)
.catch(() => setImpressum({ error: 'Impressum-Analyse fehlgeschlagen', findings: [] }))
.finally(() => setImpLoading(false))
}, [tab, snapshotId, impressum, impLoading])
const tabBtn = (key: string, label: string) => ( const tabBtn = (key: string, label: string) => (
<button key={key} onClick={() => setTab(key)} <button key={key} onClick={() => setTab(key)}
className={`px-3 py-1.5 text-sm border-b-2 -mb-px ${tab === key ? 'border-blue-600 text-blue-700 font-medium' : 'border-transparent text-gray-500 hover:text-gray-700'}`}> className={`px-3 py-1.5 text-sm border-b-2 -mb-px ${tab === key ? 'border-blue-600 text-blue-700 font-medium' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
@@ -108,17 +98,11 @@ export default function SnapshotDetail(
)} )}
{tab === 'impressum' && ( {tab === 'impressum' && (
impLoading ? ( <AgentModuleTab snapshotId={snapshotId} docType="impressum" label="Impressum" />
<div className="text-sm text-gray-500">Impressum-Analyse läuft</div> )}
) : impressum?.error ? (
<div className="text-sm text-red-600">{impressum.error}</div> {tab === 'dse' && (
) : impressum && (impressum.findings?.length || impressum.mc_coverage?.length) ? ( <AgentModuleTab snapshotId={snapshotId} docType="dse" label="Datenschutzerklärung" />
<AgentResultTab topicLabel="Impressum" output={impressum} />
) : impressum ? (
<div className="text-sm text-gray-500">
{impressum.notes || 'Keine Impressum-Auswertung verfügbar.'}
</div>
) : null
)} )}
</> </>
)} )}
@@ -53,15 +53,15 @@ def _derive_scope(profile_dict: dict) -> list[str]:
return sorted(scope) return sorted(scope)
def impressum_input_from_snapshot(snap: dict) -> dict | None: def doc_input_from_snapshot(snap: dict, doc_type: str) -> dict | None:
"""Baut den ImpressumAgent-Input aus einem gespeicherten Snapshot (kein """Baut den AgentInput für EINEN Doc-Type aus einem gespeicherten Snapshot
Re-Crawl). Pure + testbar: zieht den Impressum-Text aus doc_entries, leitet (kein Re-Crawl). Pure + testbar: zieht den Text aus doc_entries, leitet den
den Scope aus scan_context + Profil ab (identisch zur Live-Auswertung) und Scope aus scan_context + Profil ab (identisch zur Live-Auswertung) und nimmt
nimmt site_label als company_name-Fallback. None, wenn kein Impressum-Text. site_label als company_name-Fallback. None, wenn kein/zu kurzer Text.
""" """
docs = snap.get("doc_entries") or [] docs = snap.get("doc_entries") or []
text = next((e.get("text") or e.get("content") or "" text = next((e.get("text") or e.get("content") or ""
for e in docs if e.get("doc_type") == "impressum"), "") for e in docs if e.get("doc_type") == doc_type), "")
if len((text or "").strip()) < _MIN_TEXT: if len((text or "").strip()) < _MIN_TEXT:
return None return None
profile = snap.get("profile") or {} profile = snap.get("profile") or {}
@@ -70,7 +70,7 @@ def impressum_input_from_snapshot(snap: dict) -> dict | None:
| set(_derive_scope(profile)) | set(_derive_scope(profile))
) )
return { return {
"doc_type": "impressum", "doc_type": doc_type,
"text": text, "text": text,
"business_scope": scope, "business_scope": scope,
"company_name": (profile.get("company_name") or snap.get("site_label") or ""), "company_name": (profile.get("company_name") or snap.get("site_label") or ""),
@@ -78,6 +78,11 @@ def impressum_input_from_snapshot(snap: dict) -> dict | None:
} }
def impressum_input_from_snapshot(snap: dict) -> dict | None:
"""Rückwärtskompatibler Alias für den Impressum-Endpoint."""
return doc_input_from_snapshot(snap, "impressum")
async def run_agent_outputs(state: dict) -> None: async def run_agent_outputs(state: dict) -> None:
"""Für jedes Topic mit registriertem v3-Agent + ausreichend Text: """Für jedes Topic mit registriertem v3-Agent + ausreichend Text:
Agent laufen lassen, AgentOutput ablegen + als SSE topic-Event Agent laufen lassen, AgentOutput ablegen + als SSE topic-Event
@@ -295,6 +295,33 @@ async def snapshot_impressum_check(snapshot_id: str):
db.close() db.close()
@router.get("/snapshots/{snapshot_id}/dse-check")
async def snapshot_dse_check(snapshot_id: str):
"""DSE-Analyse aus dem Snapshot (kein Re-Crawl): laeuft den kuratierten
DSEAgent (Art. 13/14, ART13_CHECKLIST — KEIN Library-Firehose) auf dem
gespeicherten DSE-Text und liefert den AgentOutput fuer den Tab."""
from fastapi import HTTPException
from database import SessionLocal
from compliance.services.check_snapshot import load_snapshot
from compliance.services.specialist_agents import REGISTRY, AgentInput
from compliance.api.agent_check._agent_outputs import (
doc_input_from_snapshot,
)
db = SessionLocal()
try:
snap = load_snapshot(db, snapshot_id)
if not snap:
raise HTTPException(status_code=404, detail="snapshot not found")
agent_input = doc_input_from_snapshot(snap, "dse")
if not agent_input:
return {"findings": [], "recommendations": [], "mc_coverage": [],
"notes": "kein DSE-Text im Snapshot", "confidence": 0.0}
out = await REGISTRY.get("dse").evaluate(AgentInput(**agent_input))
return out.model_dump(mode="json")
finally:
db.close()
@router.get("/admin/benchmark") @router.get("/admin/benchmark")
async def benchmark( async def benchmark(
industry: str = "", industry: str = "",
@@ -30,17 +30,19 @@ from ._base import (
from ._registry import REGISTRY from ._registry import REGISTRY
from .cookie_policy import CookiePolicyAgent from .cookie_policy import CookiePolicyAgent
from .cross_placement import CrossPlacementAgent from .cross_placement import CrossPlacementAgent
from .dse import DSEAgent
from .impressum import ImpressumAgent from .impressum import ImpressumAgent
# Self-register all agents # Self-register all agents
REGISTRY.register(ImpressumAgent()) REGISTRY.register(ImpressumAgent())
REGISTRY.register(CookiePolicyAgent()) REGISTRY.register(CookiePolicyAgent())
REGISTRY.register(CrossPlacementAgent()) REGISTRY.register(CrossPlacementAgent())
REGISTRY.register(DSEAgent())
__all__ = [ __all__ = [
"AgentInput", "AgentOutput", "BaseSpecialistAgent", "AgentInput", "AgentOutput", "BaseSpecialistAgent",
"EscalationLog", "EvidenceSource", "Finding", "McCoverage", "EscalationLog", "EvidenceSource", "Finding", "McCoverage",
"Recommendation", "Severity", "SourceType", "Recommendation", "Severity", "SourceType",
"REGISTRY", "ImpressumAgent", "CookiePolicyAgent", "REGISTRY", "ImpressumAgent", "CookiePolicyAgent",
"CrossPlacementAgent", "CrossPlacementAgent", "DSEAgent",
] ]
@@ -0,0 +1,5 @@
"""DSE-Agent — Datenschutzerklärung (Art. 13/14 DSGVO), kuratiert."""
from .agent import DSEAgent
__all__ = ["DSEAgent"]
@@ -0,0 +1,167 @@
"""DSEAgent — Datenschutzerklärung / Datenschutzinformation (Art. 13/14 DSGVO).
Kuratiert: läuft die ART13_CHECKLIST (Pflichtangaben L1 „erwähnt?" +
Detailchecks L2 „vollständig?") deterministisch über den DSE-Text. BEWUSST
KEIN Library-Firehose (eCall/Gesundheit/Telekom/Data-Act-Lärm aus der 90k-
Control-Library) — nur die echten Art-13/14-Auskunftspflichten. Output =
AgentOutput (mc_coverage + Findings + Maßnahmen), gerendert im AgentResultTab
wie das Impressum-Modul.
"""
from __future__ import annotations
import re
from datetime import datetime, timezone
from compliance.services.doc_checks.dse_checks import ART13_CHECKLIST
from .._base import (
AgentInput,
AgentOutput,
BaseSpecialistAgent,
CheckStatus,
EvidenceSource,
Finding,
McCoverage,
Severity,
SourceType,
lint_output,
)
from .._rollup import rollup
_SEV = {"HIGH": Severity.HIGH, "MEDIUM": Severity.MEDIUM,
"LOW": Severity.LOW, "INFO": Severity.INFO}
# Coverage-Status bei FAIL spiegelt die Risiko-Achse (severity) der Quelle.
_COV_FAIL = {"HIGH": "high", "MEDIUM": "medium", "LOW": "low", "INFO": "low"}
_NORM_RE = re.compile(r"\((Art\.[^)]+)\)")
def _match_value(text: str, start: int, end: int) -> str:
"""Exakter Treffer-Wert (nicht die umgebende Passage), normalisiert + gekappt."""
return " ".join(text[start:end].split())[:120]
def _norm_of(label: str) -> str:
m = _NORM_RE.search(label or "")
return m.group(1) if m else "Art. 13/14 DSGVO"
def _compiled(check: dict) -> list:
out = []
for p in check.get("patterns", []):
try:
out.append(re.compile(p, re.IGNORECASE | re.MULTILINE))
except re.error:
continue
return out
def _search(patterns: list, text: str):
for p in patterns:
m = p.search(text)
if m:
return m
return None
class DSEAgent(BaseSpecialistAgent):
agent_id = "dse"
agent_version = "1.0"
doc_type = "dse"
owned_mc_ids = tuple(c["id"] for c in ART13_CHECKLIST)
async def evaluate(self, agent_input: AgentInput) -> AgentOutput:
start = datetime.now(timezone.utc)
text = (agent_input.text or "").strip()
coverage: list[McCoverage] = []
findings: list[Finding] = []
if len(text) < 100:
for c in ART13_CHECKLIST:
coverage.append(McCoverage(
mc_id=c["id"], status="skipped",
label=c["label"], reason="Text zu kurz"))
return self._finalize(start, findings, coverage, 0.0,
"DSE-Text zu kurz oder leer.")
# L1 (Pflichtangabe erwähnt?) zuerst — Ergebnis steuert L2.
l1_present: dict[str, bool] = {}
for c in ART13_CHECKLIST:
if c.get("level", 1) != 1:
continue
m = _search(_compiled(c), text)
l1_present[c["id"]] = m is not None
coverage.append(self._cov(c, m, text))
if m is None:
findings.append(self._finding(c, present=False))
# L2 (vollständig/korrekt?) — nur wenn die übergeordnete L1 vorhanden ist
# (sonst kein Doppel-Finding zum selben Mangel).
for c in ART13_CHECKLIST:
if c.get("level", 1) != 2:
continue
parent = c.get("parent")
if parent and not l1_present.get(parent, False):
coverage.append(McCoverage(
mc_id=c["id"], status="na", label=c["label"],
reason="übergeordnete Pflichtangabe fehlt"))
continue
m = _search(_compiled(c), text)
coverage.append(self._cov(c, m, text))
if m is None:
findings.append(self._finding(c, present=True))
return self._finalize(start, findings, coverage, 0.7, "")
def _cov(self, c: dict, m, text: str) -> McCoverage:
if m is not None:
return McCoverage(
mc_id=c["id"], status="ok", label=c["label"],
reason="Pattern-Treffer",
found=_match_value(text, m.start(), m.end()))
sev = c.get("severity", "MEDIUM")
return McCoverage(
mc_id=c["id"], status=_COV_FAIL.get(sev, "medium"),
label=c["label"],
reason="fehlt" if c.get("level", 1) == 1 else "Detail unvollständig")
def _finding(self, c: dict, present: bool) -> Finding:
sev = c.get("severity", "MEDIUM")
title = (f"{c['label']}: Detail unvollständig" if present
else f"{c['label']} fehlt")
return Finding(
check_id=f"DSE-{c['id']}",
agent=self.agent_id, agent_version=self.agent_version,
field_id=c["id"], status=CheckStatus.FAIL,
severity=_SEV.get(sev, Severity.MEDIUM),
severity_reason=("detail_incomplete" if present
else "pflichtangabe_missing"),
title=title, norm=_norm_of(c["label"]),
action=c.get("hint", ""), confidence=0.7,
sources=[EvidenceSource(
source_type=SourceType.REGEX, source_id=c["id"],
detail="kein Pattern-Treffer", confidence=0.7)],
)
def _finalize(self, start, findings, coverage, confidence, notes):
end = datetime.now(timezone.utc)
recs = rollup([f for f in findings
if f.status == CheckStatus.FAIL.value])
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,
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"),
mc_insufficient=sum(
1 for c in coverage if c.status == "insufficient_evidence"),
mc_possibly=sum(
1 for c in coverage if c.status == "possibly_applicable"),
)
return lint_output(out)
@@ -99,7 +99,7 @@ MCS: tuple[MC, ...] = (
excludes_scope=("kein_handelsregister",), excludes_scope=("kein_handelsregister",),
legal_form_dependent=True, legal_form_dependent=True,
patterns=( patterns=(
re.compile(r"\bHR[BA]\s+\d", re.IGNORECASE), re.compile(r"\bHR[BA]\s+\d+", re.IGNORECASE),
re.compile(r"Handelsregister", re.IGNORECASE), re.compile(r"Handelsregister", re.IGNORECASE),
), ),
), ),
@@ -0,0 +1,44 @@
"""DSEAgent — kuratierte Art-13/14-Checkliste (kein Library-Firehose)."""
from __future__ import annotations
import asyncio
from compliance.services.specialist_agents import REGISTRY, AgentInput
def _run(text: str):
return asyncio.run(
REGISTRY.get("dse").evaluate(AgentInput(doc_type="dse", text=text)))
def test_dse_agent_registered():
assert REGISTRY.get("dse") is not None
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"
assert out.mc_total == 33 # ART13_CHECKLIST komplett
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_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)
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)