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

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:
Benjamin Admin
2026-04-12 17:26:06 +02:00
parent 52637778b9
commit 5da9a550bf
6 changed files with 661 additions and 2 deletions

View File

@@ -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}

View File

@@ -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 */

View File

@@ -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 {

View 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>
)
}

View File

@@ -0,0 +1,256 @@
"""
Box layout classifier — detects internal layout type of embedded boxes.
Classifies each box as: flowing | columnar | bullet_list | header_only
and provides layout-appropriate grid building.
Used by the Box-Grid-Review step to rebuild box zones with correct structure.
"""
import logging
import re
import statistics
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
# Bullet / list-item patterns at the start of a line
_BULLET_RE = re.compile(
r'^[\-\u2022\u2013\u2014\u25CF\u25CB\u25AA\u25A0•·]\s' # dash, bullet chars
r'|^\d{1,2}[.)]\s' # numbered: "1) " or "1. "
r'|^[a-z][.)]\s' # lettered: "a) " or "a. "
)
def classify_box_layout(
words: List[Dict],
box_w: int,
box_h: int,
) -> str:
"""Classify the internal layout of a detected box.
Args:
words: OCR word dicts within the box (with top, left, width, height, text)
box_w: Box width in pixels
box_h: Box height in pixels
Returns:
'header_only' | 'bullet_list' | 'columnar' | 'flowing'
"""
if not words:
return "header_only"
# Group words into lines by y-proximity
lines = _group_into_lines(words)
# Header only: very few words or single line
total_words = sum(len(line) for line in lines)
if total_words <= 5 or len(lines) <= 1:
return "header_only"
# Bullet list: check if majority of lines start with bullet patterns
bullet_count = 0
for line in lines:
first_text = line[0].get("text", "") if line else ""
if _BULLET_RE.match(first_text):
bullet_count += 1
# Also check if first word IS a bullet char
elif first_text.strip() in ("-", "", "", "", "·", "", ""):
bullet_count += 1
if bullet_count >= len(lines) * 0.4 and bullet_count >= 2:
return "bullet_list"
# Columnar: check for multiple distinct x-clusters
if len(lines) >= 3 and _has_column_structure(words, box_w):
return "columnar"
# Default: flowing text
return "flowing"
def _group_into_lines(words: List[Dict]) -> List[List[Dict]]:
"""Group words into lines by y-proximity."""
if not words:
return []
sorted_words = sorted(words, key=lambda w: (w["top"], w["left"]))
heights = [w["height"] for w in sorted_words if w.get("height", 0) > 0]
median_h = statistics.median(heights) if heights else 20
y_tolerance = max(median_h * 0.5, 5)
lines: List[List[Dict]] = []
current_line: List[Dict] = [sorted_words[0]]
current_y = sorted_words[0]["top"]
for w in sorted_words[1:]:
if abs(w["top"] - current_y) <= y_tolerance:
current_line.append(w)
else:
lines.append(sorted(current_line, key=lambda ww: ww["left"]))
current_line = [w]
current_y = w["top"]
if current_line:
lines.append(sorted(current_line, key=lambda ww: ww["left"]))
return lines
def _has_column_structure(words: List[Dict], box_w: int) -> bool:
"""Check if words have multiple distinct left-edge clusters (columns)."""
if box_w <= 0:
return False
lines = _group_into_lines(words)
if len(lines) < 3:
return False
# Collect left-edges of non-first words in each line
# (first word of each line often aligns regardless of columns)
left_edges = []
for line in lines:
for w in line[1:]: # skip first word
left_edges.append(w["left"])
if len(left_edges) < 4:
return False
# Check if left edges cluster into 2+ distinct groups
left_edges.sort()
gaps = [left_edges[i + 1] - left_edges[i] for i in range(len(left_edges) - 1)]
if not gaps:
return False
median_gap = statistics.median(gaps)
# A column gap is typically > 15% of box width
column_gap_threshold = box_w * 0.15
large_gaps = [g for g in gaps if g > column_gap_threshold]
return len(large_gaps) >= 1
def build_box_zone_grid(
zone_words: List[Dict],
box_x: int,
box_y: int,
box_w: int,
box_h: int,
zone_index: int,
img_w: int,
img_h: int,
layout_type: Optional[str] = None,
) -> Dict[str, Any]:
"""Build a grid for a box zone with layout-aware processing.
If layout_type is None, auto-detects it.
For 'flowing' and 'bullet_list', forces single-column layout.
For 'columnar', uses the standard multi-column detection.
For 'header_only', creates a single cell.
Returns the same format as _build_zone_grid (columns, rows, cells, header_rows).
"""
from grid_editor_helpers import _build_zone_grid, _cluster_rows
if not zone_words:
return {
"columns": [],
"rows": [],
"cells": [],
"header_rows": [],
"box_layout_type": layout_type or "header_only",
"box_grid_reviewed": False,
}
# Auto-detect layout if not specified
if not layout_type:
layout_type = classify_box_layout(zone_words, box_w, box_h)
logger.info(
"Box zone %d: layout_type=%s, %d words, %dx%d",
zone_index, layout_type, len(zone_words), box_w, box_h,
)
if layout_type == "header_only":
# Single cell with all text concatenated
all_text = " ".join(
w.get("text", "") for w in sorted(zone_words, key=lambda ww: (ww["top"], ww["left"]))
).strip()
return {
"columns": [{"col_index": 0, "index": 0, "label": "column_text", "col_type": "column_1"}],
"rows": [{"index": 0, "row_index": 0, "y_min": box_y, "y_max": box_y + box_h, "y_center": box_y + box_h / 2}],
"cells": [{
"cell_id": f"Z{zone_index}_R0C0",
"row_index": 0,
"col_index": 0,
"col_type": "column_1",
"text": all_text,
"word_boxes": zone_words,
}],
"header_rows": [0],
"box_layout_type": layout_type,
"box_grid_reviewed": False,
}
if layout_type in ("flowing", "bullet_list"):
# Force single column — each line becomes one row with one cell
lines = _group_into_lines(zone_words)
column = {"col_index": 0, "index": 0, "label": "column_text", "col_type": "column_1"}
rows = []
cells = []
for row_idx, line_words in enumerate(lines):
if not line_words:
continue
y_min = min(w["top"] for w in line_words)
y_max = max(w["top"] + w["height"] for w in line_words)
y_center = (y_min + y_max) / 2
row = {
"index": row_idx,
"row_index": row_idx,
"y_min": y_min,
"y_max": y_max,
"y_center": y_center,
}
rows.append(row)
line_text = " ".join(w.get("text", "") for w in line_words).strip()
cell = {
"cell_id": f"Z{zone_index}_R{row_idx}C0",
"row_index": row_idx,
"col_index": 0,
"col_type": "column_1",
"text": line_text,
"word_boxes": line_words,
}
cells.append(cell)
# Detect header: first row if it's notably different (bold, larger, or short)
header_rows = []
if len(lines) >= 2:
first_line = lines[0]
first_text = " ".join(w.get("text", "") for w in first_line).strip()
# Header heuristic: short text, or all-caps, or ends with ':'
if (len(first_text) < 40
or first_text.isupper()
or first_text.rstrip().endswith(':')):
header_rows = [0]
return {
"columns": [column],
"rows": rows,
"cells": cells,
"header_rows": header_rows,
"box_layout_type": layout_type,
"box_grid_reviewed": False,
}
# Columnar: use standard grid builder with independent column detection
result = _build_zone_grid(
zone_words, box_x, box_y, box_w, box_h,
zone_index, img_w, img_h,
global_columns=None, # detect columns independently
)
result["box_layout_type"] = layout_type
result["box_grid_reviewed"] = False
return result

