Files
breakpilot-lehrer/admin-lehrer/components/ocr/GroundTruthPanel.tsx
Benjamin Admin b681ddb131 [split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)
Phase 1 — Python (klausur-service): 5 monoliths → 36 files
- dsfa_corpus_ingestion.py (1,828 LOC → 5 files)
- cv_ocr_engines.py (2,102 LOC → 7 files)
- cv_layout.py (3,653 LOC → 10 files)
- vocab_worksheet_api.py (2,783 LOC → 8 files)
- grid_build_core.py (1,958 LOC → 6 files)

Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files
- staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3)
- policy_handlers.go (700 → 2), repository.go (684 → 2)
- search.go (592 → 2), ai_extraction_handlers.go (554 → 2)
- seed_data.go (591 → 2), grade_service.go (646 → 2)

Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files
- sdk/types.ts (2,108 → 16 domain files)
- ai/rag/page.tsx (2,686 → 14 files)
- 22 page.tsx files split into _components/ + _hooks/
- 11 component files split into sub-components
- 10 SDK data catalogs added to loc-exceptions
- Deleted dead backup index_original.ts (4,899 LOC)

All original public APIs preserved via re-export facades.
Zero new errors: Python imports verified, Go builds clean,
TypeScript tsc --noEmit shows only pre-existing errors.

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

501 lines
19 KiB
TypeScript

