Files
breakpilot-lehrer/website/app/admin/ocr-labeling/page.tsx
Benjamin Admin b6983ab1dc [split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 23:35:37 +02:00

124 lines
6.9 KiB
TypeScript

'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 } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import type { OCRSession, OCRItem, OCRStats } from './types'
import { API_BASE, tabs } from './constants'
import type { TabId } from './constants'
import LabelingTab from './_components/LabelingTab'
import SessionsTab from './_components/SessionsTab'
import UploadTab from './_components/UploadTab'
import StatsTab from './_components/StatsTab'
import ExportTab from './_components/ExportTab'
export default function OCRLabelingPage() {
const [activeTab, setActiveTab] = useState<TabId>('labeling')
const [sessions, setSessions] = useState<OCRSession[]>([])
const [selectedSession, setSelectedSession] = useState<string | null>(null)
const [queue, setQueue] = useState<OCRItem[]>([])
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
const [currentIndex, setCurrentIndex] = useState(0)
const [stats, setStats] = useState<OCRStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [correctedText, setCorrectedText] = useState('')
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
const fetchSessions = useCallback(async () => {
try { const r = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`); if (r.ok) setSessions(await r.json()) }
catch (e) { console.error('Failed to fetch sessions:', e) }
}, [])
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 r = await fetch(url)
if (r.ok) {
const data = await r.json(); setQueue(data)
if (data.length > 0 && !currentItem) { setCurrentItem(data[0]); setCurrentIndex(0); setCorrectedText(data[0].ocr_text || ''); setLabelStartTime(Date.now()) }
}
} catch (e) { console.error('Failed to fetch queue:', e) }
}, [selectedSession, currentItem])
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 r = await fetch(url); if (r.ok) setStats(await r.json())
} catch (e) { console.error('Failed to fetch stats:', e) }
}, [selectedSession])
useEffect(() => { setLoading(true); Promise.all([fetchSessions(), fetchQueue(), fetchStats()]).then(() => setLoading(false)) }, [fetchSessions, fetchQueue, fetchStats])
useEffect(() => { setCurrentItem(null); setCurrentIndex(0); fetchQueue(); fetchStats() }, [selectedSession, fetchQueue, fetchStats])
const getLabelTime = () => labelStartTime ? Math.round((Date.now() - labelStartTime) / 1000) : undefined
const setItem = (item: OCRItem, idx: number) => { setCurrentIndex(idx); setCurrentItem(item); setCorrectedText(item.ocr_text || ''); setLabelStartTime(Date.now()) }
const goToNext = () => {
if (currentIndex < queue.length - 1) setItem(queue[currentIndex + 1], currentIndex + 1)
else fetchQueue()
}
const goToPrev = () => { if (currentIndex > 0) setItem(queue[currentIndex - 1], currentIndex - 1) }
const postAction = async (endpoint: string, body: object) => {
const r = await fetch(`${API_BASE}/api/v1/ocr-label/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (r.ok) { setQueue(prev => prev.filter(i => i.id !== currentItem?.id)); goToNext(); fetchStats() }
else setError(`${endpoint} fehlgeschlagen`)
}
const confirmItem = () => { if (currentItem) postAction('confirm', { item_id: currentItem.id, label_time_seconds: getLabelTime() }).catch(() => setError('Netzwerkfehler')) }
const correctItem = () => { if (currentItem && correctedText.trim()) postAction('correct', { item_id: currentItem.id, ground_truth: correctedText.trim(), label_time_seconds: getLabelTime() }).catch(() => setError('Netzwerkfehler')) }
const skipItem = () => { if (currentItem) postAction('skip', { item_id: currentItem.id }).catch(() => setError('Netzwerkfehler')) }
useEffect(() => {
const h = (e: KeyboardEvent) => {
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', h); return () => window.removeEventListener('keydown', h)
}, [currentItem, correctedText])
return (
<AdminLayout title="OCR-Labeling" description="Handschrift-Training & Ground Truth Erfassung">
{error && (
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
<span>{error}</span>
<button onClick={() => setError(null)} className="ml-4">X</button>
</div>
)}
<div className="mb-6 border-b border-slate-200">
<nav className="flex space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${activeTab === tab.id ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}>
{tab.icon}{tab.name}
</button>
))}
</nav>
</div>
{loading ? (
<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /></div>
) : (
<>
{activeTab === 'labeling' && <LabelingTab queue={queue} currentItem={currentItem} currentIndex={currentIndex} correctedText={correctedText} setCorrectedText={setCorrectedText} onGoToPrev={goToPrev} onGoToNext={goToNext} onConfirm={confirmItem} onCorrect={correctItem} onSkip={skipItem} onSelectItem={setItem} />}
{activeTab === 'sessions' && <SessionsTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} onSessionCreated={fetchSessions} onError={setError} />}
{activeTab === 'upload' && <UploadTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} onUploadComplete={() => { fetchQueue(); fetchStats() }} onError={setError} />}
{activeTab === 'stats' && <StatsTab stats={stats} />}
{activeTab === 'export' && <ExportTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} stats={stats} onError={setError} />}
</>
)}
</AdminLayout>
)
}