Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 34s
CI / test-go-edu-search (push) Successful in 37s
CI / test-python-klausur (push) Failing after 2m18s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 27s
- StepGroundTruth now shows the split view (original image + table) so the user can verify the final result before marking as GT - Backend session list now returns is_ground_truth flag - SessionList shows amber "GT" badge for marked sessions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
|
import { GridTable } from '@/components/grid-editor/GridTable'
|
|
import { ImageLayoutEditor } from '@/components/grid-editor/ImageLayoutEditor'
|
|
import type { GridZone } from '@/components/grid-editor/types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface StepGroundTruthProps {
|
|
sessionId: string | null
|
|
isGroundTruth: boolean
|
|
onMarked: () => void
|
|
gridSaveRef: React.MutableRefObject<(() => Promise<void>) | null>
|
|
}
|
|
|
|
/**
|
|
* Step 12: Ground Truth marking.
|
|
*
|
|
* Shows the full Grid-Review view (original image + table) so the user
|
|
* can verify the final result before marking as Ground Truth reference.
|
|
*/
|
|
export function StepGroundTruth({ sessionId, isGroundTruth, onMarked, gridSaveRef }: StepGroundTruthProps) {
|
|
const {
|
|
grid,
|
|
loading,
|
|
saving,
|
|
error,
|
|
dirty,
|
|
selectedCell,
|
|
selectedCells,
|
|
setSelectedCell,
|
|
loadGrid,
|
|
saveGrid,
|
|
updateCellText,
|
|
toggleColumnBold,
|
|
toggleRowHeader,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
getAdjacentCell,
|
|
deleteColumn,
|
|
addColumn,
|
|
deleteRow,
|
|
addRow,
|
|
toggleCellSelection,
|
|
clearCellSelection,
|
|
toggleSelectedBold,
|
|
setCellColor,
|
|
} = useGridEditor(sessionId)
|
|
|
|
const [showImage, setShowImage] = useState(true)
|
|
const [zoom, setZoom] = useState(100)
|
|
const [markSaving, setMarkSaving] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
|
|
// Expose save function via ref
|
|
useEffect(() => {
|
|
if (gridSaveRef) {
|
|
gridSaveRef.current = async () => {
|
|
if (dirty) await saveGrid()
|
|
}
|
|
return () => { gridSaveRef.current = null }
|
|
}
|
|
}, [gridSaveRef, dirty, saveGrid])
|
|
|
|
// Load grid on mount
|
|
useEffect(() => {
|
|
if (sessionId) loadGrid()
|
|
}, [sessionId, loadGrid])
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
|
e.preventDefault(); undo()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
|
|
e.preventDefault(); redo()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
e.preventDefault(); saveGrid()
|
|
} else if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
|
e.preventDefault()
|
|
if (selectedCells.size > 0) toggleSelectedBold()
|
|
} else if (e.key === 'Escape') {
|
|
clearCellSelection()
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [undo, redo, saveGrid, selectedCells, toggleSelectedBold, clearCellSelection])
|
|
|
|
const handleNavigate = useCallback(
|
|
(cellId: string, direction: 'up' | 'down' | 'left' | 'right') => {
|
|
const target = getAdjacentCell(cellId, direction)
|
|
if (target) {
|
|
setSelectedCell(target)
|
|
setTimeout(() => {
|
|
const el = document.getElementById(`cell-${target}`)
|
|
if (el) {
|
|
el.focus()
|
|
if (el instanceof HTMLInputElement) el.select()
|
|
}
|
|
}, 0)
|
|
}
|
|
},
|
|
[getAdjacentCell, setSelectedCell],
|
|
)
|
|
|
|
const handleMark = async () => {
|
|
if (!sessionId) return
|
|
setMarkSaving(true)
|
|
setMessage('')
|
|
try {
|
|
if (dirty) await saveGrid()
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=kombi`,
|
|
{ method: 'POST' },
|
|
)
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new Error(`Ground Truth fehlgeschlagen (${res.status}): ${body}`)
|
|
}
|
|
const data = await res.json()
|
|
setMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`)
|
|
onMarked()
|
|
} catch (e) {
|
|
setMessage(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setMarkSaving(false)
|
|
}
|
|
}
|
|
|
|
if (!sessionId) {
|
|
return <div className="text-center py-12 text-gray-400">Keine Session ausgewaehlt.</div>
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Grid wird geladen...
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-sm text-red-700 dark:text-red-300">Fehler: {error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!grid || !grid.zones.length) {
|
|
return <div className="text-center py-12 text-gray-400">Kein Grid vorhanden.</div>
|
|
}
|
|
|
|
const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* GT Header Bar */}
|
|
<div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-900/10 rounded-xl border border-amber-200 dark:border-amber-800">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
|
Ground Truth
|
|
{isGroundTruth && <span className="ml-2 text-xs font-normal text-amber-500">(bereits markiert)</span>}
|
|
</h3>
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">
|
|
Pruefen Sie das Ergebnis und markieren Sie es als Referenz fuer Regressionstests.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{dirty && (
|
|
<button
|
|
onClick={saveGrid}
|
|
disabled={saving}
|
|
className="px-3 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichere...' : 'Speichern'}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleMark}
|
|
disabled={markSaving}
|
|
className="px-4 py-1.5 text-xs bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50"
|
|
>
|
|
{markSaving ? 'Speichere...' : isGroundTruth ? 'GT aktualisieren' : 'Als Ground Truth markieren'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className={`text-sm p-2 rounded ${message.includes('fehlgeschlagen') ? 'text-red-500 bg-red-50 dark:bg-red-900/20' : 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/10'}`}>
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center gap-4 text-xs flex-wrap">
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
{grid.summary.total_zones} Zone(n), {grid.summary.total_columns} Spalten,{' '}
|
|
{grid.summary.total_rows} Zeilen, {grid.summary.total_cells} Zellen
|
|
</span>
|
|
<button
|
|
onClick={() => setShowImage(!showImage)}
|
|
className={`px-2.5 py-1 rounded text-xs border transition-colors ${
|
|
showImage
|
|
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
|
|
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{showImage ? 'Bild ausblenden' : 'Bild einblenden'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Split View: Image left + Grid right */}
|
|
<div className={showImage ? 'grid grid-cols-2 gap-3' : ''} style={{ minHeight: '55vh' }}>
|
|
{showImage && (
|
|
<ImageLayoutEditor
|
|
imageUrl={imageUrl}
|
|
zones={grid.zones}
|
|
imageWidth={grid.image_width}
|
|
layoutDividers={grid.layout_dividers}
|
|
zoom={zoom}
|
|
onZoomChange={setZoom}
|
|
onColumnDividerMove={() => {}}
|
|
onHorizontalsChange={() => {}}
|
|
onCommitUndo={() => {}}
|
|
onSplitColumnAt={() => {}}
|
|
onDeleteColumn={() => {}}
|
|
/>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{(() => {
|
|
const groups: GridZone[][] = []
|
|
for (const zone of grid.zones) {
|
|
const prev = groups[groups.length - 1]
|
|
if (prev && zone.vsplit_group != null && prev[0].vsplit_group === zone.vsplit_group) {
|
|
prev.push(zone)
|
|
} else {
|
|
groups.push([zone])
|
|
}
|
|
}
|
|
return groups.map((group) => (
|
|
<div key={group[0].vsplit_group ?? group[0].zone_index}>
|
|
<div className={`${group.length > 1 ? 'flex gap-2' : ''}`}>
|
|
{group.map((zone) => (
|
|
<div
|
|
key={zone.zone_index}
|
|
className={`${group.length > 1 ? 'flex-1 min-w-0' : ''} bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700`}
|
|
>
|
|
<GridTable
|
|
zone={zone}
|
|
layoutMetrics={grid.layout_metrics}
|
|
selectedCell={selectedCell}
|
|
selectedCells={selectedCells}
|
|
onSelectCell={setSelectedCell}
|
|
onToggleCellSelection={toggleCellSelection}
|
|
onCellTextChange={updateCellText}
|
|
onToggleColumnBold={toggleColumnBold}
|
|
onToggleRowHeader={toggleRowHeader}
|
|
onNavigate={handleNavigate}
|
|
onDeleteColumn={deleteColumn}
|
|
onAddColumn={addColumn}
|
|
onDeleteRow={deleteRow}
|
|
onAddRow={addRow}
|
|
onSetCellColor={setCellColor}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Keyboard tips */}
|
|
<div className="text-[11px] text-gray-400 dark:text-gray-500 flex items-center gap-4">
|
|
<span>Tab: naechste Zelle</span>
|
|
<span>Ctrl+Z/Y: Undo/Redo</span>
|
|
<span>Ctrl+S: Speichern</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|