'use client'
/**
* GroundTruthPanel — Step-through UI for labeling OCR ground truth.
*
* Shows page image with SVG overlay (color-coded bounding boxes),
* alongside crops of the current entry and editable text fields.
* Keyboard-driven: Enter=confirm, Tab=skip, Arrow keys=navigate.
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import type { GTEntry, GroundTruthPanelProps } from './ground-truth-types'
import { KLAUSUR_API, STATUS_COLORS, getEntryColor } from './ground-truth-types'
import { GTImageCrop } from './GTImageCrop'
import { GroundTruthSummary } from './GroundTruthSummary'
// ---------- Main Component ----------
export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: GroundTruthPanelProps) {
// State
const [entries, setEntries] = useState<GTEntry[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
const [showSummary, setShowSummary] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
const [imageUrl, setImageUrl] = useState(pageImageUrl)
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
// Editable fields for current entry
const [editEn, setEditEn] = useState('')
const [editDe, setEditDe] = useState('')
const [editEx, setEditEx] = useState('')
const panelRef = useRef<HTMLDivElement>(null)
const enInputRef = useRef<HTMLInputElement>(null)
// Reset image URL when page changes
useEffect(() => {
setImageUrl(pageImageUrl)
setDeskewAngle(null)
}, [pageImageUrl])
// Load natural image dimensions
useEffect(() => {
if (!imageUrl) return
const img = new Image()
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
img.src = imageUrl
}, [imageUrl])
// Sync edit fields when current entry changes
useEffect(() => {
const entry = entries[currentIndex]
if (entry) {
setEditEn(entry.english)
setEditDe(entry.german)
setEditEx(entry.example)
}
}, [currentIndex, entries])
// ---------- Actions ----------
const handleExtract = useCallback(async () => {
setLoading(true)
setError(null)
setShowSummary(false)
setSavedMessage(null)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/extract-with-boxes/${selectedPage}`, {
method: 'POST',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Extract failed')
}
const data = await res.json()
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
setEntries(loaded)
setCurrentIndex(0)
// Switch to deskewed image if available
if (data.deskewed) {
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
setDeskewAngle(data.deskew_angle)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed')
} finally {
setLoading(false)
}
}, [sessionId, selectedPage])
const confirmEntry = useCallback(() => {
if (entries.length === 0) return
const entry = entries[currentIndex]
const isEdited = editEn !== entry.english || editDe !== entry.german || editEx !== entry.example
const updated = [...entries]
updated[currentIndex] = {
...entry,
english: editEn,
german: editDe,
example: editEx,
status: isEdited ? 'edited' : 'confirmed',
}
setEntries(updated)
if (currentIndex < entries.length - 1) {
setCurrentIndex(currentIndex + 1)
} else {
setShowSummary(true)
}
}, [entries, currentIndex, editEn, editDe, editEx])
const skipEntry = useCallback(() => {
if (entries.length === 0) return
const updated = [...entries]
updated[currentIndex] = { ...updated[currentIndex], status: 'skipped' }
setEntries(updated)
if (currentIndex < entries.length - 1) {
setCurrentIndex(currentIndex + 1)
} else {
setShowSummary(true)
}
}, [entries, currentIndex])
const goTo = useCallback((idx: number) => {
if (idx >= 0 && idx < entries.length) {
setCurrentIndex(idx)
setShowSummary(false)
}
}, [entries.length])
const handleSave = useCallback(async () => {
setSaving(true)
setError(null)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ground-truth/${selectedPage}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries }),
})
if (!res.ok) throw new Error('Save failed')
const data = await res.json()
setSavedMessage(`Gespeichert: ${data.confirmed} bestaetigt, ${data.edited} editiert, ${data.skipped} uebersprungen`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}, [sessionId, selectedPage, entries])
// ---------- Keyboard shortcuts ----------
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
e.preventDefault()
setIsFullscreen(false)
return
}
if (entries.length === 0 || showSummary) return
// Don't capture when typing in inputs
const tag = (e.target as HTMLElement)?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmEntry()
} else if (e.key === 'Tab' && !e.shiftKey) {
if (!isInput) {
e.preventDefault()
skipEntry()
}
} else if (e.key === 'ArrowLeft' && !isInput) {
e.preventDefault()
goTo(currentIndex - 1)
} else if (e.key === 'ArrowRight' && !isInput) {
e.preventDefault()
goTo(currentIndex + 1)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
// ---------- Computed ----------
const currentEntry = entries[currentIndex]
const confirmedCount = entries.filter(e => e.status === 'confirmed').length
const editedCount = entries.filter(e => e.status === 'edited').length
const skippedCount = entries.filter(e => e.status === 'skipped').length
const processedCount = confirmedCount + editedCount + skippedCount
const progress = entries.length > 0 ? Math.round((processedCount / entries.length) * 100) : 0
// ---------- Render: No entries yet ----------
if (entries.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center" ref={panelRef}>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Ground Truth Labeling</h3>
<p className="text-sm text-slate-500 mb-6">
Erkennung starten um Vokabeln mit Positionen zu extrahieren.
Danach jede Zeile durchgehen und bestaetigen oder korrigieren.
</p>
<button
onClick={handleExtract}
disabled={loading}
className="px-6 py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Erkennung laeuft...
</span>
) : 'Erkennung starten'}
</button>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
)}
</div>
)
}
// ---------- Render: Summary ----------
if (showSummary) {
return (
<GroundTruthSummary
entries={entries}
confirmedCount={confirmedCount}
editedCount={editedCount}
skippedCount={skippedCount}
saving={saving}
savedMessage={savedMessage}
error={error}
isFullscreen={isFullscreen}
onSave={handleSave}
onRestart={() => { setShowSummary(false); setCurrentIndex(0) }}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
onGoTo={goTo}
/>
)
}
// ---------- Render: Main Review UI ----------
return (
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
}`} ref={panelRef}>
{/* Header with progress + fullscreen toggle */}
<div className="flex items-center gap-2 px-4 pt-2">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
<div
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
{deskewAngle !== null && (
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
{deskewAngle.toFixed(1)}&deg;
</span>
)}
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
{/* Left: Page image with SVG overlay (2/3) */}
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
{imageUrl && (
<img
src={imageUrl}
alt={`Seite ${selectedPage + 1}`}
className="w-full"
draggable={false}
/>
)}
{/* SVG Overlay */}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
>
{entries.map((entry, i) => {
const colors = getEntryColor(entry, i, currentIndex)
return (
<rect
key={i}
x={entry.bbox.x}
y={entry.bbox.y}
width={entry.bbox.w}
height={entry.bbox.h}
fill={colors.fill}
stroke={colors.stroke}
strokeWidth={i === currentIndex ? 0.3 : 0.15}
style={{ cursor: 'pointer', pointerEvents: 'all' }}
onClick={() => goTo(i)}
/>
)
})}
</svg>
</div>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.current.fill, border: `1px solid ${STATUS_COLORS.current.stroke}` }} /> Aktuell
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.confirmed.fill, border: `1px solid ${STATUS_COLORS.confirmed.stroke}` }} /> Bestaetigt
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.edited.fill, border: `1px solid ${STATUS_COLORS.edited.stroke}` }} /> Editiert
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.skipped.fill, border: `1px solid ${STATUS_COLORS.skipped.stroke}` }} /> Uebersprungen
</span>
</div>
</div>
{/* Right: Crops + Edit fields (1/3) */}
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
{currentEntry && (
<>
{/* Row crop */}
{imageNatural.w > 0 && (
<GTImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
label="Gesamte Zeile"
/>
)}
{/* Column crops */}
{imageNatural.w > 0 && (
<div className="grid grid-cols-3 gap-2">
{currentEntry.bbox_en.w > 0 && (
<GTImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox_en}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
maxWidth={120}
label="EN"
/>
)}
{currentEntry.bbox_de.w > 0 && (
<GTImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox_de}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
maxWidth={120}
label="DE"
/>
)}
{currentEntry.bbox_ex.w > 0 && (
<GTImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox_ex}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
maxWidth={120}
label="EX"
/>
)}
</div>
)}
{/* Confidence badge */}
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
currentEntry.confidence >= 70 ? 'bg-green-100 text-green-700' :
currentEntry.confidence >= 40 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
Konfidenz: {currentEntry.confidence}%
</span>
</div>
{/* Edit fields */}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">English</label>
<input
ref={enInputRef}
type="text"
value={editEn}
onChange={e => setEditEn(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Deutsch</label>
<input
type="text"
value={editDe}
onChange={e => setEditDe(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Beispiel</label>
<input
type="text"
value={editEx}
onChange={e => setEditEx(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
onClick={confirmEntry}
className="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 text-sm"
title="Enter"
>
OK (Enter)
</button>
<button
onClick={skipEntry}
className="flex-1 px-4 py-2.5 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 text-sm"
title="Tab"
>
Skip (Tab)
</button>
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => goTo(currentIndex - 1)}
disabled={currentIndex === 0}
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
>
&larr; Zurueck
</button>
<span className="text-sm text-slate-500 font-medium">
{currentIndex + 1} / {entries.length}
</span>
<button
onClick={() => goTo(currentIndex + 1)}
disabled={currentIndex === entries.length - 1}
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
>
Weiter &rarr;
</button>
</div>
{/* Progress stats */}
<div className="text-xs text-slate-400 text-center">
{confirmedCount} bestaetigt &middot; {editedCount} editiert &middot; {skippedCount} uebersprungen &middot; {progress}%
</div>
{/* Keyboard hints */}
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
Enter = Bestaetigen &middot; Tab = Ueberspringen &middot; &larr;&rarr; = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
</div>
</>
)}
</div>
</div>
{error && (
<div className="mx-4 mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
)}
</div>
)
}