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
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:
361
admin-lehrer/components/ocr-kombi/SessionList.tsx
Normal file
361
admin-lehrer/components/ocr-kombi/SessionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user