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
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>
394 lines
15 KiB
TypeScript
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 ·{' '}
|
|
{result!.stats.words_checked} Woerter geprueft ·{' '}
|
|
{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">→</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>
|
|
{' '}· 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">→</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>
|
|
)
|
|
}
|