From e6f8e12f441d996456711706f0a152a591b0bd59 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 10 Apr 2026 19:34:32 +0200 Subject: [PATCH] Show full Grid-Review in Ground Truth step + GT badge in session list - 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) --- .../app/(admin)/ai/ocr-pipeline/types.ts | 1 + .../components/ocr-kombi/SessionList.tsx | 7 +- .../components/ocr-kombi/StepGroundTruth.tsx | 275 ++++++++++++++++-- .../backend/ocr_pipeline_session_store.py | 12 +- 4 files changed, 265 insertions(+), 30 deletions(-) diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts index 442eff9..3da6e4c 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts @@ -36,6 +36,7 @@ export interface SessionListItem { parent_session_id?: string document_group_id?: string page_number?: number + is_ground_truth?: boolean created_at: string updated_at?: string } diff --git a/admin-lehrer/components/ocr-kombi/SessionList.tsx b/admin-lehrer/components/ocr-kombi/SessionList.tsx index 4394d0d..c70be09 100644 --- a/admin-lehrer/components/ocr-kombi/SessionList.tsx +++ b/admin-lehrer/components/ocr-kombi/SessionList.tsx @@ -298,7 +298,7 @@ function SessionRow({ - {/* Category badge */} + {/* Category + GT badge */}
e.stopPropagation()}> + {session.is_ground_truth && ( + + GT + + )}
{/* Actions */} diff --git a/admin-lehrer/components/ocr-kombi/StepGroundTruth.tsx b/admin-lehrer/components/ocr-kombi/StepGroundTruth.tsx index 0bb023a..3c21cd4 100644 --- a/admin-lehrer/components/ocr-kombi/StepGroundTruth.tsx +++ b/admin-lehrer/components/ocr-kombi/StepGroundTruth.tsx @@ -1,6 +1,10 @@ 'use client' -import { useState } from 'react' +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' @@ -12,22 +16,104 @@ interface StepGroundTruthProps { } /** - * Step 11: Ground Truth marking. - * Saves the current grid as reference data for regression tests. + * 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 [saving, setSaving] = useState(false) + 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 - setSaving(true) + setMarkSaving(true) setMessage('') try { - // Auto-save grid editor before marking - if (gridSaveRef.current) { - await gridSaveRef.current() - } + if (dirty) await saveGrid() const res = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=kombi`, { method: 'POST' }, @@ -42,33 +128,168 @@ export function StepGroundTruth({ sessionId, isGroundTruth, onMarked, gridSaveRe } catch (e) { setMessage(e instanceof Error ? e.message : String(e)) } finally { - setSaving(false) + setMarkSaving(false) } } - return ( -
-

- Ground Truth -

-

- Markiert die aktuelle Grid-Ausgabe als Referenz fuer Regressionstests. - {isGroundTruth && ' Diese Session ist bereits als Ground Truth markiert.'} -

+ if (!sessionId) { + return
Keine Session ausgewaehlt.
+ } - + if (loading) { + return ( +
+
+ + + + + Grid wird geladen... +
+
+ ) + } + + if (error) { + return ( +
+

Fehler: {error}

+
+ ) + } + + if (!grid || !grid.zones.length) { + return
Kein Grid vorhanden.
+ } + + const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` + + return ( +
+ {/* GT Header Bar */} +
+
+

+ Ground Truth + {isGroundTruth && (bereits markiert)} +

+

+ Pruefen Sie das Ergebnis und markieren Sie es als Referenz fuer Regressionstests. +

+
+
+ {dirty && ( + + )} + +
+
{message && ( -
+
{message}
)} + + {/* Stats */} +
+ + {grid.summary.total_zones} Zone(n), {grid.summary.total_columns} Spalten,{' '} + {grid.summary.total_rows} Zeilen, {grid.summary.total_cells} Zellen + + +
+ + {/* Split View: Image left + Grid right */} +
+ {showImage && ( + {}} + onHorizontalsChange={() => {}} + onCommitUndo={() => {}} + onSplitColumnAt={() => {}} + onDeleteColumn={() => {}} + /> + )} + +
+ {(() => { + 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) => ( +
+
1 ? 'flex gap-2' : ''}`}> + {group.map((zone) => ( +
1 ? 'flex-1 min-w-0' : ''} bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700`} + > + +
+ ))} +
+
+ )) + })()} +
+
+ + {/* Keyboard tips */} +
+ Tab: naechste Zelle + Ctrl+Z/Y: Undo/Redo + Ctrl+S: Speichern +
) } diff --git a/klausur-service/backend/ocr_pipeline_session_store.py b/klausur-service/backend/ocr_pipeline_session_store.py index 9645ef2..dc80c55 100644 --- a/klausur-service/backend/ocr_pipeline_session_store.py +++ b/klausur-service/backend/ocr_pipeline_session_store.py @@ -262,14 +262,22 @@ async def list_sessions_db( document_category, doc_type, parent_session_id, box_index, document_group_id, page_number, - created_at, updated_at + created_at, updated_at, + ground_truth FROM ocr_pipeline_sessions {where} ORDER BY created_at DESC LIMIT $1 """, limit) - return [_row_to_dict(row) for row in rows] + results = [] + for row in rows: + d = _row_to_dict(row) + # Derive is_ground_truth flag from JSONB, then drop the heavy field + gt = d.pop("ground_truth", None) or {} + d["is_ground_truth"] = bool(gt.get("build_grid_reference")) + results.append(d) + return results async def get_sub_sessions(parent_session_id: str) -> List[Dict[str, Any]]: