feat: add Kombi-Vergleich mode for side-by-side Paddle vs RapidOCR comparison
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 33s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m55s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 21s

Add /rapid-kombi backend endpoint using local RapidOCR + Tesseract merge,
KombiCompareStep component for parallel execution and side-by-side overlay,
and wordResultOverride prop on OverlayReconstruction for direct data injection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-14 07:59:06 +01:00
parent c2c082d4b4
commit a994ddee83
6 changed files with 504 additions and 35 deletions

View File

@@ -0,0 +1,231 @@
'use client'
import { useState } from 'react'
import { OverlayReconstruction } from './OverlayReconstruction'
import type { GridCell } from '@/app/(admin)/ai/ocr-overlay/types'
const KLAUSUR_API = '/klausur-api'
type Phase = 'idle' | 'running' | 'compare'
interface KombiResult {
cells: GridCell[]
image_width: number
image_height: number
duration_seconds: number
summary: {
total_cells: number
non_empty_cells: number
merged_words: number
[key: string]: unknown
}
[key: string]: unknown
}
interface KombiCompareStepProps {
sessionId: string | null
onNext: () => void
}
export function KombiCompareStep({ sessionId, onNext }: KombiCompareStepProps) {
const [phase, setPhase] = useState<Phase>('idle')
const [error, setError] = useState('')
const [paddleResult, setPaddleResult] = useState<KombiResult | null>(null)
const [rapidResult, setRapidResult] = useState<KombiResult | null>(null)
const [paddleStatus, setPaddleStatus] = useState<'pending' | 'running' | 'done' | 'error'>('pending')
const [rapidStatus, setRapidStatus] = useState<'pending' | 'running' | 'done' | 'error'>('pending')
const runBothEngines = async () => {
if (!sessionId) return
setPhase('running')
setError('')
setPaddleStatus('running')
setRapidStatus('running')
setPaddleResult(null)
setRapidResult(null)
const fetchEngine = async (
endpoint: string,
setResult: (r: KombiResult) => void,
setStatus: (s: 'pending' | 'running' | 'done' | 'error') => void,
) => {
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/${endpoint}`,
{ method: 'POST' },
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.detail || `HTTP ${res.status}`)
}
const data = await res.json()
setResult(data)
setStatus('done')
} catch (e: unknown) {
setStatus('error')
throw e
}
}
try {
await Promise.all([
fetchEngine('paddle-kombi', setPaddleResult, setPaddleStatus),
fetchEngine('rapid-kombi', setRapidResult, setRapidStatus),
])
setPhase('compare')
} catch (e: unknown) {
// At least one failed — still show compare if the other succeeded
setError(e instanceof Error ? e.message : String(e))
setPhase('compare')
}
}
if (phase === 'idle') {
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-800 dark:text-gray-200 mb-2">
Kombi-Vergleich
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-lg mx-auto">
Beide Kombi-Modi (Paddle + Tesseract vs. RapidOCR + Tesseract) laufen parallel.
Die Ergebnisse werden nebeneinander angezeigt, damit die Qualitaet direkt verglichen werden kann.
</p>
<button
onClick={runBothEngines}
disabled={!sessionId}
className="px-5 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
Beide Kombi-Modi starten
</button>
</div>
)
}
if (phase === 'running' && !paddleResult && !rapidResult) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8">
<div className="flex items-center justify-center gap-8">
<EngineStatusCard label="Paddle + Tesseract" status={paddleStatus} />
<EngineStatusCard label="RapidOCR + Tesseract" status={rapidStatus} />
</div>
</div>
)
}
// compare phase
return (
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Side-by-Side Vergleich
</h3>
<button
onClick={() => { setPhase('idle'); setPaddleResult(null); setRapidResult(null) }}
className="text-xs px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Neu starten
</button>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Left: Paddle-Kombi */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
🔀 Paddle + Tesseract
</span>
{paddleStatus === 'error' && (
<span className="text-xs text-red-500">Fehler</span>
)}
</div>
{paddleResult ? (
<>
<OverlayReconstruction
sessionId={sessionId}
onNext={() => {}}
wordResultOverride={paddleResult}
/>
<StatsBar result={paddleResult} engine="Paddle-Kombi" />
</>
) : (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-12 text-center text-sm text-gray-400">
{paddleStatus === 'running' ? 'Laeuft...' : 'Fehlgeschlagen'}
</div>
)}
</div>
{/* Right: Rapid-Kombi */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
RapidOCR + Tesseract
</span>
{rapidStatus === 'error' && (
<span className="text-xs text-red-500">Fehler</span>
)}
</div>
{rapidResult ? (
<>
<OverlayReconstruction
sessionId={sessionId}
onNext={() => {}}
wordResultOverride={rapidResult}
/>
<StatsBar result={rapidResult} engine="Rapid-Kombi" />
</>
) : (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-12 text-center text-sm text-gray-400">
{rapidStatus === 'running' ? 'Laeuft...' : 'Fehlgeschlagen'}
</div>
)}
</div>
</div>
<div className="flex justify-end">
<button
onClick={onNext}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium"
>
Fertig
</button>
</div>
</div>
)
}
function EngineStatusCard({ label, status }: { label: string; status: string }) {
return (
<div className="flex items-center gap-3 bg-gray-50 dark:bg-gray-900 rounded-lg px-5 py-4">
{status === 'running' && (
<div className="w-5 h-5 border-2 border-teal-400 border-t-transparent rounded-full animate-spin" />
)}
{status === 'done' && <span className="text-green-500 text-lg"></span>}
{status === 'error' && <span className="text-red-500 text-lg"></span>}
{status === 'pending' && <span className="text-gray-400 text-lg"></span>}
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
</div>
)
}
function StatsBar({ result, engine }: { result: KombiResult; engine: string }) {
const nonEmpty = result.summary?.non_empty_cells ?? 0
const totalCells = result.summary?.total_cells ?? 0
const merged = result.summary?.merged_words ?? 0
const duration = result.duration_seconds ?? 0
return (
<div className="flex items-center gap-3 text-[11px] text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 rounded-lg px-3 py-2">
<span className="font-medium text-gray-600 dark:text-gray-300">{engine}</span>
<span>{merged} Woerter</span>
<span>{nonEmpty}/{totalCells} Zellen</span>
<span>{duration.toFixed(2)}s</span>
</div>
)
}

View File

@@ -10,6 +10,8 @@ const KLAUSUR_API = '/klausur-api'
interface OverlayReconstructionProps {
sessionId: string | null
onNext: () => void
/** When set, use this data directly instead of fetching from the session API. */
wordResultOverride?: { cells: GridCell[]; image_width: number; image_height: number; [key: string]: unknown }
}
interface EditableCell {
@@ -24,7 +26,7 @@ interface EditableCell {
type UndoAction = { cellId: string; oldText: string; newText: string }
export function OverlayReconstruction({ sessionId, onNext }: OverlayReconstructionProps) {
export function OverlayReconstruction({ sessionId, onNext, wordResultOverride }: OverlayReconstructionProps) {
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
const [error, setError] = useState('')
const [cells, setCells] = useState<EditableCell[]>([])
@@ -78,10 +80,39 @@ export function OverlayReconstruction({ sessionId, onNext }: OverlayReconstructi
// Load session data
useEffect(() => {
if (wordResultOverride) {
applyWordResult(wordResultOverride)
return
}
if (!sessionId) return
loadSessionData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
}, [sessionId, wordResultOverride])
const applyWordResult = (wordResult: { cells: GridCell[]; image_width: number; image_height: number; [key: string]: unknown }) => {
const rawGridCells: GridCell[] = wordResult.cells || []
setGridCells(rawGridCells)
const editableCells: EditableCell[] = rawGridCells.map(c => ({
cellId: c.cell_id,
text: c.text,
originalText: c.text,
bboxPct: c.bbox_pct,
colType: c.col_type,
rowIndex: c.row_index,
colIndex: c.col_index,
}))
setCells(editableCells)
setEditedTexts(new Map())
setUndoStack([])
setRedoStack([])
if (wordResult.image_width && wordResult.image_height) {
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
}
setStatus('ready')
}
const loadSessionData = async () => {
if (!sessionId) return
@@ -98,33 +129,11 @@ export function OverlayReconstruction({ sessionId, onNext }: OverlayReconstructi
return
}
const rawGridCells: GridCell[] = wordResult.cells || []
setGridCells(rawGridCells)
const editableCells: EditableCell[] = rawGridCells.map(c => ({
cellId: c.cell_id,
text: c.text,
originalText: c.text,
bboxPct: c.bbox_pct,
colType: c.col_type,
rowIndex: c.row_index,
colIndex: c.col_index,
}))
setCells(editableCells)
setEditedTexts(new Map())
setUndoStack([])
setRedoStack([])
applyWordResult(wordResult as unknown as { cells: GridCell[]; image_width: number; image_height: number })
// Load rows
const rowResult: RowResult | undefined = data.row_result
if (rowResult?.rows) setRows(rowResult.rows)
// Store image dimensions
if (wordResult.image_width && wordResult.image_height) {
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
}
setStatus('ready')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e))
setStatus('error')