'use client' /** * OCR Labeling Admin Page * * Labeling interface for handwriting training data collection. * DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama). */ import { useState, useEffect, useCallback, useRef } from 'react' import AdminLayout from '@/components/admin/AdminLayout' import type { OCRSession, OCRItem, OCRStats, TrainingSample, CreateSessionRequest, OCRModel, } from './types' // API Base URL for klausur-service const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086' // Tab definitions type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export' const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [ { id: 'labeling', name: 'Labeling', icon: ( ), }, { id: 'sessions', name: 'Sessions', icon: ( ), }, { id: 'upload', name: 'Upload', icon: ( ), }, { id: 'stats', name: 'Statistiken', icon: ( ), }, { id: 'export', name: 'Export', icon: ( ), }, ] export default function OCRLabelingPage() { const [activeTab, setActiveTab] = useState('labeling') const [sessions, setSessions] = useState([]) const [selectedSession, setSelectedSession] = useState(null) const [queue, setQueue] = useState([]) const [currentItem, setCurrentItem] = useState(null) const [currentIndex, setCurrentIndex] = useState(0) const [stats, setStats] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [correctedText, setCorrectedText] = useState('') const [labelStartTime, setLabelStartTime] = useState(null) // Fetch sessions const fetchSessions = useCallback(async () => { try { const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`) if (res.ok) { const data = await res.json() setSessions(data) } } catch (err) { console.error('Failed to fetch sessions:', err) } }, []) // Fetch queue const fetchQueue = useCallback(async () => { try { const url = selectedSession ? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20` : `${API_BASE}/api/v1/ocr-label/queue?limit=20` const res = await fetch(url) if (res.ok) { const data = await res.json() setQueue(data) if (data.length > 0 && !currentItem) { setCurrentItem(data[0]) setCurrentIndex(0) setCorrectedText(data[0].ocr_text || '') setLabelStartTime(Date.now()) } } } catch (err) { console.error('Failed to fetch queue:', err) } }, [selectedSession, currentItem]) // Fetch stats const fetchStats = useCallback(async () => { try { const url = selectedSession ? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}` : `${API_BASE}/api/v1/ocr-label/stats` const res = await fetch(url) if (res.ok) { const data = await res.json() setStats(data) } } catch (err) { console.error('Failed to fetch stats:', err) } }, [selectedSession]) // Initial data load useEffect(() => { const loadData = async () => { setLoading(true) await Promise.all([fetchSessions(), fetchQueue(), fetchStats()]) setLoading(false) } loadData() }, [fetchSessions, fetchQueue, fetchStats]) // Refresh queue when session changes useEffect(() => { setCurrentItem(null) setCurrentIndex(0) fetchQueue() fetchStats() }, [selectedSession, fetchQueue, fetchStats]) // Navigate to next item const goToNext = () => { if (currentIndex < queue.length - 1) { const nextIndex = currentIndex + 1 setCurrentIndex(nextIndex) setCurrentItem(queue[nextIndex]) setCorrectedText(queue[nextIndex].ocr_text || '') setLabelStartTime(Date.now()) } else { // Refresh queue fetchQueue() } } // Navigate to previous item const goToPrev = () => { if (currentIndex > 0) { const prevIndex = currentIndex - 1 setCurrentIndex(prevIndex) setCurrentItem(queue[prevIndex]) setCorrectedText(queue[prevIndex].ocr_text || '') setLabelStartTime(Date.now()) } } // Calculate label time const getLabelTime = (): number | undefined => { if (!labelStartTime) return undefined return Math.round((Date.now() - labelStartTime) / 1000) } // Confirm item const confirmItem = async () => { if (!currentItem) return try { const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_id: currentItem.id, label_time_seconds: getLabelTime(), }), }) if (res.ok) { // Remove from queue and go to next setQueue(prev => prev.filter(item => item.id !== currentItem.id)) goToNext() fetchStats() } else { setError('Bestaetigung fehlgeschlagen') } } catch (err) { setError('Netzwerkfehler') } } // Correct item const correctItem = async () => { if (!currentItem || !correctedText.trim()) return try { const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_id: currentItem.id, ground_truth: correctedText.trim(), label_time_seconds: getLabelTime(), }), }) if (res.ok) { setQueue(prev => prev.filter(item => item.id !== currentItem.id)) goToNext() fetchStats() } else { setError('Korrektur fehlgeschlagen') } } catch (err) { setError('Netzwerkfehler') } } // Skip item const skipItem = async () => { if (!currentItem) return try { const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ item_id: currentItem.id }), }) if (res.ok) { setQueue(prev => prev.filter(item => item.id !== currentItem.id)) goToNext() fetchStats() } else { setError('Ueberspringen fehlgeschlagen') } } catch (err) { setError('Netzwerkfehler') } } // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle if not in text input if (e.target instanceof HTMLTextAreaElement) return if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() confirmItem() } else if (e.key === 'ArrowRight') { goToNext() } else if (e.key === 'ArrowLeft') { goToPrev() } else if (e.key === 's' && !e.ctrlKey && !e.metaKey) { skipItem() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [currentItem, correctedText]) // Render Labeling Tab const renderLabelingTab = () => (
{/* Left: Image Viewer */}

Bild

{currentIndex + 1} / {queue.length}
{currentItem ? (
OCR Bild { // Fallback if image fails to load const target = e.target as HTMLImageElement target.style.display = 'none' }} />
) : (

Keine Bilder in der Warteschlange

)}
{/* Right: OCR Text & Actions */}
{/* OCR Result */}

OCR-Ergebnis

{currentItem?.ocr_confidence && ( 0.8 ? 'bg-green-100 text-green-800' : currentItem.ocr_confidence > 0.5 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' }`}> {Math.round(currentItem.ocr_confidence * 100)}% Konfidenz )}
{currentItem?.ocr_text || Kein OCR-Text}
{/* Correction Input */}

Korrektur