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
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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
admin-lehrer/components/ocr-pipeline/BoxSessionTabs.tsx
Normal file
68
admin-lehrer/components/ocr-pipeline/BoxSessionTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user