feat(ocr-pipeline): improve LLM review UI + add reconstruction step
StepLlmReview: Show full vocab table with image overlay, row-level
status tracking (pending/active/reviewed/corrected/skipped), and
auto-scroll during SSE streaming. Load previous results on mount.
StepReconstruction: New step 7 with editable text fields at original
bbox positions over dewarped image. Zoom controls, tab navigation,
color-coded columns, save to backend.
Backend: Add POST /sessions/{id}/reconstruction endpoint.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,343 @@
|
||||
'use client'
|
||||
|
||||
export function StepReconstruction() {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { GridResult, GridCell, WordEntry } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
interface StepReconstructionProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
interface EditableCell {
|
||||
cellId: string
|
||||
text: string
|
||||
originalText: string
|
||||
bboxPct: { x: number; y: number; w: number; h: number }
|
||||
colType: string
|
||||
rowIndex: number
|
||||
colIndex: number
|
||||
}
|
||||
|
||||
export function StepReconstruction({ sessionId, onNext }: StepReconstructionProps) {
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [cells, setCells] = useState<EditableCell[]>([])
|
||||
const [editedTexts, setEditedTexts] = useState<Map<string, string>>(new Map())
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
|
||||
// Load session data on mount
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
loadSessionData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
// Track container size for font scaling
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
||||
}
|
||||
})
|
||||
observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const loadSessionData = async () => {
|
||||
if (!sessionId) return
|
||||
setStatus('loading')
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
const wordResult: GridResult | undefined = data.word_result
|
||||
if (!wordResult) {
|
||||
setError('Keine Worterkennungsdaten gefunden. Bitte zuerst Schritt 5 abschliessen.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
// Build editable cells from grid cells
|
||||
const gridCells: GridCell[] = wordResult.cells || []
|
||||
const editableCells: EditableCell[] = gridCells
|
||||
.filter(c => c.text.trim() !== '')
|
||||
.map(c => ({
|
||||
cellId: c.cell_id,
|
||||
text: c.text,
|
||||
originalText: c.text,
|
||||
bboxPct: c.bbox_pct,
|
||||
colType: c.col_type,
|
||||
rowIndex: c.row_index,
|
||||
colIndex: c.col_index,
|
||||
}))
|
||||
|
||||
setCells(editableCells)
|
||||
setStatus('ready')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextChange = useCallback((cellId: string, newText: string) => {
|
||||
setEditedTexts(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(cellId, newText)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const getDisplayText = useCallback((cell: EditableCell): string => {
|
||||
return editedTexts.get(cell.cellId) ?? cell.text
|
||||
}, [editedTexts])
|
||||
|
||||
const isEdited = useCallback((cell: EditableCell): boolean => {
|
||||
const edited = editedTexts.get(cell.cellId)
|
||||
return edited !== undefined && edited !== cell.originalText
|
||||
}, [editedTexts])
|
||||
|
||||
const changedCount = useMemo(() => {
|
||||
let count = 0
|
||||
for (const cell of cells) {
|
||||
if (isEdited(cell)) count++
|
||||
}
|
||||
return count
|
||||
}, [cells, isEdited])
|
||||
|
||||
// Sort cells for tab navigation: by row, then by column
|
||||
const sortedCellIds = useMemo(() => {
|
||||
return [...cells]
|
||||
.sort((a, b) => a.rowIndex !== b.rowIndex ? a.rowIndex - b.rowIndex : a.colIndex - b.colIndex)
|
||||
.map(c => c.cellId)
|
||||
}, [cells])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent, cellId: string) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const idx = sortedCellIds.indexOf(cellId)
|
||||
const nextIdx = e.shiftKey ? idx - 1 : idx + 1
|
||||
if (nextIdx >= 0 && nextIdx < sortedCellIds.length) {
|
||||
const nextId = sortedCellIds[nextIdx]
|
||||
const el = document.getElementById(`cell-${nextId}`)
|
||||
el?.focus()
|
||||
}
|
||||
}
|
||||
}, [sortedCellIds])
|
||||
|
||||
const saveReconstruction = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
setStatus('saving')
|
||||
try {
|
||||
const cellUpdates = Array.from(editedTexts.entries())
|
||||
.filter(([cellId, text]) => {
|
||||
const cell = cells.find(c => c.cellId === cellId)
|
||||
return cell && text !== cell.originalText
|
||||
})
|
||||
.map(([cellId, text]) => ({ cell_id: cellId, text }))
|
||||
|
||||
if (cellUpdates.length === 0) {
|
||||
// Nothing changed, just advance
|
||||
setStatus('saved')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cells: cellUpdates }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
setStatus('saved')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [sessionId, editedTexts, cells])
|
||||
|
||||
const dewarpedUrl = sessionId
|
||||
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped`
|
||||
: ''
|
||||
|
||||
const colTypeColor = (colType: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
column_en: 'border-blue-400/40 focus:border-blue-500',
|
||||
column_de: 'border-green-400/40 focus:border-green-500',
|
||||
column_example: 'border-orange-400/40 focus:border-orange-500',
|
||||
column_text: 'border-purple-400/40 focus:border-purple-500',
|
||||
}
|
||||
return colors[colType] || 'border-gray-400/40 focus:border-gray-500'
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-3 justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500" />
|
||||
<span className="text-gray-500">Rekonstruktionsdaten werden geladen...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-medium text-red-600 dark:text-red-400 mb-2">Fehler</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg mb-4">{error}</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setError(''); loadSessionData() }}
|
||||
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button onClick={onNext}
|
||||
className="px-5 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
||||
Ueberspringen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">Rekonstruktion gespeichert</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'}
|
||||
</p>
|
||||
<button onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="text-5xl mb-4">🏗️</div>
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Schritt 6: Seitenrekonstruktion
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md">
|
||||
Nachbau der Originalseite aus erkannten Woertern und Positionen.
|
||||
Dieser Schritt wird in einer zukuenftigen Version implementiert.
|
||||
</p>
|
||||
<div className="mt-6 px-4 py-2 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full text-sm font-medium">
|
||||
Kommt bald
|
||||
<div className="space-y-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Schritt 7: Rekonstruktion
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
{cells.length} Zellen · {changedCount} geaendert
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom controls */}
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(50, z - 25))}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 w-10 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(200, z + 25))}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(100)}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={saveReconstruction}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-colors font-medium"
|
||||
>
|
||||
{status === 'saving' ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reconstruction canvas */}
|
||||
<div className="border rounded-lg overflow-auto dark:border-gray-700 bg-gray-100 dark:bg-gray-900" style={{ maxHeight: '75vh' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative inline-block"
|
||||
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
{/* Background image at reduced opacity */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={dewarpedUrl}
|
||||
alt="Dewarped"
|
||||
className="block"
|
||||
style={{ opacity: 0.3 }}
|
||||
/>
|
||||
|
||||
{/* Editable text fields at bbox positions */}
|
||||
{cells.map((cell) => {
|
||||
const displayText = getDisplayText(cell)
|
||||
const edited = isEdited(cell)
|
||||
|
||||
return (
|
||||
<input
|
||||
key={cell.cellId}
|
||||
id={`cell-${cell.cellId}`}
|
||||
type="text"
|
||||
value={displayText}
|
||||
onChange={(e) => handleTextChange(cell.cellId, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, cell.cellId)}
|
||||
className={`absolute bg-transparent text-black dark:text-white border px-0.5 outline-none transition-colors ${
|
||||
colTypeColor(cell.colType)
|
||||
} ${edited ? 'border-green-500 bg-green-50/30 dark:bg-green-900/20' : ''}`}
|
||||
style={{
|
||||
left: `${cell.bboxPct.x}%`,
|
||||
top: `${cell.bboxPct.y}%`,
|
||||
width: `${cell.bboxPct.w}%`,
|
||||
height: `${cell.bboxPct.h}%`,
|
||||
fontSize: `${Math.max(8, Math.min(16, (cell.bboxPct.h / 100) * (containerSize?.h || 800) * 0.6))}px`,
|
||||
lineHeight: '1',
|
||||
}}
|
||||
title={`${cell.cellId} (${cell.colType})`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom action */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (changedCount > 0) {
|
||||
saveReconstruction()
|
||||
} else {
|
||||
onNext()
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
{changedCount > 0 ? 'Speichern & Weiter →' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user