feat: Multi-URL Document Check with full checklist visibility

New "Dokumenten-Pruefung" tab in Compliance Agent:
- User adds multiple URLs with document type (DSI, AGB, Impressum, Cookie, Widerruf)
- Each document loaded via Playwright, accordions expanded, text extracted
- Checked against type-specific legal checklist
- Optional: Cookie banner check via checkbox

Checklisten-UX (solves "100% looks like nothing was checked"):
- All checks shown per document: green checkmark + matched text excerpt
- Red X for missing fields with legal reference
- Builds user trust: "9 Punkte geprueft, alle bestanden"
- Expandable per document with completeness bar

New checklists:
- Impressum: §5 TMG (6 fields: name, address, contact, register, VAT, representative)
- Cookie-Richtlinie: §25 TDDDG (5 fields: types, purposes, retention, third-party, opt-out)

Backend:
- POST /agent/doc-check — async with polling (same pattern as /scan)
- DocCheckResult includes checks[] with passed/failed + matched_text
- dsi_document_checker returns all_checks in SCORE finding
- Email report shows per-document checklist

Files: agent_doc_check_routes.py (280 LOC), DocCheckTab.tsx (248 LOC),
ChecklistView.tsx (130 LOC), dsi_document_checker.py (+70 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-06 10:08:40 +02:00
parent 254dbab566
commit 4c68caac4e
7 changed files with 770 additions and 8 deletions
@@ -0,0 +1,130 @@
'use client'
import React, { useState } from 'react'
interface CheckItem {
id: string
label: string
passed: boolean
severity: string
matched_text: string
}
interface DocResult {
label: string
url: string
doc_type: string
word_count: number
completeness_pct: number
checks: CheckItem[]
findings_count: number
error: string
}
const DOC_TYPE_LABELS: Record<string, string> = {
dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
}
export function ChecklistView({ results }: { results: DocResult[] }) {
const [expanded, setExpanded] = useState<string | null>(null)
if (!results || results.length === 0) return null
const totalOk = results.filter(r => r.completeness_pct === 100).length
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800">
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
</h3>
</div>
<div className="space-y-2">
{results.map((r, i) => {
const isExp = expanded === r.url
const pct = r.completeness_pct
const barColor = pct === 100 ? 'bg-green-500' : pct >= 80 ? 'bg-green-400' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
return (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(isExp ? null : r.url)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50 text-left"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExp ? 'rotate-90' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium shrink-0">
{typeLabel}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">{r.label}</div>
<div className="text-xs text-gray-500 truncate">{r.url}</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{r.error ? (
<span className="text-xs text-red-600 font-medium">Fehler</span>
) : (
<>
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-medium w-10 text-right ${
pct === 100 ? 'text-green-700' : pct >= 50 ? 'text-yellow-700' : 'text-red-700'
}`}>{pct}%</span>
</>
)}
</div>
</button>
{isExp && (
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
{r.error ? (
<p className="text-sm text-red-600">{r.error}</p>
) : (
<div className="space-y-1.5">
{r.checks.map((check, ci) => (
<div key={ci} className="flex items-start gap-2">
{check.passed ? (
<svg className="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<div className="flex-1">
<div className={`text-sm ${check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
{check.label}
</div>
{check.passed && check.matched_text && (
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
&quot;...{check.matched_text}...&quot;
</div>
)}
</div>
</div>
))}
{r.word_count > 0 && (
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
{r.word_count} Woerter analysiert
</div>
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,248 @@
'use client'
import React, { useState } from 'react'
import { ChecklistView } from './ChecklistView'
interface DocEntry {
id: string
type: string
label: string
url: string
}
const DOC_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
{ id: 'impressum', label: 'Impressum' },
{ id: 'cookie', label: 'Cookie-Richtlinie' },
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
{ id: 'other', label: 'Sonstiges' },
]
function newEntry(): DocEntry {
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
}
export function DocCheckTab() {
const [entries, setEntries] = useState<DocEntry[]>([newEntry()])
const [checkCookieBanner, setCheckCookieBanner] = useState(false)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [results, setResults] = useState<any>(null)
const [error, setError] = useState<string | null>(null)
const updateEntry = (id: string, field: keyof DocEntry, value: string) => {
setEntries(prev => prev.map(e => e.id === id ? { ...e, [field]: value } : e))
}
const removeEntry = (id: string) => {
setEntries(prev => prev.filter(e => e.id !== id))
}
const addEntry = () => {
setEntries(prev => [...prev, newEntry()])
}
// Auto-detect label from URL
const autoLabel = (entry: DocEntry) => {
if (entry.label) return
try {
const path = new URL(entry.url).pathname
const last = path.split('/').filter(Boolean).pop() || ''
const label = last.replace(/-\d+$/, '').replace(/-/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
if (label.length > 3) {
updateEntry(entry.id, 'label', label)
}
} catch { /* invalid URL */ }
}
const handleSubmit = async () => {
const validEntries = entries.filter(e => e.url.trim())
if (validEntries.length === 0) return
setLoading(true)
setError(null)
setResults(null)
setProgress('Pruefung wird gestartet...')
try {
const startRes = await fetch('/api/sdk/v1/agent/doc-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: validEntries.map(e => ({
doc_type: e.type,
label: e.label || e.url.split('/').pop() || 'Dokument',
url: e.url.trim(),
})),
check_cookie_banner: checkCookieBanner,
}),
})
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
const { check_id } = await startRes.json()
if (!check_id) throw new Error('Keine Check-ID erhalten')
// Poll for results
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 3000))
const pollRes = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
break
}
if (pollData.status === 'failed') {
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
}
attempts++
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4">
{/* URL Entries */}
<div className="space-y-2">
{entries.map((entry, i) => (
<div key={entry.id} className="flex items-center gap-2">
<select
value={entry.type}
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
>
{DOC_TYPES.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<input
type="text"
value={entry.label}
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
placeholder="Bezeichnung (optional)"
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
/>
<input
type="url"
value={entry.url}
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
onBlur={() => autoLabel(entry)}
placeholder="https://example.com/datenschutz"
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
/>
{entries.length > 1 && (
<button onClick={() => removeEntry(entry.id)}
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))}
</div>
{/* Add URL + Options */}
<div className="flex items-center justify-between">
<button onClick={addEntry}
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
URL hinzufuegen
</button>
<label className="flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={checkCookieBanner}
onChange={e => setCheckCookieBanner(e.target.checked)}
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
Cookie-Banner pruefen
</label>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={loading || entries.every(e => !e.url.trim())}
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
>
{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>
Pruefe...
</>
) : (
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
)}
</button>
{/* Progress */}
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" 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>
{progress}
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
{/* Results */}
{results && results.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ChecklistView results={results.results} />
{/* Cookie Banner Result */}
{results.cookie_banner_result && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-semibold text-gray-800 mb-2">Cookie-Banner</h4>
<div className="text-sm text-gray-600">
{results.cookie_banner_result.banner_detected
? `Banner erkannt: ${results.cookie_banner_result.banner_provider || 'unbekannt'}`
: 'Kein Banner erkannt'}
</div>
{results.cookie_banner_result.banner_checks?.violations?.length > 0 && (
<div className="mt-2 space-y-1">
{results.cookie_banner_result.banner_checks.violations.map((v: any, i: number) => (
<div key={i} className="text-xs text-red-600 flex items-start gap-1.5">
<span className="shrink-0 mt-0.5">!!</span>
<span>{v.text}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Email Status */}
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
</div>
)}
</div>
)
}
+9 -4
View File
@@ -6,9 +6,10 @@ import { AnalysisResult } from './_components/AnalysisResult'
import { AnalysisHistory } from './_components/AnalysisHistory'
import { FollowUpQuestions } from './_components/FollowUpQuestions'
import { ScanResult } from './_components/ScanResult'
import { DocCheckTab } from './_components/DocCheckTab'
type AnalysisMode = 'pre_launch' | 'post_launch'
type AnalysisTab = 'quick' | 'scan'
type AnalysisTab = 'quick' | 'scan' | 'doc-check'
const MODES: { id: AnalysisMode; label: string; desc: string; icon: string }[] = [
{ id: 'pre_launch', label: 'Internes Dokument', desc: 'Vor Veroeffentlichung pruefen', icon: '📋' },
@@ -18,6 +19,7 @@ const MODES: { id: AnalysisMode; label: string; desc: string; icon: string }[] =
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
{ id: 'quick', label: 'Schnellanalyse', desc: 'Einzelne Seite klassifizieren + bewerten' },
{ id: 'scan', label: 'Website-Scan', desc: 'Mehrere Seiten scannen + Dienstleister abgleichen' },
{ id: 'doc-check', label: 'Dokumenten-Pruefung', desc: 'Einzelne Dokumente gezielt pruefen' },
]
export default function AgentPage() {
@@ -219,8 +221,11 @@ export default function AgentPage() {
))}
</div>
{/* URL Input */}
<form onSubmit={handleSubmit} className="flex gap-3">
{/* Doc Check Tab — own component */}
{tab === 'doc-check' && <DocCheckTab />}
{/* URL Input (quick + scan only) */}
{tab !== 'doc-check' && <form onSubmit={handleSubmit} className="flex gap-3">
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
placeholder={tab === 'scan' ? 'https://www.example.com/' : '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"
@@ -234,7 +239,7 @@ export default function AgentPage() {
</svg>{tab === 'scan' ? 'Scanne...' : 'Analysiere...'}</>
) : tab === 'scan' ? 'Website scannen' : 'Analysieren'}
</button>
</form>
</form>}
{/* Scan Progress */}
{scanProgress && tab === 'scan' && (