Files
breakpilot-lehrer/admin-lehrer/components/ocr-pipeline/useGroundTruthSession.ts
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

268 lines
9.3 KiB
TypeScript

'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<GroundTruthStatus>('loading')
const [error, setError] = useState('')
const [session, setSession] = useState<SessionData | null>(null)
const [imageRegions, setImageRegions] = useState<ImageRegionWithState[]>([])
const [detecting, setDetecting] = useState(false)
const [zoom, setZoom] = useState(100)
const [syncScroll, setSyncScroll] = useState(true)
const [notes, setNotes] = useState('')
const [score, setScore] = useState<number | null>(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<HTMLDivElement>(null)
const rightPanelRef = useRef<HTMLDivElement>(null)
const reconRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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,
}
}