'use client' import { useCallback, useEffect, useState } from 'react' import type { ColumnResult, ColumnGroundTruth, PageRegion, SubSession } from '@/app/(admin)/ai/ocr-pipeline/types' import { ColumnControls } from './ColumnControls' import { ManualColumnEditor } from './ManualColumnEditor' import type { ColumnTypeKey } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' type ViewMode = 'normal' | 'ground-truth' | 'manual' interface StepColumnDetectionProps { sessionId: string | null onNext: () => void onBoxSessionsCreated?: (subSessions: SubSession[]) => void } /** Convert PageRegion[] to divider percentages + column types for ManualColumnEditor */ function columnsToEditorState( columns: PageRegion[], imageWidth: number ): { dividers: number[]; columnTypes: ColumnTypeKey[] } { if (!columns.length || !imageWidth) return { dividers: [], columnTypes: [] } const sorted = [...columns].sort((a, b) => a.x - b.x) const dividers: number[] = [] const columnTypes: ColumnTypeKey[] = sorted.map(c => c.type) for (let i = 1; i < sorted.length; i++) { const xPct = (sorted[i].x / imageWidth) * 100 dividers.push(xPct) } return { dividers, columnTypes } } export function StepColumnDetection({ sessionId, onNext, onBoxSessionsCreated }: StepColumnDetectionProps) { const [columnResult, setColumnResult] = useState(null) const [detecting, setDetecting] = useState(false) const [error, setError] = useState(null) const [viewMode, setViewMode] = useState('normal') const [applying, setApplying] = useState(false) const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null) const [savedGtColumns, setSavedGtColumns] = useState(null) const [creatingBoxSessions, setCreatingBoxSessions] = useState(false) const [existingSubSessions, setExistingSubSessions] = useState(null) const [isSubSession, setIsSubSession] = useState(false) // Fetch session info (image dimensions) + check for cached column result useEffect(() => { if (!sessionId || imageDimensions) return const fetchSessionInfo = async () => { try { const infoRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) if (infoRes.ok) { const info = await infoRes.json() if (info.image_width && info.image_height) { setImageDimensions({ width: info.image_width, height: info.image_height }) } const isSub = !!info.parent_session_id setIsSubSession(isSub) if (info.sub_sessions && info.sub_sessions.length > 0) { setExistingSubSessions(info.sub_sessions) onBoxSessionsCreated?.(info.sub_sessions) } if (info.column_result) { setColumnResult(info.column_result) // Sub-session with pseudo-column already set → auto-advance if (isSub) { onNext() return } return } // Sub-session without columns → auto-detect (creates pseudo-column) if (isSub) { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/columns`, { method: 'POST' }) if (res.ok) { const data: ColumnResult = await res.json() setColumnResult(data) onNext() return } } } } catch (e) { console.error('Failed to fetch session info:', e) } // No cached result - run auto-detection runAutoDetection() } fetchSessionInfo() // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) // Load saved GT if exists useEffect(() => { if (!sessionId) return const fetchGt = async () => { try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/columns`) if (res.ok) { const data = await res.json() const corrected = data.columns_gt?.corrected_columns if (corrected) setSavedGtColumns(corrected) } } catch { // No saved GT - that's fine } } fetchGt() }, [sessionId]) const runAutoDetection = useCallback(async () => { if (!sessionId) return setDetecting(true) setError(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/columns`, { method: 'POST', }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(err.detail || 'Spaltenerkennung fehlgeschlagen') } const data: ColumnResult = await res.json() setColumnResult(data) } catch (e) { setError(e instanceof Error ? e.message : 'Unbekannter Fehler') } finally { setDetecting(false) } }, [sessionId]) const handleRerun = useCallback(() => { runAutoDetection() }, [runAutoDetection]) const handleGroundTruth = useCallback(async (gt: ColumnGroundTruth) => { if (!sessionId) return try { await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/columns`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gt), }) } catch (e) { console.error('Ground truth save failed:', e) } }, [sessionId]) const handleManualApply = useCallback(async (columns: PageRegion[]) => { if (!sessionId) return setApplying(true) setError(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/columns/manual`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ columns }), }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(err.detail || 'Manuelle Spalten konnten nicht gespeichert werden') } const data = await res.json() setColumnResult({ columns: data.columns, duration_seconds: data.duration_seconds ?? 0, }) setViewMode('normal') } catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Speichern') } finally { setApplying(false) } }, [sessionId]) const handleGtApply = useCallback(async (columns: PageRegion[]) => { if (!sessionId) return setApplying(true) setError(null) try { const gt: ColumnGroundTruth = { is_correct: false, corrected_columns: columns, } await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/columns`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gt), }) setSavedGtColumns(columns) setViewMode('normal') } catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Speichern') } finally { setApplying(false) } }, [sessionId]) // Count box zones from column result const boxZones = columnResult?.zones?.filter(z => z.zone_type === 'box') || [] const boxCount = boxZones.length const createBoxSessions = useCallback(async () => { if (!sessionId) return setCreatingBoxSessions(true) setError(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/create-box-sessions`, { method: 'POST', }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(err.detail || 'Box-Sessions konnten nicht erstellt werden') } const data = await res.json() const subs: SubSession[] = data.sub_sessions.map((s: { id: string; name?: string; box_index: number }) => ({ id: s.id, name: s.name || `Box ${s.box_index + 1}`, box_index: s.box_index, current_step: 1, status: 'pending', })) setExistingSubSessions(subs) onBoxSessionsCreated?.(subs) } catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Erstellen der Box-Sessions') } finally { setCreatingBoxSessions(false) } }, [sessionId, onBoxSessionsCreated]) if (!sessionId) { return (
📊

Schritt 3: Spaltenerkennung

Bitte zuerst Schritt 1 und 2 abschliessen.

) } const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/columns-overlay` // Pre-compute editor state from saved GT or auto columns for GT mode const gtInitial = savedGtColumns ? columnsToEditorState(savedGtColumns, imageDimensions?.width ?? 1000) : undefined return (
{/* Loading indicator */} {detecting && (
Spaltenerkennung laeuft...
)} {viewMode === 'manual' ? ( /* Manual column editor - overwrites column_result */ setViewMode('normal')} applying={applying} mode="manual" /> ) : viewMode === 'ground-truth' ? ( /* GT mode: auto result (left, readonly) + GT editor (right) */
{/* Left: Auto result (readonly overlay) */}
Auto-Ergebnis (readonly)
{columnResult ? ( // eslint-disable-next-line @next/next/no-img-element Auto Spalten-Overlay ) : (
Keine Auto-Daten
)}
{/* Auto column list */} {columnResult && (
Auto: {columnResult.columns.length} Spalten
{columnResult.columns .filter(c => c.type.startsWith('column') || c.type === 'page_ref') .map((col, i) => (
{i + 1}. {col.type} x={col.x} w={col.width}
))}
)}
{/* Right: GT editor */}
Ground Truth Editor
setViewMode('normal')} applying={applying} mode="ground-truth" layout="stacked" initialDividers={gtInitial?.dividers} initialColumnTypes={gtInitial?.columnTypes} />
) : ( /* Normal mode: overlay (left) vs clean (right) */
Mit Spalten-Overlay
{columnResult ? ( // eslint-disable-next-line @next/next/no-img-element Spalten-Overlay ) : (
{detecting ? 'Erkenne Spalten...' : 'Keine Daten'}
)}
Entzerrtes Bild
{/* eslint-disable-next-line @next/next/no-img-element */} Entzerrt
)} {/* Box zone info */} {viewMode === 'normal' && boxCount > 0 && (
📦
{boxCount} Box{boxCount > 1 ? 'en' : ''} erkannt
Box-Bereiche werden separat verarbeitet
{existingSubSessions && existingSubSessions.length > 0 ? (
{existingSubSessions.length} Box-Session{existingSubSessions.length > 1 ? 's' : ''} vorhanden
) : ( )}
)} {/* Controls */} {viewMode === 'normal' && ( setViewMode('manual')} onGtMode={() => setViewMode('ground-truth')} onGroundTruth={handleGroundTruth} onNext={onNext} isDetecting={detecting} savedGtColumns={savedGtColumns} /> )} {error && (
{error}
)}
) }