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>
232 lines
8.1 KiB
TypeScript
232 lines
8.1 KiB
TypeScript
'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>
|
||
)
|
||
}
|