From dbf0db0c13ee872477ff2b0481ff7cc49f1160f7 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 2 Mar 2026 12:19:21 +0100 Subject: [PATCH] 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 --- .../app/(admin)/ai/ocr-pipeline/page.tsx | 2 +- .../app/(admin)/ai/ocr-pipeline/types.ts | 8 + .../components/ocr-pipeline/StepLlmReview.tsx | 623 ++++++++++++------ .../ocr-pipeline/StepReconstruction.tsx | 349 +++++++++- klausur-service/backend/ocr_pipeline_api.py | 64 ++ 5 files changed, 849 insertions(+), 197 deletions(-) diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx b/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx index cecf749..2ebde8c 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx @@ -175,7 +175,7 @@ export default function OcrPipelinePage() { case 5: return case 6: - return + return case 7: return default: diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts index ac9bdc4..1a9797e 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts @@ -165,6 +165,14 @@ export interface GridResult { with_english?: number with_german?: number } + llm_review?: { + changes: { row_index: number; field: string; old: string; new: string }[] + model_used: string + duration_ms: number + entries_corrected: number + applied_count?: number + applied_at?: string + } } export interface WordEntry { diff --git a/admin-lehrer/components/ocr-pipeline/StepLlmReview.tsx b/admin-lehrer/components/ocr-pipeline/StepLlmReview.tsx index ad07c40..89640d5 100644 --- a/admin-lehrer/components/ocr-pipeline/StepLlmReview.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepLlmReview.tsx @@ -1,6 +1,7 @@ 'use client' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { GridResult, WordEntry } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' @@ -21,6 +22,7 @@ interface ReviewMeta { to_review: number skipped: number model: string + skipped_indices?: number[] } interface StreamProgress { @@ -34,27 +36,108 @@ const FIELD_LABELS: Record = { example: 'Beispiel', } +type RowStatus = 'pending' | 'active' | 'reviewed' | 'corrected' | 'skipped' + export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { - const [status, setStatus] = useState<'idle' | 'running' | 'done' | 'error' | 'applied'>('idle') + // Core state + const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'running' | 'done' | 'error' | 'applied'>('idle') const [meta, setMeta] = useState(null) const [changes, setChanges] = useState([]) const [progress, setProgress] = useState(null) - const [batchLog, setBatchLog] = useState([]) const [totalDuration, setTotalDuration] = useState(0) const [error, setError] = useState('') const [accepted, setAccepted] = useState>(new Set()) const [applying, setApplying] = useState(false) - const tableEndRef = useRef(null) + + // Full vocab table state + const [vocabEntries, setVocabEntries] = useState([]) + const [activeRowIndices, setActiveRowIndices] = useState>(new Set()) + const [reviewedRows, setReviewedRows] = useState>(new Set()) + const [skippedRows, setSkippedRows] = useState>(new Set()) + const [correctedMap, setCorrectedMap] = useState>(new Map()) + + // Image + const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null) + + const tableRef = useRef(null) + const activeRowRef = useRef(null) + + // Load session data on mount + useEffect(() => { + if (!sessionId) return + loadSessionData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + + 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 + } + + const entries = wordResult.vocab_entries || wordResult.entries || [] + setVocabEntries(entries) + + // Check if LLM review was already run + const llmReview = wordResult.llm_review + if (llmReview && llmReview.changes) { + const existingChanges: LlmChange[] = llmReview.changes as LlmChange[] + setChanges(existingChanges) + setTotalDuration(llmReview.duration_ms || 0) + + // Mark all rows as reviewed + const allReviewed = new Set(entries.map((_: WordEntry, i: number) => i)) + setReviewedRows(allReviewed) + + // Build corrected map + const cMap = new Map() + for (const c of existingChanges) { + const existing = cMap.get(c.row_index) || [] + existing.push(c) + cMap.set(c.row_index, existing) + } + setCorrectedMap(cMap) + + // Default: all accepted + setAccepted(new Set(existingChanges.map((_: LlmChange, i: number) => i))) + + setMeta({ + total_entries: entries.length, + to_review: llmReview.entries_corrected !== undefined ? entries.length : entries.length, + skipped: 0, + model: llmReview.model_used || 'unknown', + }) + setStatus('done') + } else { + setStatus('ready') + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + setStatus('error') + } + } const runReview = useCallback(async () => { if (!sessionId) return setStatus('running') setError('') setChanges([]) - setBatchLog([]) setProgress(null) setMeta(null) setTotalDuration(0) + setActiveRowIndices(new Set()) + setReviewedRows(new Set()) + setSkippedRows(new Set()) + setCorrectedMap(new Map()) try { const res = await fetch( @@ -71,6 +154,9 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { const decoder = new TextDecoder() let buffer = '' let allChanges: LlmChange[] = [] + let allReviewed = new Set() + let allSkipped = new Set() + let cMap = new Map() while (true) { const { done, value } = await reader.read() @@ -94,25 +180,57 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { to_review: event.to_review, skipped: event.skipped, model: event.model, + skipped_indices: event.skipped_indices, }) - setBatchLog([`${event.total_entries} Eintraege, ${event.skipped} uebersprungen (Lautschrift), ${event.to_review} zu pruefen`]) + // Mark skipped rows + if (event.skipped_indices) { + allSkipped = new Set(event.skipped_indices) + setSkippedRows(allSkipped) + } } if (event.type === 'batch') { const batchChanges: LlmChange[] = event.changes || [] + const batchRows: number[] = event.entries_reviewed || [] + + // Update active rows (currently being reviewed) + setActiveRowIndices(new Set(batchRows)) + + // Accumulate changes allChanges = [...allChanges, ...batchChanges] setChanges(allChanges) setProgress(event.progress) - const rows = (event.entries_reviewed || []).map((r: number) => `R${r}`).join(', ') - setBatchLog(prev => [...prev, - `Batch ${event.batch_index + 1}: ${rows} — ${batchChanges.length} Korrektur${batchChanges.length !== 1 ? 'en' : ''} (${event.duration_ms}ms)` - ]) - setTimeout(() => tableEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 16) + + // Update corrected map + for (const c of batchChanges) { + const existing = cMap.get(c.row_index) || [] + existing.push(c) + cMap.set(c.row_index, [...existing]) + } + setCorrectedMap(new Map(cMap)) + + // Mark batch rows as reviewed + for (const r of batchRows) { + allReviewed.add(r) + } + setReviewedRows(new Set(allReviewed)) + + // Scroll to active row in table + setTimeout(() => { + activeRowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 50) } if (event.type === 'complete') { + setActiveRowIndices(new Set()) setTotalDuration(event.duration_ms) - setAccepted(new Set(allChanges.map((_, i) => i))) + setAccepted(new Set(allChanges.map((_: LlmChange, i: number) => i))) + // Mark all non-skipped as reviewed + const allEntryIndices = vocabEntries.map((_: WordEntry, i: number) => i) + for (const i of allEntryIndices) { + if (!allSkipped.has(i)) allReviewed.add(i) + } + setReviewedRows(new Set(allReviewed)) setStatus('done') } @@ -122,8 +240,8 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { } } - // If no complete event was received (e.g. 0 entries to review) - if (allChanges.length === 0 && status !== 'done') { + // If stream ended without complete event + if (allChanges.length === 0) { setStatus('done') } } catch (e: unknown) { @@ -131,7 +249,7 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { setError(msg) setStatus('error') } - }, [sessionId]) + }, [sessionId, vocabEntries]) const toggleChange = (index: number) => { setAccepted(prev => { @@ -146,7 +264,7 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { if (accepted.size === changes.length) { setAccepted(new Set()) } else { - setAccepted(new Set(changes.map((_, i) => i))) + setAccepted(new Set(changes.map((_: LlmChange, i: number) => i))) } } @@ -171,99 +289,28 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { } }, [sessionId, accepted]) + const getRowStatus = (rowIndex: number): RowStatus => { + if (activeRowIndices.has(rowIndex)) return 'active' + if (skippedRows.has(rowIndex)) return 'skipped' + if (correctedMap.has(rowIndex)) return 'corrected' + if (reviewedRows.has(rowIndex)) return 'reviewed' + return 'pending' + } + + const dewarpedUrl = sessionId + ? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped` + : '' + if (!sessionId) { return
Bitte zuerst eine Session auswaehlen.
} - // --- Idle --- - if (status === 'idle') { + // --- Loading session data --- + if (status === 'loading' || status === 'idle') { return ( -
-
🤖
-

