feat: Frontend Sub-Sessions (Boxen) in OCR-Pipeline UI
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 29s
CI / test-python-klausur (push) Failing after 1m57s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 18s

- BoxSessionTabs: Tab-Leiste zum Wechsel zwischen Haupt- und Box-Sessions
- StepColumnDetection: Box-Info + "Box-Sessions erstellen" Button
- page.tsx: Session-Wechsel, Sub-Session-State, auto-return nach Abschluss
- types.ts: SubSession, PageZone, erweiterte SessionInfo/ColumnResult

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-09 20:33:59 +01:00
parent 256efef3ea
commit 2592ef233b
4 changed files with 219 additions and 6 deletions

View File

@@ -13,7 +13,8 @@ import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecogniti
import { StepLlmReview } from '@/components/ocr-pipeline/StepLlmReview'
import { StepReconstruction } from '@/components/ocr-pipeline/StepReconstruction'
import { StepGroundTruth } from '@/components/ocr-pipeline/StepGroundTruth'
import { PIPELINE_STEPS, DOCUMENT_CATEGORIES, type PipelineStep, type SessionListItem, type DocumentTypeResult, type DocumentCategory } from './types'
import { BoxSessionTabs } from '@/components/ocr-pipeline/BoxSessionTabs'
import { PIPELINE_STEPS, DOCUMENT_CATEGORIES, type PipelineStep, type SessionListItem, type DocumentTypeResult, type DocumentCategory, type SubSession } from './types'
const KLAUSUR_API = '/klausur-api'
@@ -28,6 +29,8 @@ export default function OcrPipelinePage() {
const [editingCategory, setEditingCategory] = useState<string | null>(null)
const [docTypeResult, setDocTypeResult] = useState<DocumentTypeResult | null>(null)
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
const [subSessions, setSubSessions] = useState<SubSession[]>([])
const [parentSessionId, setParentSessionId] = useState<string | null>(null)
const [steps, setSteps] = useState<PipelineStep[]>(
PIPELINE_STEPS.map((s, i) => ({
...s,
@@ -55,7 +58,7 @@ export default function OcrPipelinePage() {
}
}
const openSession = useCallback(async (sid: string) => {
const openSession = useCallback(async (sid: string, keepSubSessions?: boolean) => {
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
if (!res.ok) return
@@ -65,6 +68,18 @@ export default function OcrPipelinePage() {
setSessionName(data.name || data.filename || '')
setActiveCategory(data.document_category || undefined)
// Sub-session handling
if (data.sub_sessions && data.sub_sessions.length > 0) {
setSubSessions(data.sub_sessions)
setParentSessionId(sid)
} else if (data.parent_session_id) {
// This is a sub-session — keep parent info but don't reset sub-session list
setParentSessionId(data.parent_session_id)
} else if (!keepSubSessions) {
setSubSessions([])
setParentSessionId(null)
}
// Restore doc type result if available
const savedDocType: DocumentTypeResult | null = data.doc_type_result || null
setDocTypeResult(savedDocType)
@@ -98,6 +113,8 @@ export default function OcrPipelinePage() {
setSessionId(null)
setCurrentStep(0)
setDocTypeResult(null)
setSubSessions([])
setParentSessionId(null)
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
}
} catch (e) {
@@ -144,6 +161,8 @@ export default function OcrPipelinePage() {
setCurrentStep(0)
setDocTypeResult(null)
setActiveCategory(undefined)
setSubSessions([])
setParentSessionId(null)
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
} catch (e) {
console.error('Failed to delete all sessions:', e)
@@ -168,10 +187,22 @@ export default function OcrPipelinePage() {
const handleNext = () => {
if (currentStep >= steps.length - 1) {
// Last step completed — return to session list
// Last step completed
if (parentSessionId && sessionId !== parentSessionId) {
// Sub-session completed — update its status and stay in tab view
setSubSessions((prev) =>
prev.map((s) => s.id === sessionId ? { ...s, status: 'completed', current_step: 10 } : s)
)
// Switch back to parent
handleSessionChange(parentSessionId)
return
}
// Main session: return to session list
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
setCurrentStep(0)
setSessionId(null)
setSubSessions([])
setParentSessionId(null)
loadSessions()
return
}
@@ -268,9 +299,20 @@ export default function OcrPipelinePage() {
setSessionName('')
setCurrentStep(0)
setDocTypeResult(null)
setSubSessions([])
setParentSessionId(null)
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
}
const handleSessionChange = useCallback((newSessionId: string) => {
openSession(newSessionId, true)
}, [openSession])
const handleBoxSessionsCreated = useCallback((subs: SubSession[]) => {
setSubSessions(subs)
if (sessionId) setParentSessionId(sessionId)
}, [sessionId])
const stepNames: Record<number, string> = {
1: 'Orientierung',
2: 'Begradigung',
@@ -318,7 +360,7 @@ export default function OcrPipelinePage() {
case 3:
return <StepCrop sessionId={sessionId} onNext={handleCropNext} />
case 4:
return <StepColumnDetection sessionId={sessionId} onNext={handleNext} />
return <StepColumnDetection sessionId={sessionId} onNext={handleNext} onBoxSessionsCreated={handleBoxSessionsCreated} />
case 5:
return <StepRowDetection sessionId={sessionId} onNext={handleNext} />
case 6:
@@ -552,6 +594,15 @@ export default function OcrPipelinePage() {
onDocTypeChange={handleDocTypeChange}
/>
{subSessions.length > 0 && parentSessionId && sessionId && (
<BoxSessionTabs
parentSessionId={parentSessionId}
subSessions={subSessions}
activeSessionId={sessionId}
onSessionChange={handleSessionChange}
/>
)}
<div className="min-h-[400px]">{renderStep()}</div>
</div>
)

View File

@@ -34,6 +34,16 @@ export interface SessionListItem {
doc_type?: string
created_at: string
updated_at?: string
parent_session_id?: string | null
box_index?: number | null
}
export interface SubSession {
id: string
name: string
box_index: number
current_step?: number
status?: string
}
export interface PipelineLogEntry {
@@ -95,6 +105,9 @@ export interface SessionInfo {
row_result?: RowResult
word_result?: GridResult
doc_type_result?: DocumentTypeResult
sub_sessions?: SubSession[]
parent_session_id?: string
box_index?: number
}
export interface DeskewResult {
@@ -151,9 +164,17 @@ export interface PageRegion {
classification_method?: string
}
export interface PageZone {
zone_type: 'content' | 'box'
y_start: number
y_end: number
box?: { x: number; y: number; width: number; height: number }
}
export interface ColumnResult {
columns: PageRegion[]
duration_seconds: number
zones?: PageZone[]
}
export interface ColumnGroundTruth {

View File

@@ -0,0 +1,68 @@
'use client'
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
interface BoxSessionTabsProps {
parentSessionId: string
subSessions: SubSession[]
activeSessionId: string
onSessionChange: (sessionId: string) => void
}
const STATUS_ICONS: Record<string, string> = {
pending: '\u23F3', // hourglass
processing: '\uD83D\uDD04', // arrows
completed: '\u2713', // checkmark
}
function getStatusIcon(sub: SubSession): string {
if (sub.status === 'completed' || (sub.current_step && sub.current_step >= 9)) return STATUS_ICONS.completed
if (sub.current_step && sub.current_step > 1) return STATUS_ICONS.processing
return STATUS_ICONS.pending
}
export function BoxSessionTabs({ parentSessionId, subSessions, activeSessionId, onSessionChange }: BoxSessionTabsProps) {
if (subSessions.length === 0) return null
const isParentActive = activeSessionId === parentSessionId
return (
<div className="flex items-center gap-1.5 px-1 py-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
{/* Main session tab */}
<button
onClick={() => onSessionChange(parentSessionId)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
isParentActive
? 'bg-white dark:bg-gray-700 text-teal-700 dark:text-teal-400 shadow-sm ring-1 ring-teal-300 dark:ring-teal-600'
: 'text-gray-500 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50'
}`}
>
Hauptseite
</button>
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
{/* Sub-session tabs */}
{subSessions.map((sub) => {
const isActive = activeSessionId === sub.id
const icon = getStatusIcon(sub)
return (
<button
key={sub.id}
onClick={() => onSessionChange(sub.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
isActive
? 'bg-white dark:bg-gray-700 text-teal-700 dark:text-teal-400 shadow-sm ring-1 ring-teal-300 dark:ring-teal-600'
: 'text-gray-500 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50'
}`}
title={sub.name}
>
<span className="mr-1">{icon}</span>
Box {sub.box_index + 1}
</button>
)
})}
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
import type { ColumnResult, ColumnGroundTruth, PageRegion, SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
import { ColumnControls } from './ColumnControls'
import { ManualColumnEditor } from './ManualColumnEditor'
import type { ColumnTypeKey } from '@/app/(admin)/ai/ocr-pipeline/types'
@@ -13,6 +13,7 @@ type ViewMode = 'normal' | 'ground-truth' | 'manual'
interface StepColumnDetectionProps {
sessionId: string | null
onNext: () => void
onBoxSessionsCreated?: (subSessions: SubSession[]) => void
}
/** Convert PageRegion[] to divider percentages + column types for ManualColumnEditor */
@@ -34,7 +35,7 @@ function columnsToEditorState(
return { dividers, columnTypes }
}
export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionProps) {
export function StepColumnDetection({ sessionId, onNext, onBoxSessionsCreated }: StepColumnDetectionProps) {
const [columnResult, setColumnResult] = useState<ColumnResult | null>(null)
const [detecting, setDetecting] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -42,6 +43,8 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
const [applying, setApplying] = useState(false)
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null)
const [savedGtColumns, setSavedGtColumns] = useState<PageRegion[] | null>(null)
const [creatingBoxSessions, setCreatingBoxSessions] = useState(false)
const [existingSubSessions, setExistingSubSessions] = useState<SubSession[] | null>(null)
// Fetch session info (image dimensions) + check for cached column result
useEffect(() => {
@@ -55,6 +58,10 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
if (info.image_width && info.image_height) {
setImageDimensions({ width: info.image_width, height: info.image_height })
}
if (info.sub_sessions && info.sub_sessions.length > 0) {
setExistingSubSessions(info.sub_sessions)
onBoxSessionsCreated?.(info.sub_sessions)
}
if (info.column_result) {
setColumnResult(info.column_result)
return
@@ -178,6 +185,39 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
}
}, [sessionId])
// Count box zones from column result
const boxZones = columnResult?.zones?.filter(z => z.zone_type === 'box') || []
const boxCount = boxZones.length
const createBoxSessions = useCallback(async () => {
if (!sessionId) return
setCreatingBoxSessions(true)
setError(null)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/create-box-sessions`, {
method: 'POST',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Box-Sessions konnten nicht erstellt werden')
}
const data = await res.json()
const subs: SubSession[] = data.sub_sessions.map((s: { id: string; name?: string; box_index: number }) => ({
id: s.id,
name: s.name || `Box ${s.box_index + 1}`,
box_index: s.box_index,
current_step: 1,
status: 'pending',
}))
setExistingSubSessions(subs)
onBoxSessionsCreated?.(subs)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen der Box-Sessions')
} finally {
setCreatingBoxSessions(false)
}
}, [sessionId, onBoxSessionsCreated])
if (!sessionId) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
@@ -317,6 +357,39 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
</div>
)}
{/* Box zone info */}
{viewMode === 'normal' && boxCount > 0 && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">📦</span>
<div>
<div className="text-sm font-medium text-amber-800 dark:text-amber-300">
{boxCount} Box{boxCount > 1 ? 'en' : ''} erkannt
</div>
<div className="text-xs text-amber-600 dark:text-amber-400">
Box-Bereiche werden separat verarbeitet
</div>
</div>
</div>
{existingSubSessions && existingSubSessions.length > 0 ? (
<div className="text-xs text-amber-700 dark:text-amber-300 font-medium">
{existingSubSessions.length} Box-Session{existingSubSessions.length > 1 ? 's' : ''} vorhanden
</div>
) : (
<button
onClick={createBoxSessions}
disabled={creatingBoxSessions}
className="px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium disabled:opacity-50 flex items-center gap-2"
>
{creatingBoxSessions && (
<div className="animate-spin w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full" />
)}
Box-Sessions erstellen
</button>
)}
</div>
)}
{/* Controls */}
{viewMode === 'normal' && (
<ColumnControls