Add Box-Grid-Review step (Step 11) to OCR pipeline
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 44s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m52s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
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 44s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m52s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
New pipeline step between Gutter Repair and Ground Truth that processes
embedded boxes (grammar tips, exercises) independently from the main grid.
Backend:
- cv_box_layout.py: classify_box_layout() detects flowing/columnar/
bullet_list/header_only layout types per box
- build_box_zone_grid(): layout-aware grid building (single-column for
flowing text, independent columns for tabular content)
- POST /sessions/{id}/build-box-grids endpoint with SmartSpellChecker
- Layout type overridable per box via request body
Frontend:
- StepBoxGridReview.tsx: shows each box with cropped image + editable
GridTable. Layout type dropdown per box. Auto-builds on first load.
- Auto-skip when no boxes detected on page
- Pipeline steps updated: 13 steps (0-12), Ground Truth moved to 12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { StepStructure } from '@/components/ocr-kombi/StepStructure'
|
||||
import { StepGridBuild } from '@/components/ocr-kombi/StepGridBuild'
|
||||
import { StepGridReview } from '@/components/ocr-kombi/StepGridReview'
|
||||
import { StepGutterRepair } from '@/components/ocr-kombi/StepGutterRepair'
|
||||
import { StepBoxGridReview } from '@/components/ocr-kombi/StepBoxGridReview'
|
||||
import { StepGroundTruth } from '@/components/ocr-kombi/StepGroundTruth'
|
||||
import { useKombiPipeline } from './useKombiPipeline'
|
||||
|
||||
@@ -97,6 +98,8 @@ function OcrKombiContent() {
|
||||
case 10:
|
||||
return <StepGutterRepair sessionId={sessionId} onNext={handleNext} />
|
||||
case 11:
|
||||
return <StepBoxGridReview sessionId={sessionId} onNext={handleNext} />
|
||||
case 12:
|
||||
return (
|
||||
<StepGroundTruth
|
||||
sessionId={sessionId}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const KOMBI_V2_STEPS: PipelineStep[] = [
|
||||
{ id: 'grid-build', name: 'Grid-Aufbau', icon: '🧱', status: 'pending' },
|
||||
{ id: 'grid-review', name: 'Grid-Review', icon: '📊', status: 'pending' },
|
||||
{ id: 'gutter-repair', name: 'Wortkorrektur', icon: '🩹', status: 'pending' },
|
||||
{ id: 'box-review', name: 'Box-Review', icon: '📦', status: 'pending' },
|
||||
{ id: 'ground-truth', name: 'Ground Truth', icon: '✅', status: 'pending' },
|
||||
]
|
||||
|
||||
@@ -56,7 +57,8 @@ export const KOMBI_V2_UI_TO_DB: Record<number, number> = {
|
||||
8: 10, // grid-build
|
||||
9: 11, // grid-review
|
||||
10: 11, // gutter-repair (shares DB step with grid-review)
|
||||
11: 12, // ground-truth
|
||||
11: 11, // box-review (shares DB step with grid-review)
|
||||
12: 12, // ground-truth
|
||||
}
|
||||
|
||||
/** Map from DB step to Kombi V2 UI step index */
|
||||
@@ -70,7 +72,7 @@ export function dbStepToKombiV2Ui(dbStep: number): number {
|
||||
if (dbStep === 9) return 7 // structure
|
||||
if (dbStep === 10) return 8 // grid-build
|
||||
if (dbStep === 11) return 9 // grid-review
|
||||
return 11 // ground-truth
|
||||
return 12 // ground-truth
|
||||
}
|
||||
|
||||
/** Document group: groups multiple sessions from a multi-page upload */
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface GridZone {
|
||||
header_rows: number[]
|
||||
layout_hint?: 'left_of_vsplit' | 'right_of_vsplit' | 'middle_of_vsplit'
|
||||
vsplit_group?: number
|
||||
box_layout_type?: 'flowing' | 'columnar' | 'bullet_list' | 'header_only'
|
||||
box_grid_reviewed?: boolean
|
||||
}
|
||||
|
||||
export interface BBox {
|
||||
|
||||
282
admin-lehrer/components/ocr-kombi/StepBoxGridReview.tsx
Normal file
282
admin-lehrer/components/ocr-kombi/StepBoxGridReview.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
|
||||
import type { GridZone } from '@/components/grid-editor/types'
|
||||
import { GridTable } from '@/components/grid-editor/GridTable'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
type BoxLayoutType = 'flowing' | 'columnar' | 'bullet_list' | 'header_only'
|
||||
|
||||
const LAYOUT_LABELS: Record<BoxLayoutType, string> = {
|
||||
flowing: 'Fließtext',
|
||||
columnar: 'Tabelle/Spalten',
|
||||
bullet_list: 'Aufzählung',
|
||||
header_only: 'Überschrift',
|
||||
}
|
||||
|
||||
interface StepBoxGridReviewProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export function StepBoxGridReview({ sessionId, onNext }: StepBoxGridReviewProps) {
|
||||
const {
|
||||
grid,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
dirty,
|
||||
selectedCell,
|
||||
setSelectedCell,
|
||||
loadGrid,
|
||||
saveGrid,
|
||||
updateCellText,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
getAdjacentCell,
|
||||
commitUndoPoint,
|
||||
selectedCells,
|
||||
toggleCellSelection,
|
||||
clearCellSelection,
|
||||
toggleSelectedBold,
|
||||
setCellColor,
|
||||
} = useGridEditor(sessionId)
|
||||
|
||||
const [building, setBuilding] = useState(false)
|
||||
const [buildError, setBuildError] = useState<string | null>(null)
|
||||
|
||||
// Load grid on mount
|
||||
useEffect(() => {
|
||||
if (sessionId) loadGrid()
|
||||
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Get box zones
|
||||
const boxZones: GridZone[] = (grid?.zones || []).filter(
|
||||
(z: GridZone) => z.zone_type === 'box'
|
||||
)
|
||||
|
||||
// Build box grids via backend
|
||||
const buildBoxGrids = useCallback(async (overrides?: Record<string, string>) => {
|
||||
if (!sessionId) return
|
||||
setBuilding(true)
|
||||
setBuildError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-box-grids`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: overrides || {} }),
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || `HTTP ${res.status}`)
|
||||
}
|
||||
// Reload grid to see updated box zones
|
||||
await loadGrid()
|
||||
} catch (e) {
|
||||
setBuildError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBuilding(false)
|
||||
}
|
||||
}, [sessionId, loadGrid])
|
||||
|
||||
// Handle layout type change for a specific box zone
|
||||
const changeLayoutType = useCallback(async (zoneIndex: number, layoutType: string) => {
|
||||
await buildBoxGrids({ [String(zoneIndex)]: layoutType })
|
||||
}, [buildBoxGrids])
|
||||
|
||||
// Auto-build on first load if box zones have no cells
|
||||
useEffect(() => {
|
||||
if (!grid || loading || building) return
|
||||
const needsBuild = boxZones.some(z => !z.cells || z.cells.length === 0)
|
||||
if (needsBuild && boxZones.length > 0) {
|
||||
buildBoxGrids()
|
||||
}
|
||||
}, [grid, loading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-3 text-gray-500">Lade Grid...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// No boxes detected — skip step
|
||||
if (boxZones.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center">
|
||||
<div className="text-4xl mb-3">📦</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Keine Boxen erkannt
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
Auf dieser Seite wurden keine eingebetteten Boxen (Grammatik-Tipps, Übungen etc.) erkannt.
|
||||
</p>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
📦 Box-Review ({boxZones.length} {boxZones.length === 1 ? 'Box' : 'Boxen'})
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Eingebettete Boxen prüfen und korrigieren. Layout-Typ kann pro Box angepasst werden.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={saveGrid}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => buildBoxGrids()}
|
||||
disabled={building}
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{building ? 'Verarbeite...' : 'Alle Boxen neu aufbauen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (dirty) await saveGrid()
|
||||
onNext()
|
||||
}}
|
||||
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{(error || buildError) && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
|
||||
{error || buildError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{building && (
|
||||
<div className="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="w-5 h-5 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-amber-700 dark:text-amber-300 text-sm">Box-Grids werden aufgebaut...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Box zones */}
|
||||
{boxZones.map((zone) => (
|
||||
<div
|
||||
key={zone.zone_index}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border-2 border-amber-300 dark:border-amber-700 overflow-hidden"
|
||||
>
|
||||
{/* Box header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">📦</span>
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
Box {zone.zone_index + 1}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{zone.bbox_px.w}×{zone.bbox_px.h}px
|
||||
{zone.cells?.length ? ` • ${zone.cells.length} Zellen` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400">Layout:</label>
|
||||
<select
|
||||
value={zone.box_layout_type || 'flowing'}
|
||||
onChange={(e) => changeLayoutType(zone.zone_index, e.target.value)}
|
||||
disabled={building}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{Object.entries(LAYOUT_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box content — image + grid side by side */}
|
||||
<div className="flex gap-0">
|
||||
{/* Box image crop */}
|
||||
{sessionId && (
|
||||
<div className="w-1/3 border-r border-gray-200 dark:border-gray-700 p-2 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="relative overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`}
|
||||
alt={`Box ${zone.zone_index + 1}`}
|
||||
className="w-full h-auto"
|
||||
style={{
|
||||
objectFit: 'none',
|
||||
objectPosition: `-${zone.bbox_pct?.x ?? 0}% -${zone.bbox_pct?.y ?? 0}%`,
|
||||
// Use clip-path to show only the box region
|
||||
}}
|
||||
onError={(e) => {
|
||||
// Fallback: hide image if endpoint doesn't exist
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Box grid table */}
|
||||
<div className="flex-1 p-2 overflow-x-auto">
|
||||
{zone.cells && zone.cells.length > 0 ? (
|
||||
<GridTable
|
||||
zones={[zone]}
|
||||
selectedCell={selectedCell}
|
||||
onCellSelect={setSelectedCell}
|
||||
onCellChange={updateCellText}
|
||||
onGetAdjacentCell={getAdjacentCell}
|
||||
imageWidth={grid?.image_width || 0}
|
||||
imageHeight={grid?.image_height || 0}
|
||||
commitUndoPoint={commitUndoPoint}
|
||||
selectedCells={selectedCells}
|
||||
onToggleCellSelection={toggleCellSelection}
|
||||
onClearCellSelection={clearCellSelection}
|
||||
onToggleSelectedBold={toggleSelectedBold}
|
||||
onSetCellColor={setCellColor}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<p className="text-sm">Keine Zellen erkannt.</p>
|
||||
<button
|
||||
onClick={() => buildBoxGrids({ [String(zone.zone_index)]: 'flowing' })}
|
||||
className="mt-2 text-xs text-amber-600 hover:text-amber-700"
|
||||
>
|
||||
Als Fließtext verarbeiten
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user