feat: Add 76 Level-2 regex checks for document correctness verification

Split dsi_document_checker.py (466 LOC) into doc_checks/ package (9 files).
Two-pass L1→L2 logic: L1 checks "Is it mentioned?", L2 checks "Is it correct?"
(e.g. controller has full address, specific Art. 6 lit., concrete time periods).

138 total checks (62 L1 + 76 L2) across 7 doc types:
- DSE Art. 13: 31, Impressum §5 TMG: 16, Cookie §25 TDDDG: 15
- Widerruf §355: 15, AGB §305ff: 21, Social Media Art. 26: 20, DSFA Art. 35: 18

Frontend: hierarchical L1→L2 display with dual progress bars
(green=completeness, blue=correctness).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-07 12:37:03 +02:00
parent 3c12e06faf
commit b363c28539
12 changed files with 2083 additions and 496 deletions
@@ -8,6 +8,9 @@ interface CheckItem {
passed: boolean
severity: string
matched_text: string
level?: number
parent?: string | null
skipped?: boolean
}
interface DocResult {
@@ -16,6 +19,7 @@ interface DocResult {
doc_type: string
word_count: number
completeness_pct: number
correctness_pct?: number
checks: CheckItem[]
findings_count: number
error: string
@@ -27,13 +31,69 @@ const DOC_TYPE_LABELS: Record<string, string> = {
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
}
interface GroupedCheck {
check: CheckItem
children: CheckItem[]
}
function groupChecks(checks: CheckItem[]): GroupedCheck[] {
const l1 = checks.filter(c => (c.level ?? 1) === 1)
return l1.map(c => ({
check: c,
children: checks.filter(ch => ch.parent === c.id && (ch.level ?? 1) === 2),
}))
}
function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean }) {
if (skipped) {
return (
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
)
}
if (passed) {
return (
<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>
)
}
return (
<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>
)
}
function L2Summary({ children }: { children: CheckItem[] }) {
const active = children.filter(c => !c.skipped)
if (active.length === 0) return null
const passed = active.filter(c => c.passed).length
return (
<span className="text-xs text-gray-400 ml-1">
({passed}/{active.length})
</span>
)
}
export function ChecklistView({ results }: { results: DocResult[] }) {
const [expanded, setExpanded] = useState<number | null>(null)
const [expandedL1, setExpandedL1] = useState<Set<string>>(new Set())
if (!results || results.length === 0) return null
const totalOk = results.filter(r => r.completeness_pct === 100).length
const toggleL1 = (id: string) => {
setExpandedL1(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -46,8 +106,15 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
{results.map((r, i) => {
const isExp = expanded === i
const pct = r.completeness_pct
const cpct = r.correctness_pct ?? 0
const barColor = pct === 100 ? 'bg-green-500' : pct >= 80 ? 'bg-green-400' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
const cBarColor = cpct >= 80 ? 'bg-blue-400' : cpct >= 50 ? 'bg-blue-300' : 'bg-blue-200'
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
const grouped = groupChecks(r.checks)
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
const l1Passed = l1Checks.filter(c => c.passed).length
const l2Passed = l2Active.filter(c => c.passed).length
return (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
@@ -66,8 +133,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
<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.checks.length > 0
? `${r.checks.filter(c => c.passed).length} von ${r.checks.length} Pruefpunkten bestanden`
{l1Checks.length > 0
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
: r.url}
</div>
</div>
@@ -76,14 +144,24 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
{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 className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 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>
<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>
</>
{l2Active.length > 0 && (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
</div>
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
</div>
)}
</div>
)}
</div>
</button>
@@ -93,30 +171,65 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
{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 className="space-y-1">
{grouped.map((g) => {
const hasChildren = g.children.length > 0
const isL1Exp = expandedL1.has(g.check.id)
return (
<div key={g.check.id}>
{/* L1 check */}
<div
className={`flex items-start gap-2 ${hasChildren ? 'cursor-pointer' : ''}`}
onClick={hasChildren ? () => toggleL1(g.check.id) : undefined}
>
<CheckIcon passed={g.check.passed} />
<div className="flex-1">
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
{g.check.label}
{hasChildren && <L2Summary>{g.children}</L2Summary>}
{hasChildren && (
<svg className={`w-3 h-3 inline ml-1 text-gray-400 transition-transform ${isL1Exp ? '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>
)}
</div>
{g.check.passed && g.check.matched_text && !hasChildren && (
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
&quot;...{g.check.matched_text}...&quot;
</div>
)}
</div>
</div>
{check.passed && check.matched_text && (
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
&quot;...{check.matched_text}...&quot;
{/* L2 children */}
{hasChildren && isL1Exp && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-gray-200 pl-3">
{g.children.map((ch) => (
<div key={ch.id} className="flex items-start gap-2">
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
<div className="flex-1">
<div className={`text-xs ${
ch.skipped ? 'text-gray-400 italic'
: ch.passed ? 'text-gray-600' : 'text-red-600 font-medium'
}`}>
{ch.label}
{ch.skipped && ' (uebersprungen)'}
</div>
{ch.passed && ch.matched_text && (
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
&quot;...{ch.matched_text}...&quot;
</div>
)}
</div>
</div>
))}
</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