- Schritt 6: LLM-Korrektur -

-

- Ein lokales Sprachmodell prueft die OCR-Ergebnisse auf typische Erkennungsfehler. - Eintraege mit Lautschrift werden automatisch uebersprungen. -

-

- Modell: qwen3:30b-a3b via Ollama (lokal) -

- -
- ) - } - - // --- Running (with live progress) --- - if (status === 'running') { - const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0 - - return ( -
-
-
-

- LLM-Korrektur laeuft... -

- {meta && ( - {meta.model} - )} -
- - {/* Progress bar */} - {progress && ( -
-
- {progress.current} / {progress.total} Eintraege geprueft - {pct}% -
-
-
-
-
- )} - - {/* Live batch log */} -
- {batchLog.map((line, i) => ( -
{line}
- ))} -
- - {/* Live changes appearing */} - {changes.length > 0 && ( -
- - - - - - - - - - - {changes.map((change, idx) => ( - - - - - - - ))} - -
ZeileFeldVorherNachher
R{change.row_index} - - {FIELD_LABELS[change.field] || change.field} - - {change.old}{change.new}
-
-
- )} +
+
+ Session-Daten werden geladen...
) } @@ -276,7 +323,7 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {

Fehler bei LLM-Korrektur

{error}

- @@ -306,96 +353,304 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) { ) } - // --- Done: diff table with checkboxes --- - if (changes.length === 0) { - return ( -
-
👍
-

