'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { GridCell, ColumnMeta, ImageRegion, ImageStyle, } from '@/app/(admin)/ai/ocr-pipeline/types' import { IMAGE_STYLES as STYLES } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' const COL_TYPE_COLORS: Record = { column_en: '#3b82f6', column_de: '#22c55e', column_example: '#f97316', column_text: '#a855f7', page_ref: '#06b6d4', column_marker: '#6b7280', } interface StepGroundTruthProps { sessionId: string | null onNext: () => void } interface SessionData { cells: GridCell[] columnsUsed: ColumnMeta[] imageWidth: number imageHeight: number originalImageUrl: string } export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) { const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading') const [error, setError] = useState('') const [session, setSession] = useState(null) const [imageRegions, setImageRegions] = useState<(ImageRegion & { generating?: boolean })[]>([]) const [detecting, setDetecting] = useState(false) const [zoom, setZoom] = useState(100) const [syncScroll, setSyncScroll] = useState(true) const [notes, setNotes] = useState('') const [score, setScore] = useState(null) const [drawingRegion, setDrawingRegion] = useState(false) const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null) const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null) const [isGroundTruth, setIsGroundTruth] = useState(false) const [gtSaving, setGtSaving] = useState(false) const [gtMessage, setGtMessage] = useState('') const leftPanelRef = useRef(null) const rightPanelRef = useRef(null) const reconRef = useRef(null) const [reconWidth, setReconWidth] = useState(0) // Track reconstruction container width for font size calculation useEffect(() => { const el = reconRef.current if (!el) return const obs = new ResizeObserver(entries => { for (const entry of entries) setReconWidth(entry.contentRect.width) }) obs.observe(el) return () => obs.disconnect() }, [session]) // Load session data useEffect(() => { if (!sessionId) return loadSessionData() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) const loadSessionData = async () => { if (!sessionId) return setStatus('loading') try { const resp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) if (!resp.ok) throw new Error(`Failed to load session: ${resp.status}`) const data = await resp.json() const wordResult = data.word_result || {} setSession({ cells: wordResult.cells || [], columnsUsed: wordResult.columns_used || [], imageWidth: wordResult.image_width || data.image_width || 800, imageHeight: wordResult.image_height || data.image_height || 600, originalImageUrl: data.original_image_url ? `${KLAUSUR_API}${data.original_image_url}` : `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/original`, }) // Check if session has ground truth reference const gt = data.ground_truth setIsGroundTruth(!!gt?.build_grid_reference) // Load existing validation data const valResp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validation`) if (valResp.ok) { const valData = await valResp.json() const validation = valData.validation if (validation) { setImageRegions(validation.image_regions || []) setNotes(validation.notes || '') setScore(validation.score ?? null) } } setStatus('ready') } catch (e) { setError(e instanceof Error ? e.message : String(e)) setStatus('error') } } // Sync scroll between panels const handleScroll = useCallback((source: 'left' | 'right') => { if (!syncScroll) return const from = source === 'left' ? leftPanelRef.current : rightPanelRef.current const to = source === 'left' ? rightPanelRef.current : leftPanelRef.current if (from && to) { to.scrollTop = from.scrollTop to.scrollLeft = from.scrollLeft } }, [syncScroll]) // Detect images via VLM const handleDetectImages = async () => { if (!sessionId) return setDetecting(true) try { const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/detect-images`, { method: 'POST' } ) if (!resp.ok) throw new Error(`Detection failed: ${resp.status}`) const data = await resp.json() setImageRegions(data.regions || []) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setDetecting(false) } } // Generate image for a region const handleGenerateImage = async (index: number) => { if (!sessionId) return const region = imageRegions[index] if (!region) return setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: true } : r)) try { const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/generate-image`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ region_index: index, prompt: region.prompt, style: region.style, }), } ) if (!resp.ok) throw new Error(`Generation failed: ${resp.status}`) const data = await resp.json() setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, image_b64: data.image_b64, generating: false } : r )) } catch (e) { setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: false } : r)) setError(e instanceof Error ? e.message : String(e)) } } // Save validation const handleSave = async () => { if (!sessionId) { setError('Keine Session-ID vorhanden') return } setStatus('saving') setError('') try { const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes, score: score ?? 0 }), } ) if (!resp.ok) { const body = await resp.text().catch(() => '') throw new Error(`Speichern fehlgeschlagen (${resp.status}): ${body}`) } setStatus('saved') } catch (e) { setError(e instanceof Error ? e.message : String(e)) setStatus('ready') } } // Mark/update ground truth reference const handleMarkGroundTruth = async () => { if (!sessionId) return setGtSaving(true) setGtMessage('') try { const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth`, { method: 'POST' } ) if (!resp.ok) { const body = await resp.text().catch(() => '') throw new Error(`Ground Truth fehlgeschlagen (${resp.status}): ${body}`) } const data = await resp.json() setIsGroundTruth(true) setGtMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`) setTimeout(() => setGtMessage(''), 5000) } catch (e) { setGtMessage(e instanceof Error ? e.message : String(e)) } finally { setGtSaving(false) } } // Handle manual region drawing on reconstruction const handleReconMouseDown = (e: React.MouseEvent) => { if (!drawingRegion) return const rect = e.currentTarget.getBoundingClientRect() const x = ((e.clientX - rect.left) / rect.width) * 100 const y = ((e.clientY - rect.top) / rect.height) * 100 setDragStart({ x, y }) setDragEnd({ x, y }) } const handleReconMouseMove = (e: React.MouseEvent) => { if (!dragStart) return const rect = e.currentTarget.getBoundingClientRect() const x = ((e.clientX - rect.left) / rect.width) * 100 const y = ((e.clientY - rect.top) / rect.height) * 100 setDragEnd({ x, y }) } const handleReconMouseUp = () => { if (!dragStart || !dragEnd) return const x = Math.min(dragStart.x, dragEnd.x) const y = Math.min(dragStart.y, dragEnd.y) const w = Math.abs(dragEnd.x - dragStart.x) const h = Math.abs(dragEnd.y - dragStart.y) if (w > 2 && h > 2) { setImageRegions(prev => [...prev, { bbox_pct: { x, y, w, h }, prompt: '', description: 'Manually selected region', image_b64: null, style: 'educational' as ImageStyle, }]) } setDragStart(null) setDragEnd(null) setDrawingRegion(false) } const handleRemoveRegion = (index: number) => { setImageRegions(prev => prev.filter((_, i) => i !== index)) } if (status === 'loading') { return (
Session wird geladen...
) } if (status === 'error' && !session) { return (

{error}

) } if (!session) return null const aspect = session.imageHeight / session.imageWidth return (
{/* Header / Controls */}

Validierung — Original vs. Rekonstruktion

{zoom}%
{error && (
{error}
)} {/* Side-by-side panels */}
{/* Left: Original */}
Original
handleScroll('left')} >
Original
{/* Right: Reconstruction */}
Rekonstruktion
handleScroll('right')} >
{/* Reconstruction container */}
{/* Row separator lines — derive from cells */} {(() => { const rowYs = new Set() for (const cell of session.cells) { if (cell.col_index === 0 && cell.bbox_pct) { rowYs.add(cell.bbox_pct.y) } } return Array.from(rowYs).map((y, i) => (
)) })()} {/* Cell texts — black on white, font size derived from cell height */} {session.cells.map(cell => { if (!cell.bbox_pct || !cell.text) return null // Container height in px = reconWidth * aspect // Cell height in px = containerHeightPx * (bbox_pct.h / 100) // Font size ≈ 70% of cell height const containerH = reconWidth * aspect const cellHeightPx = containerH * (cell.bbox_pct.h / 100) const fontSize = Math.max(6, cellHeightPx * 0.7) return ( {cell.text} ) })} {/* Generated images at region positions */} {imageRegions.map((region, i) => (
{region.image_b64 ? ( {region.description} ) : (
{region.generating ? '...' : `Bild ${i + 1}`}
)}
))} {/* Drawing rectangle */} {dragStart && dragEnd && (
)}
{/* Image regions panel */} {imageRegions.length > 0 && (

Bildbereiche ({imageRegions.length} gefunden)

{imageRegions.map((region, i) => (
{/* Preview thumbnail */}
{region.image_b64 ? ( ) : (
{Math.round(region.bbox_pct.w)}x{Math.round(region.bbox_pct.h)}%
)}
{/* Prompt + controls */}
Bereich {i + 1}: { setImageRegions(prev => prev.map((r, j) => j === i ? { ...r, prompt: e.target.value } : r )) }} placeholder="Beschreibung / Prompt..." className="flex-1 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
{region.description && region.description !== region.prompt && (

{region.description}

)}
))}
)} {/* Notes and score */}
setScore(e.target.value ? parseInt(e.target.value) : null)} className="w-20 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(v => ( ))}