A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
947 lines
36 KiB
TypeScript
947 lines
36 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, 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: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
id: 'sessions',
|
|
name: 'Sessions',
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
id: 'upload',
|
|
name: 'Upload',
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
id: 'stats',
|
|
name: 'Statistiken',
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
</svg>
|
|
),
|
|
},
|
|
{
|
|
id: 'export',
|
|
name: 'Export',
|
|
icon: (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
),
|
|
},
|
|
]
|
|
|
|
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)
|
|
|
|
// 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 = () => (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Left: Image Viewer */}
|
|
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold">Bild</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={goToPrev}
|
|
disabled={currentIndex === 0}
|
|
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
|
title="Zurueck (Pfeiltaste links)"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<span className="text-sm text-slate-600">
|
|
{currentIndex + 1} / {queue.length}
|
|
</span>
|
|
<button
|
|
onClick={goToNext}
|
|
disabled={currentIndex >= queue.length - 1}
|
|
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
|
title="Weiter (Pfeiltaste rechts)"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{currentItem ? (
|
|
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
|
<img
|
|
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
|
alt="OCR Bild"
|
|
className="w-full h-auto max-h-[600px] object-contain"
|
|
onError={(e) => {
|
|
// Fallback if image fails to load
|
|
const target = e.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
|
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: OCR Text & Actions */}
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<div className="space-y-4">
|
|
{/* OCR Result */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
|
{currentItem?.ocr_confidence && (
|
|
<span className={`text-sm px-2 py-1 rounded ${
|
|
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
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
|
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Correction Input */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
|
<textarea
|
|
value={correctedText}
|
|
onChange={(e) => setCorrectedText(e.target.value)}
|
|
placeholder="Korrigierter Text..."
|
|
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={confirmItem}
|
|
disabled={!currentItem}
|
|
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Korrekt (Enter)
|
|
</button>
|
|
<button
|
|
onClick={correctItem}
|
|
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
|
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
</svg>
|
|
Korrektur speichern
|
|
</button>
|
|
<button
|
|
onClick={skipItem}
|
|
disabled={!currentItem}
|
|
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
</svg>
|
|
Ueberspringen (S)
|
|
</button>
|
|
</div>
|
|
|
|
{/* Keyboard Shortcuts */}
|
|
<div className="text-xs text-slate-500 mt-4">
|
|
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
|
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
|
<p>Pfeiltasten = Navigation</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom: Queue Preview */}
|
|
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
|
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
{queue.slice(0, 10).map((item, idx) => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => {
|
|
setCurrentIndex(idx)
|
|
setCurrentItem(item)
|
|
setCorrectedText(item.ocr_text || '')
|
|
setLabelStartTime(Date.now())
|
|
}}
|
|
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
|
idx === currentIndex
|
|
? 'border-primary-500'
|
|
: 'border-transparent hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<img
|
|
src={item.image_url || `${API_BASE}${item.image_path}`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</button>
|
|
))}
|
|
{queue.length > 10 && (
|
|
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
|
+{queue.length - 10} mehr
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
// Render Sessions Tab
|
|
const renderSessionsTab = () => {
|
|
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
|
name: '',
|
|
source_type: 'klausur',
|
|
description: '',
|
|
ocr_model: 'llama3.2-vision:11b',
|
|
})
|
|
|
|
const createSession = async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newSession),
|
|
})
|
|
|
|
if (res.ok) {
|
|
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
|
fetchSessions()
|
|
} else {
|
|
setError('Session erstellen fehlgeschlagen')
|
|
}
|
|
} catch (err) {
|
|
setError('Netzwerkfehler')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Create Session */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
value={newSession.name}
|
|
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
|
placeholder="z.B. Mathe Klausur Q1 2025"
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
|
<select
|
|
value={newSession.source_type}
|
|
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="klausur">Klausur</option>
|
|
<option value="handwriting_sample">Handschriftprobe</option>
|
|
<option value="scan">Scan</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
|
<select
|
|
value={newSession.ocr_model}
|
|
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
|
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
|
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
|
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
|
</select>
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
|
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
|
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
|
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
|
<input
|
|
type="text"
|
|
value={newSession.description}
|
|
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
|
placeholder="Optional..."
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={createSession}
|
|
disabled={!newSession.name}
|
|
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
Session erstellen
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sessions List */}
|
|
<div className="bg-white rounded-lg shadow">
|
|
<div className="px-6 py-4 border-b border-slate-200">
|
|
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
|
</div>
|
|
<div className="divide-y divide-slate-200">
|
|
{sessions.map((session) => (
|
|
<div
|
|
key={session.id}
|
|
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
|
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
|
}`}
|
|
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-medium">{session.name}</h4>
|
|
<p className="text-sm text-slate-500">
|
|
{session.source_type} | {session.ocr_model}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm font-medium">
|
|
{session.labeled_items}/{session.total_items} gelabelt
|
|
</p>
|
|
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
|
<div
|
|
className="bg-primary-600 rounded-full h-2"
|
|
style={{
|
|
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{session.description && (
|
|
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
{sessions.length === 0 && (
|
|
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Render Upload Tab
|
|
const renderUploadTab = () => {
|
|
const [uploading, setUploading] = useState(false)
|
|
const [uploadResults, setUploadResults] = useState<any[]>([])
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleUpload = async (files: FileList) => {
|
|
if (!selectedSession) {
|
|
setError('Bitte zuerst eine Session auswaehlen')
|
|
return
|
|
}
|
|
|
|
setUploading(true)
|
|
const formData = new FormData()
|
|
Array.from(files).forEach(file => formData.append('files', file))
|
|
formData.append('run_ocr', 'true')
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setUploadResults(data.items || [])
|
|
fetchQueue()
|
|
fetchStats()
|
|
} else {
|
|
setError('Upload fehlgeschlagen')
|
|
}
|
|
} catch (err) {
|
|
setError('Netzwerkfehler beim Upload')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Session Selection */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
|
<select
|
|
value={selectedSession || ''}
|
|
onChange={(e) => setSelectedSession(e.target.value || null)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="">-- Session waehlen --</option>
|
|
{sessions.map((session) => (
|
|
<option key={session.id} value={session.id}>
|
|
{session.name} ({session.total_items} Items)
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Upload Area */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
|
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
|
}`}
|
|
onDragOver={(e) => {
|
|
e.preventDefault()
|
|
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault()
|
|
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
|
if (e.dataTransfer.files.length > 0) {
|
|
handleUpload(e.dataTransfer.files)
|
|
}
|
|
}}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/png,image/jpeg,image/jpg"
|
|
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
|
className="hidden"
|
|
disabled={!selectedSession}
|
|
/>
|
|
{uploading ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
|
<p>Hochladen & OCR ausfuehren...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
<p className="text-slate-600 mb-2">
|
|
Bilder hierher ziehen oder{' '}
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={!selectedSession}
|
|
className="text-primary-600 hover:underline"
|
|
>
|
|
auswaehlen
|
|
</button>
|
|
</p>
|
|
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Upload Results */}
|
|
{uploadResults.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
|
<div className="space-y-2">
|
|
{uploadResults.map((result) => (
|
|
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
|
<span className="text-sm">{result.filename}</span>
|
|
<span className={`text-xs px-2 py-1 rounded ${
|
|
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
|
}`}>
|
|
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Render Stats Tab
|
|
const renderStatsTab = () => (
|
|
<div className="space-y-6">
|
|
{/* Global Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
|
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
|
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
|
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
|
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detailed Stats */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<p className="text-sm text-slate-500">Bestaetigt</p>
|
|
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-slate-500">Korrigiert</p>
|
|
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-slate-500">Exportierbar</p>
|
|
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
|
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
{stats?.total_items ? (
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
|
<div className="w-full bg-slate-200 rounded-full h-4">
|
|
<div
|
|
className="bg-primary-600 rounded-full h-4 transition-all"
|
|
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-slate-500 mt-2">
|
|
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
|
|
// Render Export Tab
|
|
const renderExportTab = () => {
|
|
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
|
const [exporting, setExporting] = useState(false)
|
|
const [exportResult, setExportResult] = useState<any>(null)
|
|
|
|
const handleExport = async () => {
|
|
setExporting(true)
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
export_format: exportFormat,
|
|
session_id: selectedSession,
|
|
}),
|
|
})
|
|
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setExportResult(data)
|
|
} else {
|
|
setError('Export fehlgeschlagen')
|
|
}
|
|
} catch (err) {
|
|
setError('Netzwerkfehler')
|
|
} finally {
|
|
setExporting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
|
<select
|
|
value={exportFormat}
|
|
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="generic">Generic JSON</option>
|
|
<option value="trocr">TrOCR Fine-Tuning</option>
|
|
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
|
<select
|
|
value={selectedSession || ''}
|
|
onChange={(e) => setSelectedSession(e.target.value || null)}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
>
|
|
<option value="">Alle Sessions</option>
|
|
{sessions.map((session) => (
|
|
<option key={session.id} value={session.id}>{session.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
|
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{exportResult && (
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
|
<p className="text-green-800">
|
|
{exportResult.exported_count} Samples erfolgreich exportiert
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
Batch: {exportResult.batch_id}
|
|
</p>
|
|
</div>
|
|
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
|
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
|
{(exportResult.samples?.length || 0) > 3 && (
|
|
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AdminLayout
|
|
title="OCR-Labeling"
|
|
description="Handschrift-Training & Ground Truth Erfassung"
|
|
>
|
|
{/* Error Toast */}
|
|
{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>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-6">
|
|
<div className="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>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{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' && renderLabelingTab()}
|
|
{activeTab === 'sessions' && renderSessionsTab()}
|
|
{activeTab === 'upload' && renderUploadTab()}
|
|
{activeTab === 'stats' && renderStatsTab()}
|
|
{activeTab === 'export' && renderExportTab()}
|
|
</>
|
|
)}
|
|
</AdminLayout>
|
|
)
|
|
}
|