Keine Korrekturen noetig

-

Das LLM hat keine OCR-Fehler gefunden.

- {meta && ( -

- {meta.to_review} geprueft, {meta.skipped} uebersprungen · {totalDuration}ms · {meta.model} -

- )} - -
- ) - } + // Active entry for highlighting on image + const activeEntry = vocabEntries.find((_: WordEntry, i: number) => activeRowIndices.has(i)) + const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0 + + // --- Ready / Running / Done: 2-column layout --- return (
{/* Header */}
-

LLM-Korrekturvorschlaege

+

+ Schritt 6: LLM-Korrektur +

- {changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden - {meta && <> · {meta.skipped} uebersprungen (Lautschrift)} - {' '}· {totalDuration}ms · {meta?.model} + {status === 'ready' && `${vocabEntries.length} Eintraege bereit zur Pruefung`} + {status === 'running' && meta && `${meta.model} · ${meta.to_review} zu pruefen, ${meta.skipped} uebersprungen`} + {status === 'done' && ( + <> + {changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden + {meta && <> · {meta.skipped} uebersprungen} + {' '}· {totalDuration}ms · {meta?.model} + + )}

- -
- - {/* Diff table */} -
- - - - - - - - - - - - {changes.map((change, idx) => ( - - - - - - - - ))} - -
- - ZeileFeldVorherNachher
- toggleChange(idx)} - className="rounded border-gray-300 dark:border-gray-600" /> - R{change.row_index} - - {FIELD_LABELS[change.field] || change.field} - - {change.old}{change.new}
-
- - {/* Actions */} -
-

{accepted.size} von {changes.length} ausgewaehlt

-
- - +
+ {status === 'ready' && ( + + )} + {status === 'running' && ( +
+
+ {progress ? `${progress.current}/${progress.total}` : 'Startet...'} +
+ )} + {status === 'done' && changes.length > 0 && ( + + )}
+ + {/* Progress bar (while running) */} + {status === 'running' && progress && ( +
+
+ {progress.current} / {progress.total} Eintraege geprueft + {pct}% +
+
+
+
+
+ )} + + {/* 2-column layout: Image + Table */} +
+ {/* Left: Dewarped Image with highlight overlay */} +
+
+ Originalbild +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Dewarped { + const img = e.target as HTMLImageElement + setImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }) + }} + /> + {/* Highlight overlay for active row */} + {activeEntry?.bbox && ( +
+ )} +
+
+ + {/* Right: Full vocabulary table */} +
+
+ Vokabeltabelle ({vocabEntries.length} Eintraege) +
+
+
+ + + + + + + + + + + + {vocabEntries.map((entry, idx) => { + const rowStatus = getRowStatus(idx) + const rowChanges = correctedMap.get(idx) + + const rowBg = { + pending: '', + active: 'bg-yellow-50 dark:bg-yellow-900/20', + reviewed: '', + corrected: 'bg-teal-50/50 dark:bg-teal-900/10', + skipped: 'bg-gray-50 dark:bg-gray-800/50', + }[rowStatus] + + return ( + + + + + + + + ) + })} + +
#ENDEBeispielStatus
{idx} + + + + + + + +
+
+
+
+
+ + {/* Done state: summary + actions */} + {status === 'done' && ( +
+ {/* Summary */} +
+ {changes.length === 0 ? ( + Keine Korrekturen noetig — alle Eintraege sind korrekt. + ) : ( + + {changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden ·{' '} + {accepted.size} ausgewaehlt ·{' '} + {meta?.skipped || 0} uebersprungen (Lautschrift) ·{' '} + {totalDuration}ms + + )} +
+ + {/* Corrections detail list (if any) */} + {changes.length > 0 && ( +
+
+ + Korrekturvorschlaege ({accepted.size}/{changes.length} ausgewaehlt) + +
+ + + + + + + + + + + + {changes.map((change, idx) => ( + + + + + + + + ))} + +
+ + ZeileFeldVorherNachher
+ toggleChange(idx)} + className="rounded border-gray-300 dark:border-gray-600" /> + R{change.row_index} + + {FIELD_LABELS[change.field] || change.field} + + {change.old}{change.new}
+
+ )} + + {/* Actions */} +
+

+ {changes.length > 0 ? `${accepted.size} von ${changes.length} ausgewaehlt` : ''} +

+
+ {changes.length > 0 && ( + + )} + {changes.length > 0 ? ( + + ) : ( + + )} +
+
+
+ )}
) } + +/** Cell content with inline diff for corrections */ +function CellContent({ text, field, rowChanges }: { + text: string + field: string + rowChanges?: LlmChange[] +}) { + const change = rowChanges?.find(c => c.field === field) + + if (!text && !change) { + return + } + + if (change) { + return ( + + {change.old} + {change.new} + + ) + } + + return {text} +} + +/** Status icon for each row */ +function StatusIcon({ status }: { status: RowStatus }) { + switch (status) { + case 'pending': + return + case 'active': + return ( + + ) + case 'reviewed': + return ( + + + + ) + case 'corrected': + return ( + + korr. + + ) + case 'skipped': + return ( + + skip + + ) + } +} diff --git a/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx b/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx index f727d5f..ea5c197 100644 --- a/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepReconstruction.tsx @@ -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([]) + const [editedTexts, setEditedTexts] = useState>(new Map()) + const [zoom, setZoom] = useState(100) + const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null) + + const containerRef = useRef(null) + const imageRef = useRef(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 = { + 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
Bitte zuerst eine Session auswaehlen.
+ } + + if (status === 'loading') { + return ( +
+
+ Rekonstruktionsdaten werden geladen... +
+ ) + } + + if (status === 'error') { + return ( +
+
⚠️
+

Fehler

+

{error}

+
+ + +
+
+ ) + } + + if (status === 'saved') { + return ( +
+
+

Rekonstruktion gespeichert

+

+ {changedCount > 0 ? `${changedCount} Zellen wurden aktualisiert.` : 'Keine Aenderungen vorgenommen.'} +

+ +
+ ) + } + return ( -
-
🏗️
-

- Schritt 6: Seitenrekonstruktion -

-

- Nachbau der Originalseite aus erkannten Woertern und Positionen. - Dieser Schritt wird in einer zukuenftigen Version implementiert. -

-
- Kommt bald +
+ {/* Toolbar */} +
+
+

+ Schritt 7: Rekonstruktion +

+ + {cells.length} Zellen · {changedCount} geaendert + +
+
+ {/* Zoom controls */} + + {zoom}% + + + +
+ + +
+
+ + {/* Reconstruction canvas */} +
+
+ {/* Background image at reduced opacity */} + {/* eslint-disable-next-line @next/next/no-img-element */} + Dewarped + + {/* Editable text fields at bbox positions */} + {cells.map((cell) => { + const displayText = getDisplayText(cell) + const edited = isEdited(cell) + + return ( + 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})`} + /> + ) + })} +
+
+ + {/* Bottom action */} +
+
) diff --git a/klausur-service/backend/ocr_pipeline_api.py b/klausur-service/backend/ocr_pipeline_api.py index 542bd1e..afb0a81 100644 --- a/klausur-service/backend/ocr_pipeline_api.py +++ b/klausur-service/backend/ocr_pipeline_api.py @@ -1559,6 +1559,70 @@ async def apply_llm_corrections(session_id: str, request: Request): } +@router.post("/sessions/{session_id}/reconstruction") +async def save_reconstruction(session_id: str, request: Request): + """Save edited cell texts from reconstruction step.""" + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + word_result = session.get("word_result") + if not word_result: + raise HTTPException(status_code=400, detail="No word result found") + + body = await request.json() + cell_updates = body.get("cells", []) + + if not cell_updates: + await update_session_db(session_id, current_step=7) + return {"session_id": session_id, "updated": 0} + + # Build update map: cell_id -> new text + update_map = {c["cell_id"]: c["text"] for c in cell_updates} + + # Update cells + cells = word_result.get("cells", []) + updated_count = 0 + for cell in cells: + if cell["cell_id"] in update_map: + cell["text"] = update_map[cell["cell_id"]] + cell["status"] = "edited" + updated_count += 1 + + word_result["cells"] = cells + + # Also update vocab_entries if present + entries = word_result.get("vocab_entries") or word_result.get("entries") or [] + if entries: + # Map cell_id pattern "R{row}_C{col}" to entry fields + for entry in entries: + row_idx = entry.get("row_index", -1) + # Check each field's cell + for col_idx, field_name in enumerate(["english", "german", "example"]): + cell_id = f"R{row_idx:02d}_C{col_idx}" + # Also try without zero-padding + cell_id_alt = f"R{row_idx}_C{col_idx}" + new_text = update_map.get(cell_id) or update_map.get(cell_id_alt) + if new_text is not None: + entry[field_name] = new_text + + word_result["vocab_entries"] = entries + if "entries" in word_result: + word_result["entries"] = entries + + await update_session_db(session_id, word_result=word_result, current_step=7) + + if session_id in _cache: + _cache[session_id]["word_result"] = word_result + + logger.info(f"Reconstruction saved for session {session_id}: {updated_count} cells updated") + + return { + "session_id": session_id, + "updated": updated_count, + } + + async def _get_rows_overlay(session_id: str) -> Response: """Generate dewarped image with row bands drawn on it.""" session = await get_session_db(session_id)