diff --git a/admin-lehrer/app/(admin)/ai/ocr-overlay/page.tsx b/admin-lehrer/app/(admin)/ai/ocr-overlay/page.tsx index 6b9bd34..5fc4b67 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-overlay/page.tsx +++ b/admin-lehrer/app/(admin)/ai/ocr-overlay/page.tsx @@ -43,6 +43,7 @@ export default function OcrOverlayPage() { const searchParams = useSearchParams() const deepLinkHandled = useRef(false) + const gridSaveRef = useRef<(() => Promise) | null>(null) useEffect(() => { loadSessions() @@ -271,6 +272,10 @@ export default function OcrOverlayPage() { setGtSaving(true) setGtMessage('') try { + // Auto-save grid editor before marking GT (so DB has latest edits) + if (gridSaveRef.current) { + await gridSaveRef.current() + } const resp = await fetch( `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=${mode}`, { method: 'POST' } @@ -327,7 +332,7 @@ export default function OcrOverlayPage() { ) : null case 6: return mode === 'kombi' ? ( - + ) : null default: return null diff --git a/admin-lehrer/components/grid-editor/GridEditor.tsx b/admin-lehrer/components/grid-editor/GridEditor.tsx index da09249..8d7ee12 100644 --- a/admin-lehrer/components/grid-editor/GridEditor.tsx +++ b/admin-lehrer/components/grid-editor/GridEditor.tsx @@ -34,6 +34,8 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) { getAdjacentCell, deleteColumn, addColumn, + deleteRow, + addRow, } = useGridEditor(sessionId) const [showOverlay, setShowOverlay] = useState(false) @@ -163,6 +165,11 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) { +{grid.summary.recovered_colored} recovered )} + {grid.dictionary_detection?.is_dictionary && ( + + Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%) + + )} {grid.duration_seconds.toFixed(1)}s @@ -223,6 +230,8 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) { onNavigate={handleNavigate} onDeleteColumn={deleteColumn} onAddColumn={addColumn} + onDeleteRow={deleteRow} + onAddRow={addRow} /> ) : ( diff --git a/admin-lehrer/components/grid-editor/GridTable.tsx b/admin-lehrer/components/grid-editor/GridTable.tsx index 82d7e2b..22f0561 100644 --- a/admin-lehrer/components/grid-editor/GridTable.tsx +++ b/admin-lehrer/components/grid-editor/GridTable.tsx @@ -7,20 +7,24 @@ interface GridTableProps { zone: GridZone layoutMetrics?: LayoutMetrics selectedCell: string | null + selectedCells?: Set onSelectCell: (cellId: string) => void + onToggleCellSelection?: (cellId: string) => void onCellTextChange: (cellId: string, text: string) => void onToggleColumnBold: (zoneIndex: number, colIndex: number) => void onToggleRowHeader: (zoneIndex: number, rowIndex: number) => void onNavigate: (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => void onDeleteColumn?: (zoneIndex: number, colIndex: number) => void onAddColumn?: (zoneIndex: number, afterColIndex: number) => void + onDeleteRow?: (zoneIndex: number, rowIndex: number) => void + onAddRow?: (zoneIndex: number, afterRowIndex: number) => void } /** Gutter width for row numbers (px). */ const ROW_NUM_WIDTH = 36 /** Minimum column width in px so columns remain usable. */ -const MIN_COL_WIDTH = 40 +const MIN_COL_WIDTH = 80 /** Minimum row height in px. */ const MIN_ROW_HEIGHT = 26 @@ -29,13 +33,17 @@ export function GridTable({ zone, layoutMetrics, selectedCell, + selectedCells, onSelectCell, + onToggleCellSelection, onCellTextChange, onToggleColumnBold, onToggleRowHeader, onNavigate, onDeleteColumn, onAddColumn, + onDeleteRow, + onAddRow, }: GridTableProps) { const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) @@ -113,12 +121,18 @@ export function GridTable({ } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() onNavigate(cellId, 'down') - } else if (e.key === 'ArrowUp' && e.altKey) { + } else if (e.key === 'ArrowUp') { e.preventDefault() onNavigate(cellId, 'up') - } else if (e.key === 'ArrowDown' && e.altKey) { + } else if (e.key === 'ArrowDown') { e.preventDefault() onNavigate(cellId, 'down') + } else if (e.key === 'ArrowLeft' && e.altKey) { + e.preventDefault() + onNavigate(cellId, 'left') + } else if (e.key === 'ArrowRight' && e.altKey) { + e.preventDefault() + onNavigate(cellId, 'right') } else if (e.key === 'Escape') { ;(e.target as HTMLElement).blur() } @@ -323,7 +337,7 @@ export function GridTable({
{/* Row number cell */}
onToggleRowHeader(zone.zone_index, row.index)} - title={`Zeile ${row.index + 1} — Klick fuer Header-Toggle`} + title={`Zeile ${row.index + 1} — Klick: ${row.is_header ? 'Footer' : row.is_footer ? 'Normal' : 'Header'}`} > {row.index + 1} {row.is_header && H} {row.is_footer && F} + {/* Delete row button (visible on hover) */} + {onDeleteRow && zone.rows.length > 1 && ( + + )} + {/* Add row button (visible on hover, below this row) */} + {onAddRow && ( + + )} {/* Bottom-edge resize handle */}
0 && cell.confidence < 60 + const isMultiSelected = selectedCells?.has(cellId) const cellColor = getCellColor(cell) // Show per-word colored display only when word_boxes // match the cell text. Post-processing steps (e.g. 5h @@ -417,9 +462,9 @@ export function GridTable({ key={col.index} className={`relative border-b border-r border-gray-200 dark:border-gray-700 flex items-center ${ isSelected ? 'ring-2 ring-teal-500 ring-inset z-10' : '' - } ${isLowConf ? 'bg-amber-50/50 dark:bg-amber-900/10' : ''} ${ - row.is_header ? 'bg-blue-50/50 dark:bg-blue-900/10' : '' - }`} + } ${isMultiSelected ? 'bg-teal-50/60 dark:bg-teal-900/20' : ''} ${ + isLowConf && !isMultiSelected ? 'bg-amber-50/50 dark:bg-amber-900/10' : '' + } ${row.is_header && !isMultiSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`} style={{ height: `${rowH}px` }} > {cellColor && ( @@ -433,9 +478,13 @@ export function GridTable({ {hasColoredWords && !isSelected ? (
{ - onSelectCell(cellId) - setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0) + onClick={(e) => { + if ((e.metaKey || e.ctrlKey) && onToggleCellSelection) { + onToggleCellSelection(cellId) + } else { + onSelectCell(cellId) + setTimeout(() => document.getElementById(`cell-${cellId}`)?.focus(), 0) + } }} > {cell!.word_boxes!.map((wb, i) => ( @@ -457,10 +506,14 @@ export function GridTable({ id={`cell-${cellId}`} type="text" value={cell?.text ?? ''} - onChange={(e) => { - if (cell) onCellTextChange(cellId, e.target.value) - }} + onChange={(e) => onCellTextChange(cellId, e.target.value)} onFocus={() => onSelectCell(cellId)} + onClick={(e) => { + if ((e.metaKey || e.ctrlKey) && onToggleCellSelection) { + e.preventDefault() + onToggleCellSelection(cellId) + } + }} onKeyDown={(e) => handleKeyDown(e, cellId)} className={`w-full px-2 bg-transparent border-0 outline-none ${ isBold ? 'font-bold' : 'font-normal' diff --git a/admin-lehrer/components/grid-editor/ImageLayoutEditor.tsx b/admin-lehrer/components/grid-editor/ImageLayoutEditor.tsx new file mode 100644 index 0000000..cd5c01b --- /dev/null +++ b/admin-lehrer/components/grid-editor/ImageLayoutEditor.tsx @@ -0,0 +1,386 @@ +'use client' + +/** + * ImageLayoutEditor — SVG overlay on the original scan image. + * + * Shows draggable vertical column dividers and horizontal guidelines + * (margins, header/footer zones). Double-click to add a column, + * click the × on a divider to remove it. + */ + +import { useCallback, useRef } from 'react' +import type { GridZone, LayoutDividers } from './types' + +interface ImageLayoutEditorProps { + imageUrl: string + zones: GridZone[] + imageWidth: number + layoutDividers?: LayoutDividers + zoom: number + onZoomChange: (zoom: number) => void + onColumnDividerMove: (zoneIndex: number, boundaryIndex: number, newXPct: number) => void + onHorizontalsChange: (horizontals: LayoutDividers['horizontals']) => void + onCommitUndo: () => void + onSplitColumnAt: (zoneIndex: number, xPct: number) => void + onDeleteColumn: (zoneIndex: number, colIndex: number) => void +} + +const HORIZ_COLORS: Record = { + top_margin: 'rgba(239, 68, 68, 0.6)', + header_bottom: 'rgba(59, 130, 246, 0.6)', + footer_top: 'rgba(249, 115, 22, 0.6)', + bottom_margin: 'rgba(239, 68, 68, 0.6)', +} + +const HORIZ_LABELS: Record = { + top_margin: 'Rand oben', + header_bottom: 'Kopfzeile', + footer_top: 'Fusszeile', + bottom_margin: 'Rand unten', +} + +const HORIZ_DEFAULTS: Record = { + top_margin: 3, + header_bottom: 10, + footer_top: 92, + bottom_margin: 97, +} + +function clamp(val: number, min: number, max: number) { + return Math.max(min, Math.min(max, val)) +} + +export function ImageLayoutEditor({ + imageUrl, + zones, + layoutDividers, + zoom, + onZoomChange, + onColumnDividerMove, + onHorizontalsChange, + onCommitUndo, + onSplitColumnAt, + onDeleteColumn, +}: ImageLayoutEditorProps) { + const wrapperRef = useRef(null) + const draggingRef = useRef< + | { type: 'col'; zoneIndex: number; boundaryIndex: number } + | { type: 'horiz'; key: string } + | null + >(null) + const horizontalsRef = useRef(layoutDividers?.horizontals ?? {}) + horizontalsRef.current = layoutDividers?.horizontals ?? {} + + const horizontals = layoutDividers?.horizontals ?? {} + + // Compute column boundaries for each zone + const zoneBoundaries = zones.map((zone) => { + const sorted = [...zone.columns].sort((a, b) => a.index - b.index) + const boundaries: number[] = [] + if (sorted.length > 0) { + const hasValidPct = sorted.some((c) => c.x_max_pct > 0) + if (hasValidPct) { + boundaries.push(sorted[0].x_min_pct) + for (const col of sorted) { + boundaries.push(col.x_max_pct) + } + } else { + // Fallback: evenly distribute within zone bbox + const zoneX = zone.bbox_pct.x || 0 + const zoneW = zone.bbox_pct.w || 100 + for (let i = 0; i <= sorted.length; i++) { + boundaries.push(zoneX + (i / sorted.length) * zoneW) + } + } + } + return { zone, boundaries } + }) + + const startDrag = useCallback( + ( + info: NonNullable, + e: React.MouseEvent, + ) => { + e.preventDefault() + e.stopPropagation() + draggingRef.current = info + onCommitUndo() + + const handleMove = (ev: MouseEvent) => { + const wrap = wrapperRef.current + if (!wrap || !draggingRef.current) return + const rect = wrap.getBoundingClientRect() + const xPct = clamp(((ev.clientX - rect.left) / rect.width) * 100, 0, 100) + const yPct = clamp(((ev.clientY - rect.top) / rect.height) * 100, 0, 100) + + if (draggingRef.current.type === 'col') { + onColumnDividerMove( + draggingRef.current.zoneIndex, + draggingRef.current.boundaryIndex, + xPct, + ) + } else { + onHorizontalsChange({ + ...horizontalsRef.current, + [draggingRef.current.key]: yPct, + }) + } + } + + const handleUp = () => { + draggingRef.current = null + document.removeEventListener('mousemove', handleMove) + document.removeEventListener('mouseup', handleUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + document.body.style.cursor = info.type === 'col' ? 'col-resize' : 'row-resize' + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', handleMove) + document.addEventListener('mouseup', handleUp) + }, + [onColumnDividerMove, onHorizontalsChange, onCommitUndo], + ) + + const toggleHorizontal = (key: string) => { + const current = horizontals[key as keyof typeof horizontals] + if (current != null) { + const next = { ...horizontals } + delete next[key as keyof typeof next] + onHorizontalsChange(next) + } else { + onCommitUndo() + onHorizontalsChange({ + ...horizontals, + [key]: HORIZ_DEFAULTS[key], + }) + } + } + + const handleDoubleClick = (e: React.MouseEvent) => { + const wrap = wrapperRef.current + if (!wrap) return + const rect = wrap.getBoundingClientRect() + const xPct = clamp(((e.clientX - rect.left) / rect.width) * 100, 0, 100) + const yPct = clamp(((e.clientY - rect.top) / rect.height) * 100, 0, 100) + + // Find which zone this click is in + for (const { zone } of zoneBoundaries) { + const zy = zone.bbox_pct.y || 0 + const zh = zone.bbox_pct.h || 100 + if (yPct >= zy && yPct <= zy + zh) { + onSplitColumnAt(zone.zone_index, xPct) + return + } + } + // Fallback: use first zone + if (zones.length > 0) { + onSplitColumnAt(zones[0].zone_index, xPct) + } + } + + return ( +
+ {/* Header */} +
+ + Layout-Editor + +
+ + + {zoom}% + + + +
+
+ + {/* Horizontal line toggles */} +
+ {Object.entries(HORIZ_LABELS).map(([key, label]) => { + const isActive = horizontals[key as keyof typeof horizontals] != null + return ( + + ) + })} + + Doppelklick = Spalte einfuegen + +
+ + {/* Scrollable image with SVG overlay */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Original scan + {/* SVG overlay */} + + {/* Column boundary lines per zone */} + {zoneBoundaries.map(({ zone, boundaries }) => + boundaries.map((xPct, bi) => { + const yTop = zone.bbox_pct.y || 0 + const yBottom = (zone.bbox_pct.y || 0) + (zone.bbox_pct.h || 100) + const isEdge = bi === 0 || bi === boundaries.length - 1 + const isInterior = bi > 0 && bi < boundaries.length - 1 + return ( + + {/* Wide invisible hit area */} + + startDrag( + { type: 'col', zoneIndex: zone.zone_index, boundaryIndex: bi }, + e, + ) + } + /> + {/* Visible line */} + + {/* Delete button for interior dividers */} + {isInterior && zone.columns.length > 1 && ( + { + e.stopPropagation() + onDeleteColumn(zone.zone_index, bi) + }} + > + + + x + + + )} + + ) + }), + )} + + {/* Horizontal guideline lines */} + {Object.entries(horizontals).map(([key, yPct]) => { + if (yPct == null) return null + const color = HORIZ_COLORS[key] ?? 'rgba(156, 163, 175, 0.6)' + return ( + + {/* Wide invisible hit area */} + startDrag({ type: 'horiz', key }, e)} + /> + {/* Visible line */} + + {/* Label */} + + {HORIZ_LABELS[key]} + + + ) + })} + +
+
+
+ ) +} diff --git a/admin-lehrer/components/grid-editor/types.ts b/admin-lehrer/components/grid-editor/types.ts index c7b9f6c..eb1bd75 100644 --- a/admin-lehrer/components/grid-editor/types.ts +++ b/admin-lehrer/components/grid-editor/types.ts @@ -11,6 +11,15 @@ export interface LayoutMetrics { font_size_suggestion_px: number } +/** Dictionary detection result from backend analysis. */ +export interface DictionaryDetection { + is_dictionary: boolean + confidence: number + signals: Record + article_col_index: number | null + headword_col_index: number | null +} + /** A complete structured grid with zones, ready for the Excel-like editor. */ export interface StructuredGrid { session_id: string @@ -21,8 +30,10 @@ export interface StructuredGrid { summary: GridSummary formatting: GridFormatting layout_metrics?: LayoutMetrics + dictionary_detection?: DictionaryDetection duration_seconds: number edited?: boolean + layout_dividers?: LayoutDividers } export interface GridSummary { @@ -103,6 +114,16 @@ export interface GridEditorCell { is_bold: boolean } +/** Layout dividers for the visual column/margin editor on the original image. */ +export interface LayoutDividers { + horizontals: { + top_margin?: number + header_bottom?: number + footer_top?: number + bottom_margin?: number + } +} + /** Cell formatting applied by the user in the editor. */ export interface CellFormatting { bold: boolean diff --git a/admin-lehrer/components/grid-editor/useGridEditor.ts b/admin-lehrer/components/grid-editor/useGridEditor.ts index cf425be..e1c84e6 100644 --- a/admin-lehrer/components/grid-editor/useGridEditor.ts +++ b/admin-lehrer/components/grid-editor/useGridEditor.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useState } from 'react' -import type { StructuredGrid, GridZone } from './types' +import type { StructuredGrid, GridZone, LayoutDividers } from './types' const KLAUSUR_API = '/klausur-api' const MAX_UNDO = 50 @@ -134,12 +134,40 @@ export function useGridEditor(sessionId: string | null) { if (!prev) return prev return { ...prev, - zones: prev.zones.map((zone) => ({ - ...zone, - cells: zone.cells.map((cell) => - cell.cell_id === cellId ? { ...cell, text: newText } : cell, - ), - })), + zones: prev.zones.map((zone) => { + // Check if cell exists + const existing = zone.cells.find((c) => c.cell_id === cellId) + if (existing) { + return { + ...zone, + cells: zone.cells.map((cell) => + cell.cell_id === cellId ? { ...cell, text: newText } : cell, + ), + } + } + // Cell doesn't exist — create it if the cellId belongs to this zone + // cellId format: Z{zone}_R{row}_C{col} + const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/) + if (!match || parseInt(match[1]) !== zone.zone_index) return zone + const rowIndex = parseInt(match[2]) + const colIndex = parseInt(match[3]) + const col = zone.columns.find((c) => c.index === colIndex) + const newCell = { + cell_id: cellId, + zone_index: zone.zone_index, + row_index: rowIndex, + col_index: colIndex, + col_type: col?.label ?? '', + text: newText, + confidence: 0, + bbox_px: { x: 0, y: 0, w: 0, h: 0 }, + bbox_pct: { x: 0, y: 0, w: 0, h: 0 }, + word_boxes: [], + ocr_engine: 'manual', + is_bold: false, + } + return { ...zone, cells: [...zone.cells, newCell] } + }), } }) setDirty(true) @@ -192,6 +220,7 @@ export function useGridEditor(sessionId: string | null) { if (!grid) return pushUndo(grid.zones) + // Cycle: normal → header → footer → normal setGrid((prev) => { if (!prev) return prev return { @@ -200,9 +229,16 @@ export function useGridEditor(sessionId: string | null) { if (zone.zone_index !== zoneIndex) return zone return { ...zone, - rows: zone.rows.map((r) => - r.index === rowIndex ? { ...r, is_header: !r.is_header } : r, - ), + rows: zone.rows.map((r) => { + if (r.index !== rowIndex) return r + if (!r.is_header && !r.is_footer) { + return { ...r, is_header: true, is_footer: false } + } else if (r.is_header) { + return { ...r, is_header: false, is_footer: true } + } else { + return { ...r, is_header: false, is_footer: false } + } + }), } }), } @@ -229,9 +265,23 @@ export function useGridEditor(sessionId: string | null) { ...prev, zones: prev.zones.map((z) => { if (z.zone_index !== zoneIndex) return z + const deletedCol = z.columns.find((c) => c.index === colIndex) const newColumns = z.columns .filter((c) => c.index !== colIndex) - .map((c, i) => ({ ...c, index: i, label: `column_${i + 1}` })) + .map((c, i) => { + const result = { ...c, index: i, label: `column_${i + 1}` } + // Merge x-boundary: previous column absorbs deleted column's space + if (deletedCol) { + if (c.index === colIndex - 1) { + result.x_max_pct = deletedCol.x_max_pct + result.x_max_px = deletedCol.x_max_px + } else if (colIndex === 0 && c.index === 1) { + result.x_min_pct = deletedCol.x_min_pct + result.x_min_px = deletedCol.x_min_px + } + } + return result + }) const newCells = z.cells .filter((c) => c.col_index !== colIndex) .map((c) => { @@ -337,6 +387,323 @@ export function useGridEditor(sessionId: string | null) { [grid, pushUndo], ) + // ------------------------------------------------------------------ + // Row management + // ------------------------------------------------------------------ + + const deleteRow = useCallback( + (zoneIndex: number, rowIndex: number) => { + if (!grid) return + const zone = grid.zones.find((z) => z.zone_index === zoneIndex) + if (!zone || zone.rows.length <= 1) return // keep at least 1 row + pushUndo(grid.zones) + + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + zones: prev.zones.map((z) => { + if (z.zone_index !== zoneIndex) return z + const newRows = z.rows + .filter((r) => r.index !== rowIndex) + .map((r, i) => ({ ...r, index: i })) + const newCells = z.cells + .filter((c) => c.row_index !== rowIndex) + .map((c) => { + const newRI = c.row_index > rowIndex ? c.row_index - 1 : c.row_index + return { + ...c, + row_index: newRI, + cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`, + } + }) + return { ...z, rows: newRows, cells: newCells } + }), + summary: { + ...prev.summary, + total_rows: prev.summary.total_rows - 1, + total_cells: prev.zones.reduce( + (sum, z) => + sum + + (z.zone_index === zoneIndex + ? z.cells.filter((c) => c.row_index !== rowIndex).length + : z.cells.length), + 0, + ), + }, + } + }) + setDirty(true) + }, + [grid, pushUndo], + ) + + const addRow = useCallback( + (zoneIndex: number, afterRowIndex: number) => { + if (!grid) return + const zone = grid.zones.find((z) => z.zone_index === zoneIndex) + if (!zone) return + pushUndo(grid.zones) + + const newRowIndex = afterRowIndex + 1 + + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + zones: prev.zones.map((z) => { + if (z.zone_index !== zoneIndex) return z + // Shift existing rows + const shiftedRows = z.rows.map((r) => + r.index > afterRowIndex ? { ...r, index: r.index + 1 } : r, + ) + // Insert new row + const refRow = z.rows.find((r) => r.index === afterRowIndex) || z.rows[z.rows.length - 1] + const newRow = { + index: newRowIndex, + y_min_px: refRow.y_max_px, + y_max_px: refRow.y_max_px + (refRow.y_max_px - refRow.y_min_px), + y_min_pct: refRow.y_max_pct, + y_max_pct: Math.min(100, refRow.y_max_pct + (refRow.y_max_pct - refRow.y_min_pct)), + is_header: false, + is_footer: false, + } + const allRows = [...shiftedRows, newRow].sort((a, b) => a.index - b.index) + + // Shift existing cells + const shiftedCells = z.cells.map((c) => { + if (c.row_index > afterRowIndex) { + const newRI = c.row_index + 1 + return { + ...c, + row_index: newRI, + cell_id: `Z${zoneIndex}_R${String(newRI).padStart(2, '0')}_C${c.col_index}`, + } + } + return c + }) + // Create empty cells for each column + const newCells = z.columns.map((col) => ({ + cell_id: `Z${zoneIndex}_R${String(newRowIndex).padStart(2, '0')}_C${col.index}`, + zone_index: zoneIndex, + row_index: newRowIndex, + col_index: col.index, + col_type: col.label, + text: '', + confidence: 0, + bbox_px: { x: 0, y: 0, w: 0, h: 0 }, + bbox_pct: { x: 0, y: 0, w: 0, h: 0 }, + word_boxes: [], + ocr_engine: 'manual', + is_bold: false, + })) + + return { ...z, rows: allRows, cells: [...shiftedCells, ...newCells] } + }), + summary: { + ...prev.summary, + total_rows: prev.summary.total_rows + 1, + total_cells: prev.summary.total_cells + (zone?.columns.length ?? 0), + }, + } + }) + setDirty(true) + }, + [grid, pushUndo], + ) + + // ------------------------------------------------------------------ + // Layout editing (image overlay) + // ------------------------------------------------------------------ + + /** Capture current state for undo — call once at drag start. */ + const commitUndoPoint = useCallback(() => { + if (!grid) return + pushUndo(grid.zones) + }, [grid, pushUndo]) + + /** Move a column boundary. boundaryIndex 0 = left edge of col 0, etc. */ + const updateColumnDivider = useCallback( + (zoneIndex: number, boundaryIndex: number, newXPct: number) => { + if (!grid) return + setGrid((prev) => { + if (!prev) return prev + const imgW = prev.image_width || 1 + const newPx = Math.round((newXPct / 100) * imgW) + return { + ...prev, + zones: prev.zones.map((z) => { + if (z.zone_index !== zoneIndex) return z + return { + ...z, + columns: z.columns.map((col) => { + // Right edge of the column before this boundary + if (col.index === boundaryIndex - 1) { + return { ...col, x_max_pct: newXPct, x_max_px: newPx } + } + // Left edge of the column at this boundary + if (col.index === boundaryIndex) { + return { ...col, x_min_pct: newXPct, x_min_px: newPx } + } + return col + }), + } + }), + } + }) + setDirty(true) + }, + [grid], + ) + + /** Update horizontal layout guidelines (margins, header, footer). */ + const updateLayoutHorizontals = useCallback( + (horizontals: LayoutDividers['horizontals']) => { + if (!grid) return + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + layout_dividers: { + ...(prev.layout_dividers || { horizontals: {} }), + horizontals, + }, + } + }) + setDirty(true) + }, + [grid], + ) + + /** Split a column at a given x percentage, creating a new column. */ + const splitColumnAt = useCallback( + (zoneIndex: number, xPct: number) => { + if (!grid) return + const zone = grid.zones.find((z) => z.zone_index === zoneIndex) + if (!zone) return + + const sorted = [...zone.columns].sort((a, b) => a.index - b.index) + const targetCol = sorted.find((c) => c.x_min_pct <= xPct && c.x_max_pct >= xPct) + if (!targetCol) return + + pushUndo(grid.zones) + const newColIndex = targetCol.index + 1 + const imgW = grid.image_width || 1 + + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + zones: prev.zones.map((z) => { + if (z.zone_index !== zoneIndex) return z + const leftCol = { + ...targetCol, + x_max_pct: xPct, + x_max_px: Math.round((xPct / 100) * imgW), + } + const rightCol = { + index: newColIndex, + label: `column_${newColIndex + 1}`, + x_min_pct: xPct, + x_max_pct: targetCol.x_max_pct, + x_min_px: Math.round((xPct / 100) * imgW), + x_max_px: targetCol.x_max_px, + bold: false, + } + const updatedCols = z.columns.map((c) => { + if (c.index === targetCol.index) return leftCol + if (c.index > targetCol.index) return { ...c, index: c.index + 1, label: `column_${c.index + 2}` } + return c + }) + const allCols = [...updatedCols, rightCol].sort((a, b) => a.index - b.index) + const shiftedCells = z.cells.map((c) => { + if (c.col_index > targetCol.index) { + const newCI = c.col_index + 1 + return { + ...c, + col_index: newCI, + cell_id: `Z${zoneIndex}_R${String(c.row_index).padStart(2, '0')}_C${newCI}`, + } + } + return c + }) + const newCells = z.rows.map((row) => ({ + cell_id: `Z${zoneIndex}_R${String(row.index).padStart(2, '0')}_C${newColIndex}`, + zone_index: zoneIndex, + row_index: row.index, + col_index: newColIndex, + col_type: `column_${newColIndex + 1}`, + text: '', + confidence: 0, + bbox_px: { x: 0, y: 0, w: 0, h: 0 }, + bbox_pct: { x: 0, y: 0, w: 0, h: 0 }, + word_boxes: [], + ocr_engine: 'manual', + is_bold: false, + })) + return { ...z, columns: allCols, cells: [...shiftedCells, ...newCells] } + }), + summary: { + ...prev.summary, + total_columns: prev.summary.total_columns + 1, + total_cells: prev.summary.total_cells + (zone.rows.length), + }, + } + }) + setDirty(true) + }, + [grid, pushUndo], + ) + + // ------------------------------------------------------------------ + // Multi-select & bulk formatting + // ------------------------------------------------------------------ + + const [selectedCells, setSelectedCells] = useState>(new Set()) + + const toggleCellSelection = useCallback( + (cellId: string) => { + setSelectedCells((prev) => { + const next = new Set(prev) + if (next.has(cellId)) next.delete(cellId) + else next.add(cellId) + return next + }) + }, + [], + ) + + const clearCellSelection = useCallback(() => { + setSelectedCells(new Set()) + }, []) + + /** Toggle bold on all selected cells (and their columns). */ + const toggleSelectedBold = useCallback(() => { + if (!grid || selectedCells.size === 0) return + pushUndo(grid.zones) + + // Determine if we're turning bold on or off (majority rule) + const cells = grid.zones.flatMap((z) => z.cells) + const selectedArr = cells.filter((c) => selectedCells.has(c.cell_id)) + const boldCount = selectedArr.filter((c) => c.is_bold).length + const newBold = boldCount < selectedArr.length / 2 + + setGrid((prev) => { + if (!prev) return prev + return { + ...prev, + zones: prev.zones.map((zone) => ({ + ...zone, + cells: zone.cells.map((cell) => + selectedCells.has(cell.cell_id) ? { ...cell, is_bold: newBold } : cell, + ), + })), + } + }) + setDirty(true) + setSelectedCells(new Set()) + }, [grid, selectedCells, pushUndo]) + // ------------------------------------------------------------------ // Undo / Redo // ------------------------------------------------------------------ @@ -368,20 +735,37 @@ export function useGridEditor(sessionId: string | null) { (cellId: string, direction: 'up' | 'down' | 'left' | 'right'): string | null => { if (!grid) return null for (const zone of grid.zones) { + // Find the cell or derive row/col from cellId pattern const cell = zone.cells.find((c) => c.cell_id === cellId) - if (!cell) continue + let currentRow: number, currentCol: number + if (cell) { + currentRow = cell.row_index + currentCol = cell.col_index + } else { + // Try to parse from cellId: Z{zone}_R{row}_C{col} + const match = cellId.match(/^Z(\d+)_R(\d+)_C(\d+)$/) + if (!match || parseInt(match[1]) !== zone.zone_index) continue + currentRow = parseInt(match[2]) + currentCol = parseInt(match[3]) + } - let targetRow = cell.row_index - let targetCol = cell.col_index + let targetRow = currentRow + let targetCol = currentCol if (direction === 'up') targetRow-- if (direction === 'down') targetRow++ if (direction === 'left') targetCol-- if (direction === 'right') targetCol++ + // Check bounds + const hasRow = zone.rows.some((r) => r.index === targetRow) + const hasCol = zone.columns.some((c) => c.index === targetCol) + if (!hasRow || !hasCol) return null + + // Return existing cell ID or construct one const target = zone.cells.find( (c) => c.row_index === targetRow && c.col_index === targetCol, ) - return target?.cell_id ?? null + return target?.cell_id ?? `Z${zone.zone_index}_R${String(targetRow).padStart(2, '0')}_C${targetCol}` } return null }, @@ -396,6 +780,7 @@ export function useGridEditor(sessionId: string | null) { dirty, selectedCell, selectedZone, + selectedCells, setSelectedCell, setSelectedZone, buildGrid, @@ -411,5 +796,14 @@ export function useGridEditor(sessionId: string | null) { getAdjacentCell, deleteColumn, addColumn, + deleteRow, + addRow, + commitUndoPoint, + updateColumnDivider, + updateLayoutHorizontals, + splitColumnAt, + toggleCellSelection, + clearCellSelection, + toggleSelectedBold, } } diff --git a/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx b/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx index 0339d6c..5f4a319 100644 --- a/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx @@ -8,20 +8,22 @@ * the GT marking flow in the parent page. */ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' import { useGridEditor } from '@/components/grid-editor/useGridEditor' -import type { GridZone } from '@/components/grid-editor/types' +import type { GridZone, LayoutDividers } from '@/components/grid-editor/types' import { GridToolbar } from '@/components/grid-editor/GridToolbar' import { GridTable } from '@/components/grid-editor/GridTable' +import { ImageLayoutEditor } from '@/components/grid-editor/ImageLayoutEditor' const KLAUSUR_API = '/klausur-api' interface StepGridReviewProps { sessionId: string | null onNext?: () => void + saveRef?: MutableRefObject<(() => Promise) | null> } -export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { +export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewProps) { const { grid, loading, @@ -29,6 +31,7 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { error, dirty, selectedCell, + selectedCells, setSelectedCell, buildGrid, loadGrid, @@ -43,12 +46,31 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { getAdjacentCell, deleteColumn, addColumn, + deleteRow, + addRow, + commitUndoPoint, + updateColumnDivider, + updateLayoutHorizontals, + splitColumnAt, + toggleCellSelection, + clearCellSelection, + toggleSelectedBold, } = useGridEditor(sessionId) const [showImage, setShowImage] = useState(true) const [zoom, setZoom] = useState(100) const [acceptedRows, setAcceptedRows] = useState>(new Set()) + // Expose save function to parent via ref (for GT marking auto-save) + useEffect(() => { + if (saveRef) { + saveRef.current = async () => { + if (dirty) await saveGrid() + } + return () => { saveRef.current = null } + } + }, [saveRef, dirty, saveGrid]) + // Load grid on mount useEffect(() => { if (sessionId) loadGrid() @@ -71,11 +93,18 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { } 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]) + }, [undo, redo, saveGrid, selectedCells, toggleSelectedBold, clearCellSelection]) const handleNavigate = useCallback( (cellId: string, direction: 'up' | 'down' | 'left' | 'right') => { @@ -195,6 +224,11 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { {grid.summary.total_zones} Zone(n), {grid.summary.total_columns} Spalten,{' '} {grid.summary.total_rows} Zeilen, {grid.summary.total_cells} Zellen + {grid.dictionary_detection?.is_dictionary && ( + + Woerterbuch ({Math.round(grid.dictionary_detection.confidence * 100)}%) + + )} {lowConfCells.length > 0 && ( {lowConfCells.length} niedrige Konfidenz @@ -249,47 +283,21 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { className={showImage ? 'grid grid-cols-2 gap-3' : ''} style={{ minHeight: '55vh' }} > - {/* Left: Original Image */} + {/* Left: Original Image with Layout Editor */} {showImage && ( -
-
- - Original Scan (zugeschnitten) - -
- - - {zoom}% - - - -
-
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Original scan -
-
+ )} {/* Right: Grid with row-accept buttons */} @@ -370,13 +378,17 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) { 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} />
))} @@ -388,11 +400,34 @@ export function StepGridReview({ sessionId, onNext }: StepGridReviewProps) {
+ {/* Multi-select toolbar */} + {selectedCells.size > 0 && ( +
+ + {selectedCells.size} Zellen markiert + + + +
+ )} + {/* Tips + Next */}
Tab: naechste Zelle - Enter: Zeile runter + Pfeiltasten: Navigation + Ctrl+Klick: Mehrfachauswahl + Ctrl+B: Fett Ctrl+Z/Y: Undo/Redo Ctrl+S: Speichern
diff --git a/klausur-service/backend/cv_layout.py b/klausur-service/backend/cv_layout.py index 01daf1c..32ef9a6 100644 --- a/klausur-service/backend/cv_layout.py +++ b/klausur-service/backend/cv_layout.py @@ -2275,6 +2275,324 @@ def _score_role(geom: ColumnGeometry) -> Dict[str, float]: return {k: round(v, 3) for k, v in scores.items()} +# --- Dictionary / Wörterbuch Detection --- + +# Article words that appear as a dedicated column in dictionaries +_DICT_ARTICLE_WORDS = { + # German articles + "die", "der", "das", "dem", "den", "des", "ein", "eine", "einem", "einer", + # English articles / infinitive marker + "the", "a", "an", "to", +} + + +def _score_dictionary_signals( + geometries: List[ColumnGeometry], + document_category: Optional[str] = None, + margin_strip_detected: bool = False, +) -> Dict[str, Any]: + """Score dictionary-specific patterns across all columns. + + Combines 4 independent signals to determine if the page is a dictionary: + 1. Alphabetical ordering of words in each column + 2. Article column detection (der/die/das, to) + 3. First-letter uniformity (most headwords share a letter) + 4. Decorative A-Z margin strip (detected upstream) + + Args: + geometries: List of ColumnGeometry with words. + document_category: User-selected category (e.g. 'woerterbuch'). + margin_strip_detected: Whether a decorative A-Z margin strip was found. + + Returns: + Dict with 'is_dictionary', 'confidence', 'article_col_index', + 'headword_col_index', and 'signals' sub-dict. + """ + result: Dict[str, Any] = { + "is_dictionary": False, + "confidence": 0.0, + "article_col_index": None, + "headword_col_index": None, + "signals": {}, + } + + if not geometries or len(geometries) < 2: + return result + + # --- Signal 1: Alphabetical ordering per column (weight 0.35) --- + best_alpha_score = 0.0 + best_alpha_col = -1 + for geom in geometries: + texts = [ + w["text"].strip().lower() + for w in sorted(geom.words, key=lambda w: w.get("top", 0)) + if w.get("conf", 0) > 30 and len(w["text"].strip()) >= 2 + ] + if len(texts) < 5: + continue + # Deduplicate consecutive identical words (OCR double-reads) + deduped = [texts[0]] + for t in texts[1:]: + if t != deduped[-1]: + deduped.append(t) + if len(deduped) < 5: + continue + # Count consecutive pairs in alphabetical order + ordered_pairs = sum( + 1 for i in range(len(deduped) - 1) + if deduped[i] <= deduped[i + 1] + ) + alpha_score = ordered_pairs / (len(deduped) - 1) + if alpha_score > best_alpha_score: + best_alpha_score = alpha_score + best_alpha_col = geom.index + + result["signals"]["alphabetical_score"] = round(best_alpha_score, 3) + result["signals"]["alphabetical_col"] = best_alpha_col + + # --- Signal 2: Article detection (weight 0.25) --- + # Check three patterns: + # (a) Dedicated narrow article column (der/die/das only) + # (b) Inline articles: multi-word texts starting with "der X", "die X" + # (c) High article word frequency: many individual words ARE articles + # (common when OCR splits "der Zustand" into separate word_boxes) + best_article_density = 0.0 + best_article_col = -1 + best_inline_article_ratio = 0.0 + best_article_word_ratio = 0.0 + + for geom in geometries: + texts = [ + w["text"].strip().lower() + for w in geom.words + if w.get("conf", 0) > 30 and len(w["text"].strip()) > 0 + ] + if len(texts) < 3: + continue + + # (a) Dedicated article column: narrow, mostly article words + article_count = sum(1 for t in texts if t in _DICT_ARTICLE_WORDS) + if geom.width_ratio <= 0.20: + density = article_count / len(texts) + if density > best_article_density: + best_article_density = density + best_article_col = geom.index + + # (b) Inline articles: "der Zustand", "die Zutat", etc. + inline_count = sum( + 1 for t in texts + if any(t.startswith(art + " ") for art in _DICT_ARTICLE_WORDS) + ) + inline_ratio = inline_count / len(texts) + if inline_ratio > best_inline_article_ratio: + best_inline_article_ratio = inline_ratio + + # (c) Article word frequency in any column (for OCR-split word_boxes) + # In dictionaries, articles appear frequently among headwords + # Require at least 10% articles and >= 3 article words + if article_count >= 3: + art_ratio = article_count / len(texts) + # Only count if column has enough non-article words too + # (pure article column is handled by (a)) + non_art = len(texts) - article_count + if non_art >= 3 and art_ratio > best_article_word_ratio: + best_article_word_ratio = art_ratio + + # Use the strongest signal + effective_article_score = max( + best_article_density, + best_inline_article_ratio, + best_article_word_ratio * 0.8, # slight discount for raw word ratio + ) + + result["signals"]["article_density"] = round(best_article_density, 3) + result["signals"]["inline_article_ratio"] = round(best_inline_article_ratio, 3) + result["signals"]["article_word_ratio"] = round(best_article_word_ratio, 3) + result["signals"]["article_col"] = best_article_col + + # --- Signal 3: First-letter uniformity (weight 0.25) --- + best_uniformity = 0.0 + best_uniform_col = -1 + has_letter_transition = False + for geom in geometries: + texts = [ + w["text"].strip().lower() + for w in sorted(geom.words, key=lambda w: w.get("top", 0)) + if w.get("conf", 0) > 30 and len(w["text"].strip()) >= 2 + ] + if len(texts) < 5: + continue + # Count first letters + first_letters = [t[0] for t in texts if t[0].isalpha()] + if not first_letters: + continue + from collections import Counter + letter_counts = Counter(first_letters) + most_common_letter, most_common_count = letter_counts.most_common(1)[0] + uniformity = most_common_count / len(first_letters) + + # Check for orderly letter transitions (A→B or Y→Z) + # Group consecutive words by first letter, check if groups are in order + groups = [] + current_letter = first_letters[0] + for fl in first_letters: + if fl != current_letter: + groups.append(current_letter) + current_letter = fl + groups.append(current_letter) + if len(groups) >= 2 and len(groups) <= 5: + # Check if groups are alphabetically ordered + if all(groups[i] <= groups[i + 1] for i in range(len(groups) - 1)): + has_letter_transition = True + # Boost uniformity for orderly transitions + uniformity = max(uniformity, 0.70) + + if uniformity > best_uniformity: + best_uniformity = uniformity + best_uniform_col = geom.index + + result["signals"]["first_letter_uniformity"] = round(best_uniformity, 3) + result["signals"]["uniform_col"] = best_uniform_col + result["signals"]["has_letter_transition"] = has_letter_transition + + # --- Signal 4: Decorative margin strip (weight 0.15) --- + result["signals"]["margin_strip_detected"] = margin_strip_detected + + # --- Combine signals --- + s1 = min(best_alpha_score, 1.0) * 0.35 + s2 = min(effective_article_score, 1.0) * 0.25 + s3 = min(best_uniformity, 1.0) * 0.25 + s4 = (1.0 if margin_strip_detected else 0.0) * 0.15 + + combined = s1 + s2 + s3 + s4 + + # Boost if user set document_category to 'woerterbuch' + if document_category == "woerterbuch": + combined = min(1.0, combined + 0.20) + result["signals"]["category_boost"] = True + + result["confidence"] = round(combined, 3) + + # Threshold: combined >= 0.40 to classify as dictionary + # (at least 2 strong signals or 3 moderate ones) + if combined >= 0.40: + result["is_dictionary"] = True + # Identify headword column: best alphabetical OR best uniform + if best_alpha_col >= 0 and best_alpha_score >= 0.60: + result["headword_col_index"] = best_alpha_col + elif best_uniform_col >= 0 and best_uniformity >= 0.50: + result["headword_col_index"] = best_uniform_col + if best_article_col >= 0 and best_article_density >= 0.30: + result["article_col_index"] = best_article_col + # If inline articles are strong but no dedicated column, note it + if best_inline_article_ratio >= 0.30 and result["article_col_index"] is None: + result["signals"]["inline_articles_detected"] = True + + logger.info( + "DictionaryDetection: combined=%.3f is_dict=%s signals=%s", + combined, result["is_dictionary"], result["signals"], + ) + + return result + + +def _classify_dictionary_columns( + geometries: List[ColumnGeometry], + dict_signals: Dict[str, Any], + lang_scores: List[Dict[str, float]], + content_h: int, +) -> Optional[List[PageRegion]]: + """Classify columns for a detected dictionary page. + + Assigns column_headword, column_article, column_ipa, and + column_de/column_en based on dictionary signals and language scores. + + Returns None if classification fails. + """ + if not dict_signals.get("is_dictionary"): + return None + + regions: List[PageRegion] = [] + assigned = set() + article_idx = dict_signals.get("article_col_index") + headword_idx = dict_signals.get("headword_col_index") + + # 1. Assign article column if detected + if article_idx is not None: + for geom in geometries: + if geom.index == article_idx: + regions.append(PageRegion( + type="column_article", + x=geom.x, y=geom.y, + width=geom.width, height=content_h, + classification_confidence=round( + dict_signals["signals"].get("article_density", 0.5), 2), + classification_method="dictionary", + )) + assigned.add(geom.index) + break + + # 2. Assign headword column + if headword_idx is not None and headword_idx not in assigned: + for geom in geometries: + if geom.index == headword_idx: + regions.append(PageRegion( + type="column_headword", + x=geom.x, y=geom.y, + width=geom.width, height=content_h, + classification_confidence=round( + dict_signals["confidence"], 2), + classification_method="dictionary", + )) + assigned.add(geom.index) + break + + # 3. Assign remaining columns by language + content + remaining = [g for g in geometries if g.index not in assigned] + for geom in remaining: + ls = lang_scores[geom.index] if geom.index < len(lang_scores) else {"eng": 0, "deu": 0} + + # Check if column contains IPA (brackets like [, /, ˈ) + ipa_chars = sum( + 1 for w in geom.words + if any(c in (w.get("text") or "") for c in "[]/ˈˌːɪəɒʊæɑɔ") + ) + ipa_ratio = ipa_chars / max(len(geom.words), 1) + + if ipa_ratio > 0.25: + col_type = "column_ipa" + conf = round(min(1.0, ipa_ratio), 2) + elif ls["deu"] > ls["eng"] and ls["deu"] > 0.05: + col_type = "column_de" + conf = round(ls["deu"], 2) + elif ls["eng"] > ls["deu"] and ls["eng"] > 0.05: + col_type = "column_en" + conf = round(ls["eng"], 2) + else: + # Positional fallback: leftmost unassigned = EN, next = DE + left_unassigned = sorted( + [g for g in remaining if g.index not in assigned], + key=lambda g: g.x, + ) + if geom == left_unassigned[0] if left_unassigned else None: + col_type = "column_en" + else: + col_type = "column_de" + conf = 0.4 + + regions.append(PageRegion( + type=col_type, + x=geom.x, y=geom.y, + width=geom.width, height=content_h, + classification_confidence=conf, + classification_method="dictionary", + )) + assigned.add(geom.index) + + regions.sort(key=lambda r: r.x) + return regions + + def _build_margin_regions( all_regions: List[PageRegion], left_x: int, @@ -2418,9 +2736,12 @@ def classify_column_types(geometries: List[ColumnGeometry], bottom_y: int, left_x: int = 0, right_x: int = 0, - inv: Optional[np.ndarray] = None) -> List[PageRegion]: + inv: Optional[np.ndarray] = None, + document_category: Optional[str] = None, + margin_strip_detected: bool = False) -> List[PageRegion]: """Classify column types using a 3-level fallback chain. + Level 0: Dictionary detection (if signals are strong enough) Level 1: Content-based (language + role scoring) Level 2: Position + language (old rules enhanced with language detection) Level 3: Pure position (exact old code, no regression) @@ -2434,6 +2755,8 @@ def classify_column_types(geometries: List[ColumnGeometry], bottom_y: Bottom Y of content area. left_x: Left content bound (from _find_content_bounds). right_x: Right content bound (from _find_content_bounds). + document_category: User-selected category (e.g. 'woerterbuch'). + margin_strip_detected: Whether a decorative A-Z margin strip was found. Returns: List of PageRegion with types, confidence, and method. @@ -2499,6 +2822,22 @@ def classify_column_types(geometries: List[ColumnGeometry], logger.info(f"ClassifyColumns: role scores: " f"{[(g.index, rs) for g, rs in zip(geometries, role_scores)]}") + # --- Level 0: Dictionary detection --- + dict_signals = _score_dictionary_signals( + geometries, + document_category=document_category, + margin_strip_detected=margin_strip_detected, + ) + if dict_signals["is_dictionary"]: + regions = _classify_dictionary_columns( + geometries, dict_signals, lang_scores, content_h, + ) + if regions is not None: + logger.info("ClassifyColumns: Level 0 (dictionary) succeeded, confidence=%.3f", + dict_signals["confidence"]) + _add_header_footer(regions, top_y, bottom_y, img_w, img_h, inv=inv) + return _with_margins(ignore_regions + regions) + # --- Level 1: Content-based classification --- regions = _classify_by_content(geometries, lang_scores, role_scores, content_w, content_h) if regions is not None: diff --git a/klausur-service/backend/cv_vocab_types.py b/klausur-service/backend/cv_vocab_types.py index 12989f9..9f514f7 100644 --- a/klausur-service/backend/cv_vocab_types.py +++ b/klausur-service/backend/cv_vocab_types.py @@ -85,7 +85,7 @@ ENGLISH_FUNCTION_WORDS = {'the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'o @dataclass class PageRegion: """A detected region on the page.""" - type: str # 'column_en', 'column_de', 'column_example', 'page_ref', 'column_marker', 'column_text', 'header', 'footer', 'margin_top', 'margin_bottom' + type: str # 'column_en', 'column_de', 'column_example', 'page_ref', 'column_marker', 'column_text', 'header', 'footer', 'margin_top', 'margin_bottom', 'column_headword', 'column_article', 'column_ipa' x: int y: int width: int diff --git a/klausur-service/backend/grid_editor_api.py b/klausur-service/backend/grid_editor_api.py index bc34694..2c54aec 100644 --- a/klausur-service/backend/grid_editor_api.py +++ b/klausur-service/backend/grid_editor_api.py @@ -1201,7 +1201,7 @@ def _filter_decorative_margin( img_w: int, log: Any, session_id: str, -) -> None: +) -> Dict[str, Any]: """Remove words that belong to a decorative alphabet strip on a margin. Some vocabulary worksheets have a vertical A–Z alphabet graphic along @@ -1220,9 +1220,13 @@ def _filter_decorative_margin( artifacts like "Vv" that belong to the same decorative element. Modifies *words* in place. + + Returns: + Dict with 'found' (bool), 'side' (str), 'letters_detected' (int). """ + no_strip: Dict[str, Any] = {"found": False, "side": "", "letters_detected": 0} if not words or img_w <= 0: - return + return no_strip margin_cutoff = img_w * 0.30 # Phase 1: find candidate strips using single-char words @@ -1278,6 +1282,9 @@ def _filter_decorative_margin( "(strip x=%d-%d)", session_id, removed, side, strip_x_lo, strip_x_hi, ) + return {"found": True, "side": side, "letters_detected": len(strip)} + + return no_strip def _filter_footer_words( @@ -1427,7 +1434,11 @@ async def _build_grid_core(session_id: str, session: dict) -> dict: # Some worksheets have a decorative alphabet strip along one margin # (A-Z in a graphic). OCR reads these as single-char words aligned # vertically. Detect and remove them before grid building. - _filter_decorative_margin(all_words, img_w, logger, session_id) + margin_strip_info = _filter_decorative_margin(all_words, img_w, logger, session_id) + margin_strip_detected = margin_strip_info.get("found", False) + + # Read document_category from session (user-selected or auto-detected) + document_category = session.get("document_category") # 2c. Filter footer rows (page numbers at the very bottom). # Isolated short text in the bottom 5% of the page is typically a @@ -1997,18 +2008,21 @@ async def _build_grid_core(session_id: str, session: dict) -> dict: removed_pipes, z.get("zone_index", 0), ) - # Also strip pipe chars from word_box text and cell text that may remain - # from OCR reading syllable-separation marks (e.g. "zu|trau|en" → "zutrauen"). + # Strip pipe chars ONLY from word_boxes/cells where the pipe is an + # OCR column-divider artifact. Preserve pipes that are embedded in + # words as syllable separators (e.g. "zu|trau|en") — these are + # intentional and used in dictionary Ground Truth. for z in zones_data: for cell in z.get("cells", []): for wb in cell.get("word_boxes", []): wbt = wb.get("text", "") - if "|" in wbt: - wb["text"] = wbt.replace("|", "") + # Only strip if the ENTIRE word_box is just pipe(s) + # (handled by _PIPE_RE above) — leave embedded pipes alone text = cell.get("text", "") if "|" in text: - cleaned = text.replace("|", "").strip() - if cleaned != text: + # Only strip leading/trailing pipes (OCR artifacts at cell edges) + cleaned = text.strip("|").strip() + if cleaned != text.strip(): cell["text"] = cleaned # 4e. Detect and remove page-border decoration strips. @@ -2668,6 +2682,63 @@ async def _build_grid_core(session_id: str, session: dict) -> dict: ) font_size_suggestion = max(10, int(avg_row_height * 0.6)) + # --- Dictionary detection on assembled grid --- + # Build lightweight ColumnGeometry-like structures from zone columns for + # dictionary signal scoring. + from cv_layout import _score_dictionary_signals + dict_detection: Dict[str, Any] = {"is_dictionary": False, "confidence": 0.0} + try: + from cv_vocab_types import ColumnGeometry + for z in zones_data: + zone_cells = z.get("cells", []) + zone_cols = z.get("columns", []) + if len(zone_cols) < 2 or len(zone_cells) < 10: + continue + # Build pseudo-ColumnGeometry per column + pseudo_geoms = [] + for col in zone_cols: + ci = col["index"] + col_cells = [c for c in zone_cells if c.get("col_index") == ci] + # Flatten word_boxes into word dicts compatible with _score_language + col_words = [] + for cell in col_cells: + for wb in cell.get("word_boxes") or []: + col_words.append({ + "text": wb.get("text", ""), + "conf": wb.get("conf", 0), + "top": wb.get("top", 0), + "left": wb.get("left", 0), + "height": wb.get("height", 0), + "width": wb.get("width", 0), + }) + # Fallback: use cell text if no word_boxes + if not cell.get("word_boxes") and cell.get("text"): + col_words.append({ + "text": cell["text"], + "conf": cell.get("confidence", 50), + "top": cell.get("bbox_px", {}).get("y", 0), + "left": cell.get("bbox_px", {}).get("x", 0), + "height": cell.get("bbox_px", {}).get("h", 20), + "width": cell.get("bbox_px", {}).get("w", 50), + }) + col_w = col.get("x_max_px", 0) - col.get("x_min_px", 0) + pseudo_geoms.append(ColumnGeometry( + index=ci, x=col.get("x_min_px", 0), y=0, + width=max(col_w, 1), height=img_h, + word_count=len(col_words), words=col_words, + width_ratio=col_w / max(img_w, 1), + )) + if len(pseudo_geoms) >= 2: + dd = _score_dictionary_signals( + pseudo_geoms, + document_category=document_category, + margin_strip_detected=margin_strip_detected, + ) + if dd["confidence"] > dict_detection["confidence"]: + dict_detection = dd + except Exception as e: + logger.warning("Dictionary detection failed: %s", e) + result = { "session_id": session_id, "image_width": img_w, @@ -2693,6 +2764,13 @@ async def _build_grid_core(session_id: str, session: dict) -> dict: "avg_row_height_px": round(avg_row_height, 1), "font_size_suggestion_px": font_size_suggestion, }, + "dictionary_detection": { + "is_dictionary": dict_detection.get("is_dictionary", False), + "confidence": dict_detection.get("confidence", 0.0), + "signals": dict_detection.get("signals", {}), + "article_col_index": dict_detection.get("article_col_index"), + "headword_col_index": dict_detection.get("headword_col_index"), + }, "duration_seconds": round(duration, 2), } @@ -2722,8 +2800,8 @@ async def build_grid(session_id: str): except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) - # Persist to DB - await update_session_db(session_id, grid_editor_result=result) + # Persist to DB and advance current_step to 11 (reconstruction complete) + await update_session_db(session_id, grid_editor_result=result, current_step=11) logger.info( "build-grid session %s: %d zones, %d cols, %d rows, %d cells, " @@ -2772,7 +2850,7 @@ async def save_grid(session_id: str, request: Request): "edited": True, } - await update_session_db(session_id, grid_editor_result=result) + await update_session_db(session_id, grid_editor_result=result, current_step=11) logger.info("save-grid session %s: %d zones saved", session_id, len(body["zones"])) diff --git a/klausur-service/backend/ocr_pipeline_regression.py b/klausur-service/backend/ocr_pipeline_regression.py index 7636609..1bec71b 100644 --- a/klausur-service/backend/ocr_pipeline_regression.py +++ b/klausur-service/backend/ocr_pipeline_regression.py @@ -256,7 +256,7 @@ async def mark_ground_truth( # Merge into existing ground_truth JSONB gt = session.get("ground_truth") or {} gt["build_grid_reference"] = reference - await update_session_db(session_id, ground_truth=gt) + await update_session_db(session_id, ground_truth=gt, current_step=11) logger.info( "Ground truth marked for session %s: %d cells", diff --git a/klausur-service/backend/ocr_pipeline_sessions.py b/klausur-service/backend/ocr_pipeline_sessions.py index 57e25ae..89d055b 100644 --- a/klausur-service/backend/ocr_pipeline_sessions.py +++ b/klausur-service/backend/ocr_pipeline_sessions.py @@ -178,6 +178,18 @@ async def get_session_info(session_id: str): result["word_result"] = session["word_result"] if session.get("doc_type_result"): result["doc_type_result"] = session["doc_type_result"] + if session.get("structure_result"): + result["structure_result"] = session["structure_result"] + if session.get("grid_editor_result"): + # Include summary only to keep response small + gr = session["grid_editor_result"] + result["grid_editor_result"] = { + "summary": gr.get("summary", {}), + "zones_count": len(gr.get("zones", [])), + "edited": gr.get("edited", False), + } + if session.get("ground_truth"): + result["ground_truth"] = session["ground_truth"] # Sub-session info if session.get("parent_session_id"):