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>
124 lines
6.9 KiB
TypeScript
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>
|
|
)
|
|
}
|