feat: ZeroClaw compliance agent — document analysis + role assignment + email

Add autonomous compliance agent that fetches web documents (cookie banners,
privacy policies), classifies them via Qwen/Ollama, assesses DSGVO compliance,
assigns to the responsible role, and sends notification emails.

Components:
- ZeroClaw SOP (6-step workflow: fetch, classify, assess, summarize, assign, notify)
- Backend: /api/compliance/agent/analyze (combined endpoint)
- Backend: /api/compliance/agent/notify (standalone email)
- Frontend: /sdk/agent page (Manager UI with URL input + results)
- Helper scripts + E2E test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 23:27:25 +02:00
parent f528b8e7a9
commit 0c0dd4e3a6
16 changed files with 1095 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
'use client'
import React from 'react'
import type { AnalysisResult } from '../_hooks/useAgentAnalysis'
const DOC_TYPE_LABELS: Record<string, string> = {
privacy_policy: 'DSE',
cookie_banner: 'Cookie',
terms_of_service: 'AGB',
imprint: 'Impressum',
dpa: 'AVV',
other: 'Sonstig',
}
const RISK_DOT: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
interface Props {
history: AnalysisResult[]
onSelect: (result: AnalysisResult) => void
}
export function AnalysisHistory({ history, onSelect }: Props) {
if (history.length === 0) return null
return (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Analysen</h3>
<div className="space-y-2">
{history.map((item, i) => (
<button
key={i}
onClick={() => onSelect(item)}
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${RISK_DOT[item.risk_level] || 'bg-gray-400'}`} />
<span className="text-xs font-medium text-gray-500 w-16">
{DOC_TYPE_LABELS[item.classification] || item.classification}
</span>
<span className="text-sm text-gray-700 truncate flex-1">
{new URL(item.url).hostname}
</span>
<span className="text-xs text-gray-400">
{new Date(item.analyzed_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import React from 'react'
import type { AnalysisResult as AnalysisResultType } from '../_hooks/useAgentAnalysis'
const RISK_COLORS: Record<string, { bg: string; text: string; label: string }> = {
low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Niedrig' },
medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Mittel' },
high: { bg: 'bg-orange-100', text: 'text-orange-800', label: 'Hoch' },
critical: { bg: 'bg-red-100', text: 'text-red-800', label: 'Kritisch' },
unknown: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unbekannt' },
}
const DOC_TYPE_LABELS: Record<string, string> = {
privacy_policy: 'Datenschutzerklaerung',
cookie_banner: 'Cookie-Banner',
terms_of_service: 'AGB',
imprint: 'Impressum',
dpa: 'Auftragsverarbeitung (AVV)',
other: 'Sonstiges',
}
interface Props {
result: AnalysisResultType
}
export function AnalysisResult({ result }: Props) {
const risk = RISK_COLORS[result.risk_level] || RISK_COLORS.unknown
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{DOC_TYPE_LABELS[result.classification] || result.classification}
</h3>
<p className="text-sm text-gray-500 truncate max-w-md">{result.url}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${risk.bg} ${risk.text}`}>
{risk.label} ({result.risk_score}/100)
</span>
</div>
{/* Role Assignment */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="text-sm font-medium text-purple-900">
Zugewiesen an: <strong>{result.responsible_role}</strong>
</span>
<span className="text-xs text-purple-600 ml-auto">
Eskalationsstufe {result.escalation_level}
</span>
</div>
</div>
{/* Summary */}
{result.summary && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Zusammenfassung</h4>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{result.summary}</p>
</div>
)}
{/* Findings */}
{result.findings.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Findings ({result.findings.length})</h4>
<ul className="space-y-1">
{result.findings.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
<span className="text-orange-500 mt-0.5">!</span>
{f}
</li>
))}
</ul>
</div>
)}
{/* Required Controls */}
{result.required_controls.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Erforderliche Massnahmen</h4>
<ul className="space-y-1">
{result.required_controls.map((c, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
<span className="text-blue-500 mt-0.5">&#10003;</span>
{c}
</li>
))}
</ul>
</div>
)}
{/* Email Status */}
<div className="flex items-center gap-2 text-sm text-gray-500 pt-2 border-t">
<span className={result.email_status === 'sent' ? 'text-green-600' : 'text-yellow-600'}>
{result.email_status === 'sent' ? '&#9993; Email gesendet' : '&#9993; Email ausstehend'}
</span>
<span className="ml-auto text-xs">
{new Date(result.analyzed_at).toLocaleString('de-DE')}
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
export interface AnalysisResult {
url: string
classification: string
risk_level: string
risk_score: number
escalation_level: string
responsible_role: string
findings: string[]
required_controls: string[]
summary: string
email_status: string
analyzed_at: string
}
const ESCALATION_ROLES: Record<string, string> = {
E0: 'Kein Handlungsbedarf',
E1: 'Teamleitung Datenschutz',
E2: 'Datenschutzbeauftragter (DSB)',
E3: 'DSB + Rechtsabteilung',
}
const SDK_HEADERS = {
'Content-Type': 'application/json',
'X-Tenant-ID': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'X-User-ID': '00000000-0000-0000-0000-000000000001',
}
export function useAgentAnalysis() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<AnalysisResult | null>(null)
const [history, setHistory] = useState<AnalysisResult[]>([])
async function analyze(url: string) {
setLoading(true)
setError(null)
setResult(null)
try {
// Step 1: Fetch and classify
const fetchRes = await fetch('/api/sdk/v1/agent/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
if (!fetchRes.ok) {
throw new Error(`Analyse fehlgeschlagen: ${fetchRes.status}`)
}
const data = await fetchRes.json()
const analysisResult: AnalysisResult = {
url,
classification: data.classification || 'unknown',
risk_level: data.risk_level || 'unknown',
risk_score: data.risk_score || 0,
escalation_level: data.escalation_level || 'E0',
responsible_role: ESCALATION_ROLES[data.escalation_level] || ESCALATION_ROLES.E0,
findings: data.findings || [],
required_controls: data.required_controls || [],
summary: data.summary || '',
email_status: data.email_status || 'pending',
analyzed_at: new Date().toISOString(),
}
setResult(analysisResult)
setHistory(prev => [analysisResult, ...prev].slice(0, 20))
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
return { analyze, loading, error, result, history }
}

View File

@@ -0,0 +1,83 @@
'use client'
import React, { useState } from 'react'
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
import { AnalysisResult } from './_components/AnalysisResult'
import { AnalysisHistory } from './_components/AnalysisHistory'
export default function AgentPage() {
const [url, setUrl] = useState('')
const { analyze, loading, error, result, history } = useAgentAnalysis()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
analyze(url.trim())
}
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
<p className="text-gray-500 mt-1">
Analysiere Webseiten auf DSGVO-Konformitaet. Der Agent holt das Dokument,
klassifiziert es, bewertet das Risiko und weist die Aufgabe der zustaendigen Rolle zu.
</p>
</div>
{/* URL Input */}
<form onSubmit={handleSubmit} className="flex gap-3">
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://example.com/datenschutz"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
disabled={loading}
required
/>
<button
type="submit"
disabled={loading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2 text-sm font-medium"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Analysiere...
</>
) : (
'Analysieren'
)}
</button>
</form>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
{error}
</div>
)}
{/* Result */}
{result && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<AnalysisResult result={result} />
</div>
)}
{/* History */}
<AnalysisHistory
history={history}
onSelect={r => {
setUrl(r.url)
analyze(r.url)
}}
/>
</div>
)
}