View File

@@ -2181,3 +2181,117 @@ async def gutter_repair_apply(session_id: str, request: Request):
)
return result
# ---------------------------------------------------------------------------
# Box-Grid-Review endpoints
# ---------------------------------------------------------------------------
@router.post("/sessions/{session_id}/build-box-grids")
async def build_box_grids(session_id: str, request: Request):
"""Rebuild grid structure for all box zones with layout-aware detection.
For each zone with zone_type='box':
1. Auto-detect layout type (flowing / columnar / bullet_list / header_only)
2. Build grid with layout-appropriate parameters
3. Apply SmartSpellChecker corrections
4. Store results back in grid_editor_result.zones[]
Optional body: { "overrides": { "2": "bullet_list" } }
Maps zone_index → forced layout_type.
"""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
grid_data = session.get("grid_editor_result")
if not grid_data:
raise HTTPException(status_code=400, detail="No grid data. Run build-grid first.")
word_result = session.get("word_result") or {}
all_words = word_result.get("cells") or word_result.get("words") or []
body = {}
try:
body = await request.json()
except Exception:
pass
layout_overrides = body.get("overrides", {})
from cv_box_layout import classify_box_layout, build_box_zone_grid, _group_into_lines
from grid_editor_helpers import _words_in_zone
img_w = grid_data.get("image_width", 0)
img_h = grid_data.get("image_height", 0)
zones = grid_data.get("zones", [])
box_count = 0
spell_fixes = 0
for z in zones:
if z.get("zone_type") != "box":
continue
bbox = z.get("bbox_px", {})
bx, by = bbox.get("x", 0), bbox.get("y", 0)
bw, bh = bbox.get("w", 0), bbox.get("h", 0)
if bw <= 0 or bh <= 0:
continue
zone_idx = z.get("zone_index", 0)
# Filter words inside this box
zone_words = _words_in_zone(all_words, by, bh, bx, bw)
if not zone_words:
logger.info("Box zone %d: no words found in bbox", zone_idx)
continue
# Get layout override or auto-detect
forced_layout = layout_overrides.get(str(zone_idx))
# Build box grid
box_grid = build_box_zone_grid(
zone_words, bx, by, bw, bh,
zone_idx, img_w, img_h,
layout_type=forced_layout,
)
# Apply SmartSpellChecker to all box cells
try:
from smart_spell import SmartSpellChecker
ssc = SmartSpellChecker()
for cell in box_grid.get("cells", []):
text = cell.get("text", "")
if not text:
continue
result = ssc.correct_text(text, lang="auto")
if result.changed:
cell["text"] = result.corrected
spell_fixes += 1
except ImportError:
pass
# Update zone data with new grid
z["columns"] = box_grid["columns"]
z["rows"] = box_grid["rows"]
z["cells"] = box_grid["cells"]
z["header_rows"] = box_grid.get("header_rows", [])
z["box_layout_type"] = box_grid.get("box_layout_type", "flowing")
z["box_grid_reviewed"] = False
box_count += 1
# Save updated grid back
await update_session_db(session_id, grid_editor_result=grid_data)
logger.info(
"build-box-grids session %s: %d box zones rebuilt, %d spell fixes",
session_id, box_count, spell_fixes,
)
return {
"session_id": session_id,
"box_zones_rebuilt": box_count,
"spell_fixes": spell_fixes,
"zones": zones,
}