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:
@@ -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">
|
||||
"...{g.check.matched_text}..."
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{check.passed && check.matched_text && (
|
||||
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
|
||||
"...{check.matched_text}..."
|
||||
|
||||
{/* 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">
|
||||
"...{ch.matched_text}..."
|
||||
</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
|
||||
|
||||
Reference in New Issue
Block a user