Files
breakpilot-lehrer/admin-lehrer/components/ocr-overlay/KombiCompareStep.tsx
Benjamin Admin a994ddee83
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
feat: add Kombi-Vergleich mode for side-by-side Paddle vs RapidOCR comparison
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>
2026-03-14 07:59:06 +01:00

232 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}