'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { GridResult, WordEntry, ColumnMeta } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' interface LlmChange { row_index: number field: 'english' | 'german' | 'example' old: string new: string } interface StepLlmReviewProps { sessionId: string | null onNext: () => void } interface ReviewMeta { total_entries: number to_review: number skipped: number model: string skipped_indices?: number[] } interface StreamProgress { current: number total: number } const FIELD_LABELS: Record = { english: 'EN', german: 'DE', example: 'Beispiel', source_page: 'Seite', marker: 'Marker', } /** Map column type to WordEntry field name */ const COL_TYPE_TO_FIELD: Record = { column_en: 'english', column_de: 'german', column_example: 'example', page_ref: 'source_page', column_marker: 'marker', } /** Column type → color class */ const COL_TYPE_COLOR: Record = { column_en: 'text-blue-600 dark:text-blue-400', column_de: 'text-green-600 dark:text-green-400', column_example: 'text-orange-600 dark:text-orange-400', page_ref: 'text-cyan-600 dark:text-cyan-400', column_marker: 'text-gray-500 dark:text-gray-400', } type RowStatus = 'pending' | 'active' | 'reviewed' | 'corrected' | 'skipped' export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { // Core state const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'running' | 'done' | 'error' | 'applied'>('idle') const [meta, setMeta] = useState(null) const [changes, setChanges] = useState([]) const [progress, setProgress] = useState(null) const [totalDuration, setTotalDuration] = useState(0) const [error, setError] = useState('') const [accepted, setAccepted] = useState>(new Set()) const [applying, setApplying] = useState(false) // Full vocab table state const [vocabEntries, setVocabEntries] = useState([]) const [columnsUsed, setColumnsUsed] = useState([]) const [activeRowIndices, setActiveRowIndices] = useState>(new Set()) const [reviewedRows, setReviewedRows] = useState>(new Set()) const [skippedRows, setSkippedRows] = useState>(new Set()) const [correctedMap, setCorrectedMap] = useState>(new Map()) // Image const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null) const tableRef = useRef(null) const activeRowRef = useRef(null) // Load session data on mount useEffect(() => { if (!sessionId) return loadSessionData() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) const loadSessionData = async () => { if (!sessionId) return setStatus('loading') try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() const wordResult: GridResult | undefined = data.word_result if (!wordResult) { setError('Keine Worterkennungsdaten gefunden. Bitte zuerst Schritt 5 abschliessen.') setStatus('error') return } const entries = wordResult.vocab_entries || wordResult.entries || [] setVocabEntries(entries) setColumnsUsed(wordResult.columns_used || []) // Check if LLM review was already run const llmReview = wordResult.llm_review if (llmReview && llmReview.changes) { const existingChanges: LlmChange[] = llmReview.changes as LlmChange[] setChanges(existingChanges) setTotalDuration(llmReview.duration_ms || 0) // Mark all rows as reviewed const allReviewed = new Set(entries.map((_: WordEntry, i: number) => i)) setReviewedRows(allReviewed) // Build corrected map const cMap = new Map() for (const c of existingChanges) { const existing = cMap.get(c.row_index) || [] existing.push(c) cMap.set(c.row_index, existing) } setCorrectedMap(cMap) // Default: all accepted setAccepted(new Set(existingChanges.map((_: LlmChange, i: number) => i))) setMeta({ total_entries: entries.length, to_review: llmReview.entries_corrected !== undefined ? entries.length : entries.length, skipped: 0, model: llmReview.model_used || 'unknown', }) setStatus('done') } else { setStatus('ready') } } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) setStatus('error') } } const runReview = useCallback(async () => { if (!sessionId) return setStatus('running') setError('') setChanges([]) setProgress(null) setMeta(null) setTotalDuration(0) setActiveRowIndices(new Set()) setReviewedRows(new Set()) setSkippedRows(new Set()) setCorrectedMap(new Map()) try { const res = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/llm-review?stream=true`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }, ) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.detail || `HTTP ${res.status}`) } const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' let allChanges: LlmChange[] = [] let allReviewed = new Set() let allSkipped = new Set() let cMap = new Map() while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) while (buffer.includes('\n\n')) { const idx = buffer.indexOf('\n\n') const chunk = buffer.slice(0, idx).trim() buffer = buffer.slice(idx + 2) if (!chunk.startsWith('data: ')) continue const dataStr = chunk.slice(6) let event: any try { event = JSON.parse(dataStr) } catch { continue } if (event.type === 'meta') { setMeta({ total_entries: event.total_entries, to_review: event.to_review, skipped: event.skipped, model: event.model, skipped_indices: event.skipped_indices, }) // Mark skipped rows if (event.skipped_indices) { allSkipped = new Set(event.skipped_indices) setSkippedRows(allSkipped) } } if (event.type === 'batch') { const batchChanges: LlmChange[] = event.changes || [] const batchRows: number[] = event.entries_reviewed || [] // Update active rows (currently being reviewed) setActiveRowIndices(new Set(batchRows)) // Accumulate changes allChanges = [...allChanges, ...batchChanges] setChanges(allChanges) setProgress(event.progress) // Update corrected map for (const c of batchChanges) { const existing = cMap.get(c.row_index) || [] existing.push(c) cMap.set(c.row_index, [...existing]) } setCorrectedMap(new Map(cMap)) // Mark batch rows as reviewed for (const r of batchRows) { allReviewed.add(r) } setReviewedRows(new Set(allReviewed)) // Scroll to active row in table setTimeout(() => { activeRowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 50) } if (event.type === 'complete') { setActiveRowIndices(new Set()) setTotalDuration(event.duration_ms) setAccepted(new Set(allChanges.map((_: LlmChange, i: number) => i))) // Mark all non-skipped as reviewed const allEntryIndices = vocabEntries.map((_: WordEntry, i: number) => i) for (const i of allEntryIndices) { if (!allSkipped.has(i)) allReviewed.add(i) } setReviewedRows(new Set(allReviewed)) setStatus('done') } if (event.type === 'error') { throw new Error(event.detail || 'Unbekannter Fehler') } } } // If stream ended without complete event if (allChanges.length === 0) { setStatus('done') } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) setError(msg) setStatus('error') } }, [sessionId, vocabEntries]) const toggleChange = (index: number) => { setAccepted(prev => { const next = new Set(prev) if (next.has(index)) next.delete(index) else next.add(index) return next }) } const toggleAll = () => { if (accepted.size === changes.length) { setAccepted(new Set()) } else { setAccepted(new Set(changes.map((_: LlmChange, i: number) => i))) } } const applyChanges = useCallback(async () => { if (!sessionId) return setApplying(true) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/llm-review/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accepted_indices: Array.from(accepted) }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.detail || `HTTP ${res.status}`) } setStatus('applied') } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) } finally { setApplying(false) } }, [sessionId, accepted]) const getRowStatus = (rowIndex: number): RowStatus => { if (activeRowIndices.has(rowIndex)) return 'active' if (skippedRows.has(rowIndex)) return 'skipped' if (correctedMap.has(rowIndex)) return 'corrected' if (reviewedRows.has(rowIndex)) return 'reviewed' return 'pending' } const dewarpedUrl = sessionId ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped` : '' if (!sessionId) { return
Bitte zuerst eine Session auswaehlen.
} // --- Loading session data --- if (status === 'loading' || status === 'idle') { return (
Session-Daten werden geladen...
) } // --- Error --- if (status === 'error') { return (
⚠️

Fehler bei OCR-Zeichenkorrektur

{error}

) } // --- Applied --- if (status === 'applied') { return (

Korrekturen uebernommen

{accepted.size} von {changes.length} Korrekturen wurden angewendet.

) } // Active entry for highlighting on image const activeEntry = vocabEntries.find((_: WordEntry, i: number) => activeRowIndices.has(i)) const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0 // --- Ready / Running / Done: 2-column layout --- return (
{/* Header */}

Schritt 6: Korrektur

{status === 'ready' && `${vocabEntries.length} Eintraege bereit zur Pruefung`} {status === 'running' && meta && `${meta.model} · ${meta.to_review} zu pruefen, ${meta.skipped} uebersprungen`} {status === 'done' && ( <> {changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden {meta && <> · {meta.skipped} uebersprungen} {' '}· {totalDuration}ms · {meta?.model} )}

{status === 'ready' && ( )} {status === 'running' && (
{progress ? `${progress.current}/${progress.total}` : 'Startet...'}
)} {status === 'done' && changes.length > 0 && ( )}
{/* Progress bar (while running) */} {status === 'running' && progress && (
{progress.current} / {progress.total} Eintraege geprueft {pct}%
)} {/* 2-column layout: Image + Table */}
{/* Left: Dewarped Image with highlight overlay */}
Originalbild
{/* eslint-disable-next-line @next/next/no-img-element */} Dewarped { const img = e.target as HTMLImageElement setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }) }} /> {/* Highlight overlay for active row */} {activeEntry?.bbox && (
)}
{/* Right: Full vocabulary table */}
Vokabeltabelle ({vocabEntries.length} Eintraege)
{columnsUsed.length > 0 ? ( columnsUsed.map((col, i) => { const field = COL_TYPE_TO_FIELD[col.type] if (!field) return null return ( ) }) ) : ( <> )} {vocabEntries.map((entry, idx) => { const rowStatus = getRowStatus(idx) const rowChanges = correctedMap.get(idx) const rowBg = { pending: '', active: 'bg-yellow-50 dark:bg-yellow-900/20', reviewed: '', corrected: 'bg-teal-50/50 dark:bg-teal-900/10', skipped: 'bg-gray-50 dark:bg-gray-800/50', }[rowStatus] return ( {columnsUsed.length > 0 ? ( columnsUsed.map((col, i) => { const field = COL_TYPE_TO_FIELD[col.type] if (!field) return null const text = (entry as Record)[field] as string || '' return ( ) }) ) : ( <> )} ) })}
# {FIELD_LABELS[field] || field} EN DE BeispielStatus
{idx}
{/* Done state: summary + actions */} {status === 'done' && (
{/* Summary */}
{changes.length === 0 ? ( Keine Korrekturen noetig — alle Eintraege sind korrekt. ) : ( {changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden ·{' '} {accepted.size} ausgewaehlt ·{' '} {meta?.skipped || 0} uebersprungen (Lautschrift) ·{' '} {totalDuration}ms )}
{/* Corrections detail list (if any) */} {changes.length > 0 && (
Korrekturvorschlaege ({accepted.size}/{changes.length} ausgewaehlt)
{changes.map((change, idx) => ( ))}
Zeile Feld Vorher Nachher
toggleChange(idx)} className="rounded border-gray-300 dark:border-gray-600" /> R{change.row_index} {FIELD_LABELS[change.field] || change.field} {change.old} {change.new}
)} {/* Actions */}

{changes.length > 0 ? `${accepted.size} von ${changes.length} ausgewaehlt` : ''}

{changes.length > 0 && ( )} {changes.length > 0 ? ( ) : ( )}
)}
) } /** Cell content with inline diff for corrections */ function CellContent({ text, field, rowChanges }: { text: string field: string rowChanges?: LlmChange[] }) { const change = rowChanges?.find(c => c.field === field) if (!text && !change) { return } if (change) { return ( {change.old} {change.new} ) } return {text} } /** Status icon for each row */ function StatusIcon({ status }: { status: RowStatus }) { switch (status) { case 'pending': return case 'active': return ( ) case 'reviewed': return ( ) case 'corrected': return ( korr. ) case 'skipped': return ( skip ) } }