Add OCR Kombi Pipeline: modular 11-step architecture with multi-page support
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 29s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m24s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 20s

Phase 1 of the clean architecture refactor: Replaces the 751-line ocr-overlay
monolith with a modular pipeline. Each step gets its own component file.

Frontend: /ai/ocr-kombi route with 11 steps (Upload, Orientation, PageSplit,
Deskew, Dewarp, ContentCrop, OCR, Structure, GridBuild, GridReview, GroundTruth).
Session list supports document grouping for multi-page uploads.

Backend: New ocr_kombi/ module with multi-page PDF upload (splits PDF into N
sessions with shared document_group_id). DB migration adds document_group_id
and page_number columns.

Old /ai/ocr-overlay remains fully functional for A/B testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-26 15:55:28 +01:00
parent d26233b5b3
commit d26a9f60ab
25 changed files with 1935 additions and 7 deletions

View File

@@ -0,0 +1,59 @@
'use client'
import type { PipelineStep } from '@/app/(admin)/ai/ocr-pipeline/types'
interface KombiStepperProps {
steps: PipelineStep[]
currentStep: number
onStepClick: (index: number) => void
}
export function KombiStepper({ steps, currentStep, onStepClick }: KombiStepperProps) {
return (
<div className="flex items-center gap-0.5 px-3 py-2.5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-x-auto">
{steps.map((step, index) => {
const isActive = index === currentStep
const isCompleted = step.status === 'completed'
const isFailed = step.status === 'failed'
const isSkipped = step.status === 'skipped'
const isClickable = (index <= currentStep || isCompleted) && !isSkipped
return (
<div key={step.id} className="flex items-center flex-shrink-0">
{index > 0 && (
<div
className={`h-0.5 w-4 mx-0.5 ${
isSkipped
? 'bg-gray-200 dark:bg-gray-700 border-t border-dashed border-gray-400'
: index <= currentStep ? 'bg-teal-400' : 'bg-gray-300 dark:bg-gray-600'
}`}
/>
)}
<button
onClick={() => isClickable && onStepClick(index)}
disabled={!isClickable}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
isSkipped
? 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through'
: isActive
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300 ring-2 ring-teal-400'
: isCompleted
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: isFailed
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
: 'text-gray-400 dark:text-gray-500'
} ${isClickable ? 'cursor-pointer hover:opacity-80' : 'cursor-default'}`}
title={step.name}
>
<span className="text-sm">
{isSkipped ? '-' : isCompleted ? '\u2713' : isFailed ? '\u2717' : step.icon}
</span>
<span className="hidden lg:inline">{step.name}</span>
<span className="lg:hidden">{index + 1}</span>
</button>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
import { DOCUMENT_CATEGORIES, type DocumentCategory } from '@/app/(admin)/ai/ocr-pipeline/types'
interface SessionHeaderProps {
sessionName: string
activeCategory?: DocumentCategory
isGroundTruth: boolean
onUpdateCategory: (category: DocumentCategory) => void
}
export function SessionHeader({
sessionName,
activeCategory,
isGroundTruth,
onUpdateCategory,
}: SessionHeaderProps) {
const [showCategoryPicker, setShowCategoryPicker] = useState(false)
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
return (
<div className="relative flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span>
Aktive Session:{' '}
<span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span>
</span>
<button
onClick={() => setShowCategoryPicker(!showCategoryPicker)}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
activeCategory
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300 hover:bg-teal-100'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-100 animate-pulse'
}`}
>
{catInfo ? `${catInfo.icon} ${catInfo.label}` : 'Kategorie setzen'}
</button>
{isGroundTruth && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300">
GT
</span>
)}
{showCategoryPicker && (
<div className="absolute left-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64">
{DOCUMENT_CATEGORIES.map(cat => (
<button
key={cat.value}
onClick={() => {
onUpdateCategory(cat.value)
setShowCategoryPicker(false)
}}
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
activeCategory === cat.value
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
>
{cat.icon} {cat.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,361 @@
'use client'
import { useState } from 'react'
import { DOCUMENT_CATEGORIES, type DocumentCategory } from '@/app/(admin)/ai/ocr-pipeline/types'
import type { SessionListItem, DocumentGroupView } from '@/app/(admin)/ai/ocr-kombi/useKombiPipeline'
const KLAUSUR_API = '/klausur-api'
interface SessionListProps {
items: (SessionListItem | DocumentGroupView)[]
loading: boolean
activeSessionId: string | null
onOpenSession: (sid: string) => void
onNewSession: () => void
onDeleteSession: (sid: string) => void
onRenameSession: (sid: string, newName: string) => void
onUpdateCategory: (sid: string, category: DocumentCategory) => void
}
function isGroup(item: SessionListItem | DocumentGroupView): item is DocumentGroupView {
return 'group_id' in item
}
export function SessionList({
items,
loading,
activeSessionId,
onOpenSession,
onNewSession,
onDeleteSession,
onRenameSession,
onUpdateCategory,
}: SessionListProps) {
const [editingName, setEditingName] = useState<string | null>(null)
const [editNameValue, setEditNameValue] = useState('')
const [editingCategory, setEditingCategory] = useState<string | null>(null)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => {
const next = new Set(prev)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
return next
})
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Sessions ({items.length})
</h3>
<button
onClick={onNewSession}
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
>
+ Neue Session
</button>
</div>
{loading ? (
<div className="text-sm text-gray-400 py-2">Lade Sessions...</div>
) : items.length === 0 ? (
<div className="text-sm text-gray-400 py-2">Noch keine Sessions vorhanden.</div>
) : (
<div className="space-y-1.5 max-h-[320px] overflow-y-auto">
{items.map(item =>
isGroup(item) ? (
<GroupRow
key={item.group_id}
group={item}
expanded={expandedGroups.has(item.group_id)}
activeSessionId={activeSessionId}
onToggle={() => toggleGroup(item.group_id)}
onOpenSession={onOpenSession}
onDeleteSession={onDeleteSession}
/>
) : (
<SessionRow
key={item.id}
session={item}
isActive={activeSessionId === item.id}
editingName={editingName}
editNameValue={editNameValue}
editingCategory={editingCategory}
onOpenSession={() => onOpenSession(item.id)}
onStartRename={() => {
setEditNameValue(item.name || item.filename)
setEditingName(item.id)
}}
onFinishRename={(newName) => {
onRenameSession(item.id, newName)
setEditingName(null)
}}
onCancelRename={() => setEditingName(null)}
onEditNameChange={setEditNameValue}
onToggleCategory={() => setEditingCategory(editingCategory === item.id ? null : item.id)}
onUpdateCategory={(cat) => {
onUpdateCategory(item.id, cat)
setEditingCategory(null)
}}
onDelete={() => {
if (confirm('Session loeschen?')) onDeleteSession(item.id)
}}
/>
)
)}
</div>
)}
</div>
)
}
// ---- Group row (multi-page document) ----
function GroupRow({
group,
expanded,
activeSessionId,
onToggle,
onOpenSession,
onDeleteSession,
}: {
group: DocumentGroupView
expanded: boolean
activeSessionId: string | null
onToggle: () => void
onOpenSession: (sid: string) => void
onDeleteSession: (sid: string) => void
}) {
const isActive = group.sessions.some(s => s.id === activeSessionId)
return (
<div>
<div
onClick={onToggle}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${
isActive
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<span className="text-base">{expanded ? '\u25BC' : '\u25B6'}</span>
<div className="flex-1 min-w-0">
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
{group.title}
</div>
<div className="text-xs text-gray-400">
{group.page_count} Seiten
</div>
</div>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400">
Dokument
</span>
</div>
{expanded && (
<div className="ml-6 mt-1 space-y-1 border-l-2 border-gray-200 dark:border-gray-700 pl-3">
{group.sessions.map(s => (
<div
key={s.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs cursor-pointer transition-colors ${
activeSessionId === s.id
? 'bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 text-gray-600 dark:text-gray-400'
}`}
onClick={() => onOpenSession(s.id)}
>
{/* Thumbnail */}
<div className="flex-shrink-0 w-8 h-8 rounded overflow-hidden bg-gray-100 dark:bg-gray-700">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${s.id}/thumbnail?size=64`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
<span className="truncate flex-1">S. {s.page_number || '?'}</span>
<span className="text-[10px] text-gray-400">Step {s.current_step}</span>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm('Seite loeschen?')) onDeleteSession(s.id)
}}
className="p-0.5 text-gray-400 hover:text-red-500"
title="Loeschen"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
)
}
// ---- Single session row ----
function SessionRow({
session,
isActive,
editingName,
editNameValue,
editingCategory,
onOpenSession,
onStartRename,
onFinishRename,
onCancelRename,
onEditNameChange,
onToggleCategory,
onUpdateCategory,
onDelete,
}: {
session: SessionListItem
isActive: boolean
editingName: string | null
editNameValue: string
editingCategory: string | null
onOpenSession: () => void
onStartRename: () => void
onFinishRename: (name: string) => void
onCancelRename: () => void
onEditNameChange: (val: string) => void
onToggleCategory: () => void
onUpdateCategory: (cat: DocumentCategory) => void
onDelete: () => void
}) {
const catInfo = DOCUMENT_CATEGORIES.find(c => c.value === session.document_category)
const isEditing = editingName === session.id
return (
<div
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
isActive
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
{/* Thumbnail */}
<div
className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-gray-100 dark:bg-gray-700"
onClick={onOpenSession}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${session.id}/thumbnail?size=96`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0" onClick={onOpenSession}>
{isEditing ? (
<input
autoFocus
value={editNameValue}
onChange={(e) => onEditNameChange(e.target.value)}
onBlur={() => onFinishRename(editNameValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') onFinishRename(editNameValue)
if (e.key === 'Escape') onCancelRename()
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-1 py-0.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
/>
) : (
<div className="truncate font-medium text-gray-700 dark:text-gray-300">
{session.name || session.filename}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(session.id)
const btn = e.currentTarget
btn.textContent = 'Kopiert!'
setTimeout(() => { btn.textContent = `ID: ${session.id.slice(0, 8)}` }, 1500)
}}
className="text-[10px] font-mono text-gray-400 hover:text-teal-500 transition-colors"
title={`Volle ID: ${session.id} — Klick zum Kopieren`}
>
ID: {session.id.slice(0, 8)}
</button>
<div className="text-xs text-gray-400 mt-0.5">
{new Date(session.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
})}
</div>
</div>
{/* Category badge */}
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={onToggleCategory}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
catInfo
? 'bg-teal-50 dark:bg-teal-900/30 border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300'
: 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-400 hover:text-gray-600'
}`}
title="Kategorie setzen"
>
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
</button>
</div>
{/* Actions */}
<div className="flex flex-col gap-0.5 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); onStartRename() }}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Umbenennen"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1 text-gray-400 hover:text-red-500"
title="Loeschen"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
{/* Category dropdown */}
{editingCategory === session.id && (
<div
className="absolute right-0 top-full mt-1 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-64"
onClick={(e) => e.stopPropagation()}
>
{DOCUMENT_CATEGORIES.map(cat => (
<button
key={cat.value}
onClick={() => onUpdateCategory(cat.value)}
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
session.document_category === cat.value
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}
>
{cat.icon} {cat.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import { StepCrop as BaseStepCrop } from '@/components/ocr-pipeline/StepCrop'
interface StepContentCropProps {
sessionId: string | null
onNext: () => void
}
/** Thin wrapper around the shared StepCrop component */
export function StepContentCrop({ sessionId, onNext }: StepContentCropProps) {
return <BaseStepCrop key={sessionId} sessionId={sessionId} onNext={onNext} />
}

View File

@@ -0,0 +1,13 @@
'use client'
import { StepDeskew as BaseStepDeskew } from '@/components/ocr-pipeline/StepDeskew'
interface StepDeskewProps {
sessionId: string | null
onNext: () => void
}
/** Thin wrapper around the shared StepDeskew component */
export function StepDeskew({ sessionId, onNext }: StepDeskewProps) {
return <BaseStepDeskew key={sessionId} sessionId={sessionId} onNext={onNext} />
}

View File

@@ -0,0 +1,13 @@
'use client'
import { StepDewarp as BaseStepDewarp } from '@/components/ocr-pipeline/StepDewarp'
interface StepDewarpProps {
sessionId: string | null
onNext: () => void
}
/** Thin wrapper around the shared StepDewarp component */
export function StepDewarp({ sessionId, onNext }: StepDewarpProps) {
return <BaseStepDewarp key={sessionId} sessionId={sessionId} onNext={onNext} />
}

View File

@@ -0,0 +1,109 @@
'use client'
import { useState, useEffect } from 'react'
const KLAUSUR_API = '/klausur-api'
interface StepGridBuildProps {
sessionId: string | null
onNext: () => void
}
/**
* Step 9: Grid Build.
* Triggers the build-grid endpoint and shows progress.
*/
export function StepGridBuild({ sessionId, onNext }: StepGridBuildProps) {
const [building, setBuilding] = useState(false)
const [result, setResult] = useState<{ rows: number; cols: number; cells: number } | null>(null)
const [error, setError] = useState('')
const [autoTriggered, setAutoTriggered] = useState(false)
useEffect(() => {
if (!sessionId || autoTriggered) return
// Check if grid already exists
checkExistingGrid()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
const checkExistingGrid = async () => {
if (!sessionId) return
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/grid-editor`)
if (res.ok) {
const data = await res.json()
if (data.grid_shape) {
setResult({ rows: data.grid_shape.rows, cols: data.grid_shape.cols, cells: data.grid_shape.total_cells })
return
}
}
} catch { /* no existing grid */ }
// Auto-trigger build
setAutoTriggered(true)
buildGrid()
}
const buildGrid = async () => {
if (!sessionId) return
setBuilding(true)
setError('')
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-grid`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `Grid-Build fehlgeschlagen (${res.status})`)
}
const data = await res.json()
const shape = data.grid_shape || { rows: 0, cols: 0, total_cells: 0 }
setResult({ rows: shape.rows, cols: shape.cols, cells: shape.total_cells })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBuilding(false)
}
}
return (
<div className="space-y-4">
{building && (
<div className="flex items-center gap-3 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="animate-spin w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full" />
<span className="text-sm text-blue-600 dark:text-blue-400">Grid wird aufgebaut...</span>
</div>
)}
{result && (
<div className="space-y-3">
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-200 dark:border-green-800">
<div className="text-sm font-medium text-green-700 dark:text-green-300">
Grid erstellt: {result.rows} Zeilen, {result.cols} Spalten, {result.cells} Zellen
</div>
</div>
<button
onClick={onNext}
className="px-4 py-2 bg-teal-600 text-white text-sm rounded-lg hover:bg-teal-700"
>
Weiter zum Review
</button>
</div>
)}
{error && (
<div className="space-y-3">
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
</div>
<button
onClick={buildGrid}
className="px-4 py-2 bg-orange-600 text-white text-sm rounded-lg hover:bg-orange-700"
>
Erneut versuchen
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,15 @@
'use client'
import { StepGridReview as BaseStepGridReview } from '@/components/ocr-pipeline/StepGridReview'
import type { MutableRefObject } from 'react'
interface StepGridReviewProps {
sessionId: string | null
onNext: () => void
saveRef: MutableRefObject<(() => Promise<void>) | null>
}
/** Thin wrapper around the shared StepGridReview component */
export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewProps) {
return <BaseStepGridReview sessionId={sessionId} onNext={onNext} saveRef={saveRef} />
}

View File

@@ -0,0 +1,74 @@
'use client'
import { useState } from 'react'
const KLAUSUR_API = '/klausur-api'
interface StepGroundTruthProps {
sessionId: string | null
isGroundTruth: boolean
onMarked: () => void
gridSaveRef: React.MutableRefObject<(() => Promise<void>) | null>
}
/**
* Step 11: Ground Truth marking.
* Saves the current grid as reference data for regression tests.
*/
export function StepGroundTruth({ sessionId, isGroundTruth, onMarked, gridSaveRef }: StepGroundTruthProps) {
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const handleMark = async () => {
if (!sessionId) return
setSaving(true)
setMessage('')
try {
// Auto-save grid editor before marking
if (gridSaveRef.current) {
await gridSaveRef.current()
}
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=kombi`,
{ method: 'POST' },
)
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Ground Truth fehlgeschlagen (${res.status}): ${body}`)
}
const data = await res.json()
setMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`)
onMarked()
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<div className="space-y-4 p-6 bg-amber-50 dark:bg-amber-900/10 rounded-xl border border-amber-200 dark:border-amber-800">
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
Ground Truth
</h3>
<p className="text-sm text-amber-600 dark:text-amber-400">
Markiert die aktuelle Grid-Ausgabe als Referenz fuer Regressionstests.
{isGroundTruth && ' Diese Session ist bereits als Ground Truth markiert.'}
</p>
<button
onClick={handleMark}
disabled={saving}
className="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50"
>
{saving ? 'Speichere...' : isGroundTruth ? 'Ground Truth aktualisieren' : 'Als Ground Truth markieren'}
</button>
{message && (
<div className={`text-sm ${message.includes('fehlgeschlagen') ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}>
{message}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import { PaddleDirectStep } from '@/components/ocr-overlay/PaddleDirectStep'
interface StepOcrProps {
sessionId: string | null
onNext: () => void
}
/**
* Step 7: OCR (Kombi mode = PaddleOCR + Tesseract).
*
* Phase 1: Uses the existing PaddleDirectStep with kombi endpoint.
* Phase 3 (later) will add transparent 3-phase progress + engine comparison.
*/
export function StepOcr({ sessionId, onNext }: StepOcrProps) {
return (
<PaddleDirectStep
sessionId={sessionId}
onNext={onNext}
endpoint="paddle-kombi"
title="Kombi-Modus"
description="PP-OCRv5 und Tesseract laufen parallel. Koordinaten werden gewichtet gemittelt fuer optimale Positionierung."
icon="🔀"
buttonLabel="PP-OCRv5 + Tesseract starten"
runningLabel="PP-OCRv5 + Tesseract laufen..."
engineKey="kombi"
/>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import { StepOrientation as BaseStepOrientation } from '@/components/ocr-pipeline/StepOrientation'
interface StepOrientationProps {
sessionId: string | null
onNext: (sessionId: string) => void
onSessionList: () => void
}
/** Thin wrapper around the shared StepOrientation component */
export function StepOrientation({ sessionId, onNext, onSessionList }: StepOrientationProps) {
return (
<BaseStepOrientation
key={sessionId}
sessionId={sessionId}
onNext={onNext}
onSessionList={onSessionList}
/>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { useState, useEffect } from 'react'
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
const KLAUSUR_API = '/klausur-api'
interface StepPageSplitProps {
sessionId: string | null
onNext: () => void
onSubSessionsCreated: (subs: SubSession[]) => void
}
/**
* Step 3: Page split detection.
* Checks if the image is a double-page spread and offers to split it.
* If no split needed, auto-advances.
*/
export function StepPageSplit({ sessionId, onNext, onSubSessionsCreated }: StepPageSplitProps) {
const [checking, setChecking] = useState(false)
const [splitResult, setSplitResult] = useState<{ is_double_page: boolean; pages?: number } | null>(null)
const [splitting, setSplitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!sessionId) return
// Auto-check for page split
checkPageSplit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
const checkPageSplit = async () => {
if (!sessionId) return
setChecking(true)
setError('')
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
if (!res.ok) throw new Error('Session nicht gefunden')
const data = await res.json()
// If sub-sessions already exist, this was already split
if (data.sub_sessions?.length > 0) {
onSubSessionsCreated(data.sub_sessions)
onNext()
return
}
// Check aspect ratio to guess if double-page
// For now, just auto-advance (page-split detection happens in orientation step)
setSplitResult({ is_double_page: false })
// Auto-advance if single page
onNext()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setChecking(false)
}
}
const handleSplit = async () => {
if (!sessionId) return
setSplitting(true)
setError('')
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/page-split`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || 'Split fehlgeschlagen')
}
const data = await res.json()
if (data.sub_sessions?.length > 0) {
onSubSessionsCreated(data.sub_sessions)
}
onNext()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSplitting(false)
}
}
if (checking) {
return <div className="text-sm text-gray-500 py-8 text-center">Pruefe Seitenformat...</div>
}
if (splitResult?.is_double_page) {
return (
<div className="space-y-4 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-medium text-blue-700 dark:text-blue-300">
Doppelseite erkannt
</h3>
<p className="text-sm text-blue-600 dark:text-blue-400">
Das Bild scheint eine Doppelseite zu sein. Soll es in zwei Einzelseiten aufgeteilt werden?
</p>
<div className="flex gap-2">
<button
onClick={handleSplit}
disabled={splitting}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{splitting ? 'Wird aufgeteilt...' : 'Aufteilen'}
</button>
<button
onClick={onNext}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-sm rounded-lg hover:bg-gray-300"
>
Einzelseite beibehalten
</button>
</div>
{error && <div className="text-sm text-red-500">{error}</div>}
</div>
)
}
return (
<div className="text-sm text-gray-500 py-8 text-center">
Einzelseite erkannt weiter zum naechsten Schritt.
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
</div>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
import { StepStructureDetection } from '@/components/ocr-pipeline/StepStructureDetection'
interface StepStructureProps {
sessionId: string | null
onNext: () => void
}
/** Thin wrapper around the shared StepStructureDetection component */
export function StepStructure({ sessionId, onNext }: StepStructureProps) {
return <StepStructureDetection sessionId={sessionId} onNext={onNext} />
}

View File

@@ -0,0 +1,147 @@
'use client'
import { useState, useCallback } from 'react'
import { DOCUMENT_CATEGORIES, type DocumentCategory } from '@/app/(admin)/ai/ocr-pipeline/types'
const KLAUSUR_API = '/klausur-api'
interface StepUploadProps {
onUploaded: (sessionId: string) => void
}
export function StepUpload({ onUploaded }: StepUploadProps) {
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [title, setTitle] = useState('')
const [category, setCategory] = useState<DocumentCategory>('vokabelseite')
const [error, setError] = useState('')
const handleUpload = useCallback(async (file: File) => {
setUploading(true)
setError('')
try {
const formData = new FormData()
formData.append('file', file)
if (title.trim()) formData.append('name', title.trim())
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || `Upload fehlgeschlagen (${res.status})`)
}
const data = await res.json()
const sid = data.session_id || data.id
// Set category
if (category) {
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_category: category }),
})
}
onUploaded(sid)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setUploading(false)
}
}, [title, category, onUploaded])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleUpload(file)
}, [handleUpload])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
}, [handleUpload])
return (
<div className="space-y-4">
{/* Title input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Titel (optional)
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Vokabeln Unit 3"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
/>
</div>
{/* Category selector */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Kategorie
</label>
<div className="grid grid-cols-4 gap-1.5">
{DOCUMENT_CATEGORIES.map(cat => (
<button
key={cat.value}
onClick={() => setCategory(cat.value)}
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
category === cat.value
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 ring-1 ring-teal-400'
: 'bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100'
}`}
>
{cat.icon} {cat.label}
</button>
))}
</div>
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
dragging
? 'border-teal-400 bg-teal-50 dark:bg-teal-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
>
{uploading ? (
<div className="text-sm text-gray-500">Wird hochgeladen...</div>
) : (
<>
<div className="text-4xl mb-3">📤</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
Bild oder PDF hierher ziehen
</div>
<label className="inline-block px-4 py-2 bg-teal-600 text-white text-sm rounded-lg cursor-pointer hover:bg-teal-700">
Datei auswaehlen
<input
type="file"
accept="image/*,.pdf"
onChange={handleFileSelect}
className="hidden"
/>
</label>
</>
)}
</div>
{error && (
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
</div>
)}
</div>
)
}