'use client' /** * Ground-Truth Review Workflow * * Efficient mass-review of OCR sessions: * - Session queue with auto-advance * - Split-view: original image left, grid right * - Confidence highlighting on cells * - Quick-accept per row * - Inline cell editing * - Batch mark as ground truth * - Progress tracking */ import { useState, useEffect, useCallback, useRef } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar' const KLAUSUR_API = '/klausur-api' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface Session { id: string name: string filename: string status: string created_at: string document_category: string | null has_ground_truth: boolean } interface GridZone { zone_id: string zone_type: string columns: Array<{ col_index: number; col_type: string; header: string }> rows: Array<{ row_index: number; is_header: boolean }> cells: GridCell[] } interface GridCell { cell_id: string row_index: number col_index: number col_type: string text: string confidence?: number is_bold?: boolean } interface GridResult { zones: GridZone[] summary?: { total_zones: number total_columns: number total_rows: number total_cells: number } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function confidenceColor(conf: number | undefined): string { if (conf === undefined) return '' if (conf >= 80) return 'bg-emerald-50' if (conf >= 50) return 'bg-amber-50' return 'bg-red-50' } function confidenceBorder(conf: number | undefined): string { if (conf === undefined) return 'border-slate-200' if (conf >= 80) return 'border-emerald-200' if (conf >= 50) return 'border-amber-300' return 'border-red-300' } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function GroundTruthReviewPage() { // Session list & queue const [allSessions, setAllSessions] = useState([]) const [filter, setFilter] = useState<'all' | 'unreviewed' | 'reviewed'>('unreviewed') const [currentIdx, setCurrentIdx] = useState(0) const [loading, setLoading] = useState(true) // Current session data const [grid, setGrid] = useState(null) const [loadingGrid, setLoadingGrid] = useState(false) const [editingCell, setEditingCell] = useState(null) const [editText, setEditText] = useState('') const [acceptedRows, setAcceptedRows] = useState>(new Set()) const [zoom, setZoom] = useState(100) // Batch operations const [selectedSessions, setSelectedSessions] = useState>(new Set()) const [marking, setMarking] = useState(false) const [markResult, setMarkResult] = useState(null) // Stats const [reviewedCount, setReviewedCount] = useState(0) const [totalCount, setTotalCount] = useState(0) const imageRef = useRef(null) // Load all sessions const loadSessions = useCallback(async () => { setLoading(true) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions?limit=200`) if (!res.ok) return const data = await res.json() const sessions: Session[] = (data.sessions || []).map((s: any) => ({ id: s.id, name: s.name || '', filename: s.filename || '', status: s.status || 'active', created_at: s.created_at || '', document_category: s.document_category || null, has_ground_truth: !!(s.ground_truth && s.ground_truth.build_grid_reference), })) setAllSessions(sessions) setTotalCount(sessions.length) setReviewedCount(sessions.filter(s => s.has_ground_truth).length) } catch (e) { console.error('Failed to load sessions:', e) } finally { setLoading(false) } }, []) useEffect(() => { loadSessions() }, [loadSessions]) // Filtered sessions const filteredSessions = allSessions.filter(s => { if (filter === 'unreviewed') return !s.has_ground_truth && s.status === 'active' if (filter === 'reviewed') return s.has_ground_truth return true }) const currentSession = filteredSessions[currentIdx] || null // Load grid for current session const loadGrid = useCallback(async (sessionId: string) => { setLoadingGrid(true) setGrid(null) setAcceptedRows(new Set()) setEditingCell(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/grid-editor`) if (res.ok) { const data = await res.json() setGrid(data.grid || data) } } catch (e) { console.error('Failed to load grid:', e) } finally { setLoadingGrid(false) } }, []) useEffect(() => { if (currentSession) loadGrid(currentSession.id) }, [currentSession, loadGrid]) // Navigation const goNext = () => { if (currentIdx < filteredSessions.length - 1) setCurrentIdx(currentIdx + 1) } const goPrev = () => { if (currentIdx > 0) setCurrentIdx(currentIdx - 1) } // Accept row const acceptRow = (zoneId: string, rowIdx: number) => { const key = `${zoneId}-${rowIdx}` setAcceptedRows(prev => new Set([...prev, key])) } // Edit cell const startEdit = (cell: GridCell) => { setEditingCell(cell.cell_id) setEditText(cell.text) } const saveEdit = async () => { if (!editingCell || !currentSession) return try { await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${currentSession.id}/update-cell`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cell_id: editingCell, text: editText }), }) // Update local state if (grid) { const newGrid = { ...grid } for (const zone of newGrid.zones) { for (const cell of zone.cells) { if (cell.cell_id === editingCell) { cell.text = editText } } } setGrid(newGrid) } } catch (e) { console.error('Failed to save cell:', e) } setEditingCell(null) } // Mark as ground truth const markGroundTruth = async (sessionId: string) => { setMarking(true) setMarkResult(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth`, { method: 'POST', }) if (res.ok) { setMarkResult('success') // Update local session state setAllSessions(prev => prev.map(s => s.id === sessionId ? { ...s, has_ground_truth: true } : s )) setReviewedCount(prev => prev + 1) } else { setMarkResult('error') } } catch { setMarkResult('error') } finally { setMarking(false) } } // Batch mark const batchMark = async () => { setMarking(true) let success = 0 for (const sid of selectedSessions) { try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}/mark-ground-truth`, { method: 'POST', }) if (res.ok) success++ } catch { /* skip */ } } setAllSessions(prev => prev.map(s => selectedSessions.has(s.id) ? { ...s, has_ground_truth: true } : s )) setReviewedCount(prev => prev + success) setSelectedSessions(new Set()) setMarking(false) setMarkResult(`${success} Sessions als Ground Truth markiert`) setTimeout(() => setMarkResult(null), 3000) } // All cells for current grid const allCells = grid?.zones?.flatMap(z => z.cells) || [] const lowConfCells = allCells.filter(c => (c.confidence ?? 100) < 50) const imageUrl = currentSession ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${currentSession.id}/image/original` : null return (
{/* Progress Bar */}

Ground Truth Review

{reviewedCount} von {totalCount} geprueft ({totalCount > 0 ? Math.round(reviewedCount / totalCount * 100) : 0}%)
0 ? (reviewedCount / totalCount) * 100 : 0}%` }} />
{/* Filter + Queue */}
{(['unreviewed', 'reviewed', 'all'] as const).map(f => ( ))}
{/* Navigation */}
{filteredSessions.length > 0 ? `${currentIdx + 1} / ${filteredSessions.length}` : '—'}
{/* Batch mark button */} {selectedSessions.size > 0 && ( )}
{/* Toast */} {markResult && (
{markResult === 'success' ? 'Als Ground Truth markiert!' : markResult === 'error' ? 'Fehler beim Markieren' : markResult}
)} {/* Main Content: Split View */} {loading ? (
Lade Sessions...
) : !currentSession ? (

Keine Sessions in dieser Ansicht

) : (
{/* Left: Original Image */}
{currentSession.name || currentSession.filename}
{zoom}%
{imageUrl && ( Original scan )}
{/* Right: Grid Review */}
{allCells.length} Zellen {lowConfCells.length > 0 && ( {lowConfCells.length} niedrige Konfidenz )}
{!currentSession.has_ground_truth && ( )} {currentSession.has_ground_truth && ( Ground Truth )}
{/* Grid Content */}
{loadingGrid ? (
Lade Grid...
) : !grid || !grid.zones ? (
Kein Grid vorhanden. Bitte zuerst die Pipeline ausfuehren.
) : (
{grid.zones.map((zone, zi) => (
{/* Zone header */}
Zone {zi + 1} ({zone.zone_type}) {zone.columns?.length > 0 && ( {zone.columns.map(c => c.col_type.replace('column_', '')).join(' | ')} )}
{/* Group cells by row */} {Array.from(new Set(zone.cells.map(c => c.row_index))) .sort((a, b) => a - b) .map(rowIdx => { const rowCells = zone.cells .filter(c => c.row_index === rowIdx) .sort((a, b) => a.col_index - b.col_index) const rowKey = `${zone.zone_id || zi}-${rowIdx}` const isAccepted = acceptedRows.has(rowKey) return (
{/* Quick accept button */} {/* Cells */}
{rowCells.map(cell => (
!isAccepted && startEdit(cell)} title={`Konfidenz: ${cell.confidence ?? '?'}% | ${cell.col_type}`} > {editingCell === cell.cell_id ? ( setEditText(e.target.value)} onBlur={saveEdit} onKeyDown={e => { if (e.key === 'Enter') saveEdit() if (e.key === 'Escape') setEditingCell(null) }} className="w-full bg-transparent outline-none text-sm" /> ) : ( {cell.text || '(leer)'} )}
))}
) })}
))}
)}
)} {/* Session List (collapsed) */} {filteredSessions.length > 1 && (
Session-Liste ({filteredSessions.length})
{filteredSessions.map((s, idx) => (
setCurrentIdx(idx)} > { e.stopPropagation() setSelectedSessions(prev => { const next = new Set(prev) if (next.has(s.id)) next.delete(s.id) else next.add(s.id) return next }) }} className="rounded border-slate-300" /> {s.name || s.filename || s.id} {s.document_category && ( {s.document_category} )}
))}
)}
) }