'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { WordResult, WordEntry, WordGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' /** Render text with \n as line breaks */ function MultilineText({ text }: { text: string }) { if (!text) return const lines = text.split('\n') if (lines.length === 1) return <>{text} return <>{lines.map((line, i) => ( {line}{i < lines.length - 1 &&
}
))} } interface StepWordRecognitionProps { sessionId: string | null onNext: () => void goToStep: (step: number) => void } export function StepWordRecognition({ sessionId, onNext, goToStep }: StepWordRecognitionProps) { const [wordResult, setWordResult] = useState(null) const [detecting, setDetecting] = useState(false) const [error, setError] = useState(null) const [gtNotes, setGtNotes] = useState('') const [gtSaved, setGtSaved] = useState(false) // Step-through labeling state const [activeIndex, setActiveIndex] = useState(0) const [editedEntries, setEditedEntries] = useState([]) const [mode, setMode] = useState<'overview' | 'labeling'>('overview') const [ocrEngine, setOcrEngine] = useState<'auto' | 'tesseract' | 'rapid'>('auto') const [usedEngine, setUsedEngine] = useState('') const enRef = useRef(null) useEffect(() => { if (!sessionId) return const fetchSession = async () => { try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) if (res.ok) { const info = await res.json() if (info.word_result) { setWordResult(info.word_result) setUsedEngine(info.word_result.ocr_engine || '') initEntries(info.word_result.entries) return } } } catch (e) { console.error('Failed to fetch session info:', e) } runAutoDetection() } fetchSession() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) const initEntries = (entries: WordEntry[]) => { setEditedEntries(entries.map(e => ({ ...e, status: e.status || 'pending' }))) setActiveIndex(0) } const runAutoDetection = useCallback(async (engine?: string) => { if (!sessionId) return const eng = engine || ocrEngine setDetecting(true) setError(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/words?engine=${eng}`, { method: 'POST', }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(err.detail || 'Worterkennung fehlgeschlagen') } const data = await res.json() setWordResult(data) setUsedEngine(data.ocr_engine || eng) initEntries(data.entries) } catch (e) { setError(e instanceof Error ? e.message : 'Unbekannter Fehler') } finally { setDetecting(false) } }, [sessionId, ocrEngine]) const handleGroundTruth = useCallback(async (isCorrect: boolean) => { if (!sessionId) return const gt: WordGroundTruth = { is_correct: isCorrect, corrected_entries: isCorrect ? undefined : editedEntries, notes: gtNotes || undefined, } try { await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/words`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gt), }) setGtSaved(true) } catch (e) { console.error('Ground truth save failed:', e) } }, [sessionId, gtNotes, editedEntries]) // Step-through: update entry field const updateEntry = (index: number, field: 'english' | 'german' | 'example', value: string) => { setEditedEntries(prev => prev.map((e, i) => i === index ? { ...e, [field]: value, status: 'edited' as const } : e )) } // Step-through: confirm current entry const confirmEntry = () => { setEditedEntries(prev => prev.map((e, i) => i === activeIndex ? { ...e, status: e.status === 'edited' ? 'edited' : 'confirmed' } : e )) if (activeIndex < editedEntries.length - 1) { setActiveIndex(activeIndex + 1) } } // Step-through: skip current entry const skipEntry = () => { setEditedEntries(prev => prev.map((e, i) => i === activeIndex ? { ...e, status: 'skipped' as const } : e )) if (activeIndex < editedEntries.length - 1) { setActiveIndex(activeIndex + 1) } } // Focus english input when active entry changes in labeling mode useEffect(() => { if (mode === 'labeling' && enRef.current) { enRef.current.focus() } }, [activeIndex, mode]) // Keyboard shortcuts in labeling mode useEffect(() => { if (mode !== 'labeling') return const handler = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() confirmEntry() } else if (e.key === 'Tab' && !e.shiftKey) { // Let Tab move between fields naturally unless on last field } else if (e.key === 'ArrowDown' && e.ctrlKey) { e.preventDefault() skipEntry() } else if (e.key === 'ArrowUp' && e.ctrlKey) { e.preventDefault() if (activeIndex > 0) setActiveIndex(activeIndex - 1) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, activeIndex, editedEntries]) if (!sessionId) { return (
🔤

Schritt 5: Worterkennung

Bitte zuerst Schritte 1-4 abschliessen.

) } const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay` const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped` const confColor = (conf: number) => { if (conf >= 70) return 'text-green-600 dark:text-green-400' if (conf >= 50) return 'text-yellow-600 dark:text-yellow-400' return 'text-red-600 dark:text-red-400' } const statusBadge = (status?: string) => { const map: Record = { pending: 'bg-gray-100 dark:bg-gray-700 text-gray-500', confirmed: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400', edited: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400', skipped: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400', } return map[status || 'pending'] || map.pending } const summary = wordResult?.summary const confirmedCount = editedEntries.filter(e => e.status === 'confirmed' || e.status === 'edited').length const totalCount = editedEntries.length return (
{/* Loading */} {detecting && (
Worterkennung laeuft...
)} {/* Mode toggle */} {wordResult && (
)} {/* Overview mode: side-by-side images + entry list */} {mode === 'overview' && ( <> {/* Images: overlay vs clean */}
Mit Grid-Overlay
{wordResult ? ( // eslint-disable-next-line @next/next/no-img-element Wort-Overlay ) : (
{detecting ? 'Erkenne Woerter...' : 'Keine Daten'}
)}
Entzerrtes Bild
{/* eslint-disable-next-line @next/next/no-img-element */} Entzerrt
{/* Result summary */} {wordResult && summary && (

Ergebnis: {summary.total_entries} Eintraege erkannt

{wordResult.duration_seconds}s
{/* Summary badges */}
EN: {summary.with_english} DE: {summary.with_german} {summary.low_confidence > 0 && ( Unsicher: {summary.low_confidence} )}
{/* Entry table */}
{editedEntries.map((entry, idx) => ( { setActiveIndex(idx); setMode('labeling') }} > ))}
# English Deutsch Example Conf
{idx + 1} {entry.confidence}%
)} )} {/* Labeling mode: image crop + editable fields */} {mode === 'labeling' && editedEntries.length > 0 && (
{/* Left 2/3: Image with highlighted active row */}
Eintrag {activeIndex + 1} von {editedEntries.length}
{/* eslint-disable-next-line @next/next/no-img-element */} Wort-Overlay {/* Highlight overlay for active entry bbox */} {editedEntries[activeIndex]?.bbox && (
)}
{/* Right 1/3: Editable entry fields */}
{/* Navigation */}
{activeIndex + 1} / {editedEntries.length}
{/* Status badge */}
{editedEntries[activeIndex]?.status || 'pending'} {editedEntries[activeIndex]?.confidence}% Konfidenz
{/* Cell crops */} {editedEntries[activeIndex]?.bbox_en && (
EN-Zelle
)} {editedEntries[activeIndex]?.bbox_de && (
DE-Zelle
)} {/* Editable fields */}