Files
breakpilot-lehrer/admin-lehrer/components/ocr-kombi/StepGutterRepair.tsx
Benjamin Admin 71e1b10ac7
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 41s
CI / test-go-edu-search (push) Successful in 36s
CI / test-python-klausur (push) Failing after 2m31s
CI / test-python-agent-core (push) Successful in 28s
CI / test-nodejs-website (push) Successful in 29s
Add gutter repair step to OCR Kombi pipeline
New step "Wortkorrektur" between Grid-Review and Ground Truth that detects
and fixes words truncated or blurred at the book gutter (binding area) of
double-page scans. Uses pyspellchecker (DE+EN) for validation.

Two repair strategies:
- hyphen_join: words split across rows with missing chars (ve + künden → verkünden)
- spell_fix: garbled trailing chars from gutter blur (stammeli → stammeln)

Interactive frontend with per-suggestion accept/reject and batch controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:50:16 +02:00

394 lines
15 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
const KLAUSUR_API = '/klausur-api'
interface GutterSuggestion {
id: string
type: 'hyphen_join' | 'spell_fix'
zone_index: number
row_index: number
col_index: number
col_type: string
cell_id: string
original_text: string
suggested_text: string
next_row_index: number
next_row_cell_id: string
next_row_text: string
missing_chars: string
display_parts: string[]
confidence: number
reason: string
}
interface GutterRepairResult {
suggestions: GutterSuggestion[]
stats: {
words_checked: number
gutter_candidates: number
suggestions_found: number
error?: string
}
duration_seconds: number
}
interface StepGutterRepairProps {
sessionId: string | null
onNext: () => void
}
/**
* Step 11: Gutter Repair (Wortkorrektur).
* Detects words truncated at the book gutter and proposes corrections.
* User can accept/reject each suggestion individually or in batch.
*/
export function StepGutterRepair({ sessionId, onNext }: StepGutterRepairProps) {
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [result, setResult] = useState<GutterRepairResult | null>(null)
const [accepted, setAccepted] = useState<Set<string>>(new Set())
const [rejected, setRejected] = useState<Set<string>>(new Set())
const [applied, setApplied] = useState(false)
const [error, setError] = useState('')
const [applyMessage, setApplyMessage] = useState('')
const analyse = useCallback(async () => {
if (!sessionId) return
setLoading(true)
setError('')
setApplied(false)
setApplyMessage('')
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/gutter-repair`,
{ method: 'POST' },
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.detail || `Analyse fehlgeschlagen (${res.status})`)
}
const data: GutterRepairResult = await res.json()
setResult(data)
// Auto-accept all suggestions with high confidence
const autoAccept = new Set<string>()
for (const s of data.suggestions) {
if (s.confidence >= 0.85) {
autoAccept.add(s.id)
}
}
setAccepted(autoAccept)
setRejected(new Set())
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [sessionId])
// Auto-trigger analysis on mount
useEffect(() => {
if (sessionId) analyse()
}, [sessionId, analyse])
const toggleSuggestion = (id: string) => {
setAccepted(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
setRejected(r => new Set(r).add(id))
} else {
next.add(id)
setRejected(r => { const n = new Set(r); n.delete(id); return n })
}
return next
})
}
const acceptAll = () => {
if (!result) return
setAccepted(new Set(result.suggestions.map(s => s.id)))
setRejected(new Set())
}
const rejectAll = () => {
if (!result) return
setRejected(new Set(result.suggestions.map(s => s.id)))
setAccepted(new Set())
}
const applyAccepted = async () => {
if (!sessionId || accepted.size === 0) return
setApplying(true)
setApplyMessage('')
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/gutter-repair/apply`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted: Array.from(accepted) }),
},
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.detail || `Anwenden fehlgeschlagen (${res.status})`)
}
const data = await res.json()
setApplied(true)
setApplyMessage(`${data.applied_count} Korrektur(en) angewendet.`)
} catch (e) {
setApplyMessage(e instanceof Error ? e.message : String(e))
} finally {
setApplying(false)
}
}
const suggestions = result?.suggestions || []
const hasSuggestions = suggestions.length > 0
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Wortkorrektur (Buchfalz)
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Erkennt abgeschnittene oder unscharfe Woerter am Buchfalz und Bindestrich-Trennungen ueber Zeilen hinweg.
</p>
</div>
{result && !loading && (
<button
onClick={analyse}
className="px-3 py-1.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Erneut analysieren
</button>
)}
</div>
{/* Loading */}
{loading && (
<div className="flex items-center gap-3 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="animate-spin w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full" />
<span className="text-sm text-blue-600 dark:text-blue-400">Analysiere Woerter am Buchfalz...</span>
</div>
)}
{/* Error */}
{error && (
<div className="space-y-3">
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
</div>
<button
onClick={analyse}
className="px-4 py-2 bg-orange-600 text-white text-sm rounded-lg hover:bg-orange-700"
>
Erneut versuchen
</button>
</div>
)}
{/* No suggestions */}
{result && !hasSuggestions && !loading && (
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-200 dark:border-green-800">
<div className="text-sm font-medium text-green-700 dark:text-green-300">
Keine Buchfalz-Fehler erkannt.
</div>
<div className="text-xs text-green-600 dark:text-green-400 mt-1">
{result.stats.words_checked} Woerter geprueft, {result.stats.gutter_candidates} Kandidaten am Rand analysiert.
</div>
</div>
)}
{/* Suggestions list */}
{hasSuggestions && !loading && (
<>
{/* Stats bar */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400">
{suggestions.length} Vorschlag/Vorschlaege &middot;{' '}
{result!.stats.words_checked} Woerter geprueft &middot;{' '}
{result!.duration_seconds}s
</div>
<div className="flex gap-2">
<button
onClick={acceptAll}
disabled={applied}
className="px-2 py-1 text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded hover:bg-green-200 dark:hover:bg-green-900/50 disabled:opacity-50"
>
Alle akzeptieren
</button>
<button
onClick={rejectAll}
disabled={applied}
className="px-2 py-1 text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded hover:bg-red-200 dark:hover:bg-red-900/50 disabled:opacity-50"
>
Alle ablehnen
</button>
</div>
</div>
{/* Suggestion cards */}
<div className="space-y-2">
{suggestions.map((s) => {
const isAccepted = accepted.has(s.id)
const isRejected = rejected.has(s.id)
return (
<div
key={s.id}
className={`p-3 rounded-lg border transition-colors ${
applied
? isAccepted
? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-800'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700 opacity-60'
: isAccepted
? 'bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-700'
: isRejected
? 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800 opacity-60'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between gap-3">
{/* Left: suggestion details */}
<div className="flex-1 min-w-0">
{/* Type badge */}
<div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded ${
s.type === 'hyphen_join'
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300'
}`}>
{s.type === 'hyphen_join' ? 'Zeilenumbruch' : 'Buchfalz-Korrektur'}
</span>
<span className="text-[10px] text-gray-400">
Zeile {s.row_index + 1}, Spalte {s.col_index + 1}
{s.col_type && ` (${s.col_type.replace('column_', '')})`}
</span>
<span className={`text-[10px] ${
s.confidence >= 0.9 ? 'text-green-500' :
s.confidence >= 0.7 ? 'text-yellow-500' : 'text-red-500'
}`}>
{Math.round(s.confidence * 100)}%
</span>
</div>
{/* Correction display */}
{s.type === 'hyphen_join' ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-mono text-red-600 dark:text-red-400 line-through">
{s.original_text}
</span>
<span className="text-gray-400 text-xs">Z.{s.row_index + 1}</span>
<span className="text-gray-300 dark:text-gray-600">+</span>
<span className="font-mono text-red-600 dark:text-red-400 line-through">
{s.next_row_text.split(' ')[0]}
</span>
<span className="text-gray-400 text-xs">Z.{s.next_row_index + 1}</span>
<span className="text-gray-400">&rarr;</span>
<span className="font-mono text-green-600 dark:text-green-400 font-semibold">
{s.suggested_text}
</span>
</div>
{s.missing_chars && (
<div className="text-[10px] text-gray-400">
Fehlende Zeichen: <span className="font-mono font-semibold">{s.missing_chars}</span>
{' '}&middot; Darstellung: <span className="font-mono">{s.display_parts.join(' | ')}</span>
</div>
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm">
<span className="font-mono text-red-600 dark:text-red-400 line-through">
{s.original_text}
</span>
<span className="text-gray-400">&rarr;</span>
<span className="font-mono text-green-600 dark:text-green-400 font-semibold">
{s.suggested_text}
</span>
</div>
)}
</div>
{/* Right: accept/reject toggle */}
{!applied && (
<button
onClick={() => toggleSuggestion(s.id)}
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors ${
isAccepted
? 'bg-green-500 text-white hover:bg-green-600'
: isRejected
? 'bg-red-400 text-white hover:bg-red-500'
: 'bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500'
}`}
title={isAccepted ? 'Akzeptiert (klicken zum Ablehnen)' : isRejected ? 'Abgelehnt (klicken zum Akzeptieren)' : 'Klicken zum Akzeptieren'}
>
{isAccepted ? '\u2713' : isRejected ? '\u2717' : '?'}
</button>
)}
</div>
</div>
)
})}
</div>
{/* Apply / Next buttons */}
<div className="flex items-center gap-3 pt-2">
{!applied ? (
<button
onClick={applyAccepted}
disabled={applying || accepted.size === 0}
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700 disabled:opacity-50"
>
{applying ? 'Wird angewendet...' : `${accepted.size} Korrektur(en) anwenden`}
</button>
) : (
<button
onClick={onNext}
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700"
>
Weiter zu Ground Truth
</button>
)}
{!applied && (
<button
onClick={onNext}
className="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
Ueberspringen
</button>
)}
</div>
{/* Apply result message */}
{applyMessage && (
<div className={`text-sm p-2 rounded ${
applyMessage.includes('fehlgeschlagen')
? 'text-red-500 bg-red-50 dark:bg-red-900/20'
: 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20'
}`}>
{applyMessage}
</div>
)}
</>
)}
{/* Skip button when no suggestions */}
{result && !hasSuggestions && !loading && (
<button
onClick={onNext}
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700"
>
Weiter zu Ground Truth
</button>
)}
</div>
)
}