feat: Implement page-split step with auto-detection and sub-session naming

StepPageSplit now:
- Auto-calls POST /page-split on step entry
- Shows oriented image + detection result
- If double page: creates sub-sessions named "Title — S. 1/2"
- If single page: green badge "keine Trennung noetig"
- Manual "Weiter" button (no auto-advance)

Also:
- StepOrientation wrapper simplified (no page-split in orientation)
- StepUpload passes name back via onUploaded(sid, name)
- page.tsx: after page-split "Weiter" switches to first sub-session
- useKombiPipeline exposes setSessionName

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-26 17:56:45 +01:00
parent 469f09d1e1
commit 9f68bd3425
5 changed files with 179 additions and 90 deletions

View File

@@ -40,9 +40,9 @@ function OcrKombiContent() {
deleteSession,
renameSession,
updateCategory,
handleOrientationComplete,
handleSessionChange,
setSessionId,
setSessionName,
setSubSessions,
setParentSessionId,
setIsGroundTruth,
@@ -54,8 +54,9 @@ function OcrKombiContent() {
return (
<StepUpload
sessionId={sessionId}
onUploaded={(sid) => {
onUploaded={(sid, name) => {
setSessionId(sid)
setSessionName(name)
loadSessions()
}}
onNext={handleNext}
@@ -65,7 +66,7 @@ function OcrKombiContent() {
return (
<StepOrientation
sessionId={sessionId}
onNext={handleOrientationComplete}
onNext={() => handleNext()}
onSessionList={() => { loadSessions(); handleNewSession() }}
/>
)
@@ -73,10 +74,19 @@ function OcrKombiContent() {
return (
<StepPageSplit
sessionId={sessionId}
onNext={handleNext}
sessionName={sessionName}
onNext={() => {
// If sub-sessions were created, switch to the first one
if (subSessions.length > 0) {
setSessionId(subSessions[0].id)
setSessionName(subSessions[0].name)
}
handleNext()
}}
onSubSessionsCreated={(subs) => {
setSubSessions(subs)
if (sessionId) setParentSessionId(sessionId)
loadSessions()
}}
/>
)

View File

@@ -356,6 +356,7 @@ export function useKombiPipeline() {
setSessionId,
setSubSessions,
setParentSessionId,
setSessionName,
setIsGroundTruth,
}
}

View File

@@ -4,17 +4,17 @@ import { StepOrientation as BaseStepOrientation } from '@/components/ocr-pipelin
interface StepOrientationProps {
sessionId: string | null
onNext: (sessionId: string) => void
onNext: () => void
onSessionList: () => void
}
/** Thin wrapper around the shared StepOrientation component */
/** Thin wrapper — adapts the shared StepOrientation to the Kombi pipeline's simpler onNext() */
export function StepOrientation({ sessionId, onNext, onSessionList }: StepOrientationProps) {
return (
<BaseStepOrientation
key={sessionId}
sessionId={sessionId}
onNext={onNext}
onNext={() => onNext()}
onSessionList={onSessionList}
/>
)

View File

@@ -1,123 +1,201 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
const KLAUSUR_API = '/klausur-api'
interface PageSplitResult {
multi_page: boolean
page_count?: number
page_splits?: { x: number; y: number; width: number; height: number; page_index: number }[]
sub_sessions?: { id: string; name: string; page_index: number }[]
used_original?: boolean
duration_seconds?: number
}
interface StepPageSplitProps {
sessionId: string | null
sessionName: string
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)
export function StepPageSplit({ sessionId, sessionName, onNext, onSubSessionsCreated }: StepPageSplitProps) {
const [detecting, setDetecting] = useState(false)
const [splitResult, setSplitResult] = useState<PageSplitResult | null>(null)
const [error, setError] = useState('')
const didDetect = useRef(false)
// Auto-detect page split when step opens
useEffect(() => {
if (!sessionId) return
// Auto-check for page split
checkPageSplit()
if (!sessionId || didDetect.current) return
didDetect.current = true
detectPageSplit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
const checkPageSplit = async () => {
const detectPageSplit = async () => {
if (!sessionId) return
setChecking(true)
setDetecting(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()
// First check if sub-sessions already exist
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
if (sessionRes.ok) {
const sessionData = await sessionRes.json()
if (sessionData.sub_sessions?.length > 0) {
// Already split — show existing sub-sessions
const subs = sessionData.sub_sessions as { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }[]
setSplitResult({
multi_page: true,
page_count: subs.length,
sub_sessions: subs.map((s: { id: string; name: string; page_index?: number; box_index?: number }) => ({
id: s.id,
name: s.name,
page_index: s.page_index ?? s.box_index ?? 0,
})),
})
onSubSessionsCreated(subs.map((s: { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }) => ({
id: s.id,
name: s.name,
box_index: s.page_index ?? s.box_index ?? 0,
current_step: s.current_step ?? 2,
})))
setDetecting(false)
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 {
// Run page-split detection
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')
throw new Error(data.detail || 'Seitentrennung fehlgeschlagen')
}
const data = await res.json()
if (data.sub_sessions?.length > 0) {
onSubSessionsCreated(data.sub_sessions)
const data: PageSplitResult = await res.json()
setSplitResult(data)
if (data.multi_page && data.sub_sessions?.length) {
// Rename sub-sessions to "Title — S. 1", "Title — S. 2"
const baseName = sessionName || 'Dokument'
for (let i = 0; i < data.sub_sessions.length; i++) {
const sub = data.sub_sessions[i]
const newName = `${baseName} — S. ${i + 1}`
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sub.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
}).catch(() => {})
sub.name = newName
}
onSubSessionsCreated(data.sub_sessions.map(s => ({
id: s.id,
name: s.name,
box_index: s.page_index,
current_step: 2,
})))
}
onNext()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSplitting(false)
setDetecting(false)
}
}
if (checking) {
return <div className="text-sm text-gray-500 py-8 text-center">Pruefe Seitenformat...</div>
}
if (!sessionId) return null
const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/oriented`
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?
<div className="space-y-4">
{/* Image */}
<div className="relative rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="Orientiertes Bild"
className="w-full object-contain max-h-[500px]"
onError={(e) => {
// Fallback to non-oriented image
(e.target as HTMLImageElement).src =
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image`
}}
/>
</div>
{/* Detection status */}
{detecting && (
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400 text-sm">
<div className="animate-spin w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full" />
Doppelseiten-Erkennung laeuft...
</div>
)}
{/* Detection result */}
{splitResult && !detecting && (
splitResult.multi_page ? (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700 p-4 space-y-2">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
Doppelseite erkannt {splitResult.page_count} Seiten getrennt
</div>
<p className="text-xs text-blue-600 dark:text-blue-400">
Jede Seite wird als eigene Session weiterverarbeitet (eigene Begradigung, Entzerrung, etc.).
{splitResult.used_original && ' Trennung auf Originalbild, da Orientierung die Doppelseite gedreht hat.'}
</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"
<div className="flex gap-2 mt-2">
{splitResult.sub_sessions?.map(s => (
<span
key={s.id}
className="text-xs px-2.5 py-1 rounded-md bg-blue-100 dark:bg-blue-800/40 text-blue-700 dark:text-blue-300 font-medium"
>
{splitting ? 'Wird aufgeteilt...' : 'Aufteilen'}
{s.name}
</span>
))}
</div>
{splitResult.duration_seconds != null && (
<div className="text-xs text-gray-400">{splitResult.duration_seconds.toFixed(1)}s</div>
)}
</div>
) : (
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-green-700 dark:text-green-300">
<span>&#10003;</span> Einzelseite keine Trennung noetig
</div>
{splitResult.duration_seconds != null && (
<div className="text-xs text-gray-400 mt-1">{splitResult.duration_seconds.toFixed(1)}s</div>
)}
</div>
)
)}
{/* Error */}
{error && (
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
{error}
<button
onClick={() => { didDetect.current = false; detectPageSplit() }}
className="ml-2 text-teal-600 hover:underline"
>
Erneut versuchen
</button>
</div>
)}
{/* Next button — only show when detection is done */}
{(splitResult || error) && !detecting && (
<div className="flex justify-end">
<button
onClick={onNext}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-sm rounded-lg hover:bg-gray-300"
className="px-6 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 transition-colors"
>
Einzelseite beibehalten
Weiter &rarr;
</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

@@ -7,7 +7,7 @@ const KLAUSUR_API = '/klausur-api'
interface StepUploadProps {
sessionId: string | null
onUploaded: (sessionId: string) => void
onUploaded: (sessionId: string, name: string) => void
onNext: () => void
}
@@ -71,7 +71,7 @@ export function StepUpload({ sessionId, onUploaded, onNext }: StepUploadProps) {
})
}
onUploaded(sid)
onUploaded(sid, title.trim() || selectedFile.name)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {