'use client' import { useCallback, useRef, useState } from 'react' 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 } interface StreamProgress { current: number total: number } const FIELD_LABELS: Record = { english: 'EN', german: 'DE', example: 'Beispiel', } export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { const [status, setStatus] = useState<'idle' | 'running' | 'done' | 'error' | 'applied'>('idle') const [meta, setMeta] = useState(null) const [changes, setChanges] = useState([]) const [progress, setProgress] = useState(null) const [batchLog, setBatchLog] = useState([]) const [totalDuration, setTotalDuration] = useState(0) const [error, setError] = useState('') const [accepted, setAccepted] = useState>(new Set()) const [applying, setApplying] = useState(false) const tableEndRef = useRef(null) const runReview = useCallback(async () => { if (!sessionId) return setStatus('running') setError('') setChanges([]) setBatchLog([]) setProgress(null) setMeta(null) setTotalDuration(0) 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[] = [] 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, }) setBatchLog([`${event.total_entries} Eintraege, ${event.skipped} uebersprungen (Lautschrift), ${event.to_review} zu pruefen`]) } if (event.type === 'batch') { const batchChanges: LlmChange[] = event.changes || [] allChanges = [...allChanges, ...batchChanges] setChanges(allChanges) setProgress(event.progress) const rows = (event.entries_reviewed || []).map((r: number) => `R${r}`).join(', ') setBatchLog(prev => [...prev, `Batch ${event.batch_index + 1}: ${rows} — ${batchChanges.length} Korrektur${batchChanges.length !== 1 ? 'en' : ''} (${event.duration_ms}ms)` ]) setTimeout(() => tableEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 16) } if (event.type === 'complete') { setTotalDuration(event.duration_ms) setAccepted(new Set(allChanges.map((_, i) => i))) setStatus('done') } if (event.type === 'error') { throw new Error(event.detail || 'Unbekannter Fehler') } } } // If no complete event was received (e.g. 0 entries to review) if (allChanges.length === 0 && status !== 'done') { setStatus('done') } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) setError(msg) setStatus('error') } }, [sessionId]) 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((_, i) => 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]) if (!sessionId) { return
Bitte zuerst eine Session auswaehlen.
} // --- Idle --- if (status === 'idle') { return (
🤖

Schritt 6: LLM-Korrektur

Ein lokales Sprachmodell prueft die OCR-Ergebnisse auf typische Erkennungsfehler. Eintraege mit Lautschrift werden automatisch uebersprungen.

Modell: qwen3:30b-a3b via Ollama (lokal)

) } // --- Running (with live progress) --- if (status === 'running') { const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0 return (

LLM-Korrektur laeuft...

{meta && ( {meta.model} )}
{/* Progress bar */} {progress && (
{progress.current} / {progress.total} Eintraege geprueft {pct}%
)} {/* Live batch log */}
{batchLog.map((line, i) => (
{line}
))}
{/* Live changes appearing */} {changes.length > 0 && (
{changes.map((change, idx) => ( ))}
Zeile Feld Vorher Nachher
R{change.row_index} {FIELD_LABELS[change.field] || change.field} {change.old} {change.new}
)}
) } // --- Error --- if (status === 'error') { return (
⚠️

Fehler bei LLM-Korrektur

{error}

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

Korrekturen uebernommen

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

) } // --- Done: diff table with checkboxes --- if (changes.length === 0) { return (
👍

Keine Korrekturen noetig

Das LLM hat keine OCR-Fehler gefunden.

{meta && (

{meta.to_review} geprueft, {meta.skipped} uebersprungen · {totalDuration}ms · {meta.model}

)}
) } return (
{/* Header */}

LLM-Korrekturvorschlaege

{changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden {meta && <> · {meta.skipped} uebersprungen (Lautschrift)} {' '}· {totalDuration}ms · {meta?.model}

{/* Diff table */}
{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 */}

{accepted.size} von {changes.length} ausgewaehlt

) }