'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { ImageStyle } from '@/app/(admin)/ai/ocr-kombi/types' import type { SessionData, ImageRegionWithState, GroundTruthStatus, } from './ground-truth-types' import { KLAUSUR_API } from './ground-truth-types' export function useGroundTruthSession(sessionId: string | null) { const [status, setStatus] = useState('loading') const [error, setError] = useState('') const [session, setSession] = useState(null) const [imageRegions, setImageRegions] = useState([]) 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]) const loadSessionData = useCallback(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') } }, [sessionId]) // Load session data useEffect(() => { if (!sessionId) return loadSessionData() }, [sessionId, loadSessionData]) // 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 = useCallback(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) } }, [sessionId]) // Generate image for a region const handleGenerateImage = useCallback(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)) } }, [sessionId, imageRegions]) // Save validation const handleSave = useCallback(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') } }, [sessionId, notes, score]) // Mark/update ground truth reference const handleMarkGroundTruth = useCallback(async () => { if (!sessionId) return setGtSaving(true) setGtMessage('') try { const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=ocr-pipeline`, { 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) } }, [sessionId]) // Handle manual region drawing on reconstruction const handleReconMouseDown = useCallback((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 }) }, [drawingRegion]) const handleReconMouseMove = useCallback((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 }) }, [dragStart]) const handleReconMouseUp = useCallback(() => { 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) }, [dragStart, dragEnd]) const handleRemoveRegion = useCallback((index: number) => { setImageRegions(prev => prev.filter((_, i) => i !== index)) }, []) return { // State status, error, session, imageRegions, detecting, zoom, syncScroll, notes, score, drawingRegion, dragStart, dragEnd, isGroundTruth, gtSaving, gtMessage, reconWidth, // Refs leftPanelRef, rightPanelRef, reconRef, // Setters setError, setZoom, setSyncScroll, setNotes, setScore, setDrawingRegion, setImageRegions, // Handlers loadSessionData, handleScroll, handleDetectImages, handleGenerateImage, handleSave, handleMarkGroundTruth, handleReconMouseDown, handleReconMouseMove, handleReconMouseUp, handleRemoveRegion, } }