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:
@@ -40,9 +40,9 @@ function OcrKombiContent() {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
updateCategory,
|
updateCategory,
|
||||||
handleOrientationComplete,
|
|
||||||
handleSessionChange,
|
handleSessionChange,
|
||||||
setSessionId,
|
setSessionId,
|
||||||
|
setSessionName,
|
||||||
setSubSessions,
|
setSubSessions,
|
||||||
setParentSessionId,
|
setParentSessionId,
|
||||||
setIsGroundTruth,
|
setIsGroundTruth,
|
||||||
@@ -54,8 +54,9 @@ function OcrKombiContent() {
|
|||||||
return (
|
return (
|
||||||
<StepUpload
|
<StepUpload
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onUploaded={(sid) => {
|
onUploaded={(sid, name) => {
|
||||||
setSessionId(sid)
|
setSessionId(sid)
|
||||||
|
setSessionName(name)
|
||||||
loadSessions()
|
loadSessions()
|
||||||
}}
|
}}
|
||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
@@ -65,7 +66,7 @@ function OcrKombiContent() {
|
|||||||
return (
|
return (
|
||||||
<StepOrientation
|
<StepOrientation
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onNext={handleOrientationComplete}
|
onNext={() => handleNext()}
|
||||||
onSessionList={() => { loadSessions(); handleNewSession() }}
|
onSessionList={() => { loadSessions(); handleNewSession() }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -73,10 +74,19 @@ function OcrKombiContent() {
|
|||||||
return (
|
return (
|
||||||
<StepPageSplit
|
<StepPageSplit
|
||||||
sessionId={sessionId}
|
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) => {
|
onSubSessionsCreated={(subs) => {
|
||||||
setSubSessions(subs)
|
setSubSessions(subs)
|
||||||
if (sessionId) setParentSessionId(sessionId)
|
if (sessionId) setParentSessionId(sessionId)
|
||||||
|
loadSessions()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export function useKombiPipeline() {
|
|||||||
setSessionId,
|
setSessionId,
|
||||||
setSubSessions,
|
setSubSessions,
|
||||||
setParentSessionId,
|
setParentSessionId,
|
||||||
|
setSessionName,
|
||||||
setIsGroundTruth,
|
setIsGroundTruth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import { StepOrientation as BaseStepOrientation } from '@/components/ocr-pipelin
|
|||||||
|
|
||||||
interface StepOrientationProps {
|
interface StepOrientationProps {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
onNext: (sessionId: string) => void
|
onNext: () => void
|
||||||
onSessionList: () => 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) {
|
export function StepOrientation({ sessionId, onNext, onSessionList }: StepOrientationProps) {
|
||||||
return (
|
return (
|
||||||
<BaseStepOrientation
|
<BaseStepOrientation
|
||||||
key={sessionId}
|
key={sessionId}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onNext={onNext}
|
onNext={() => onNext()}
|
||||||
onSessionList={onSessionList}
|
onSessionList={onSessionList}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,123 +1,201 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
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 {
|
interface StepPageSplitProps {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
|
sessionName: string
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
onSubSessionsCreated: (subs: SubSession[]) => void
|
onSubSessionsCreated: (subs: SubSession[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function StepPageSplit({ sessionId, sessionName, onNext, onSubSessionsCreated }: StepPageSplitProps) {
|
||||||
* Step 3: Page split detection.
|
const [detecting, setDetecting] = useState(false)
|
||||||
* Checks if the image is a double-page spread and offers to split it.
|
const [splitResult, setSplitResult] = useState<PageSplitResult | null>(null)
|
||||||
* 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('')
|
const [error, setError] = useState('')
|
||||||
|
const didDetect = useRef(false)
|
||||||
|
|
||||||
|
// Auto-detect page split when step opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return
|
if (!sessionId || didDetect.current) return
|
||||||
// Auto-check for page split
|
didDetect.current = true
|
||||||
checkPageSplit()
|
detectPageSplit()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
const checkPageSplit = async () => {
|
const detectPageSplit = async () => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
setChecking(true)
|
setDetecting(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
// First check if sub-sessions already exist
|
||||||
if (!res.ok) throw new Error('Session nicht gefunden')
|
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||||
const data = await res.json()
|
if (sessionRes.ok) {
|
||||||
|
const sessionData = await sessionRes.json()
|
||||||
// If sub-sessions already exist, this was already split
|
if (sessionData.sub_sessions?.length > 0) {
|
||||||
if (data.sub_sessions?.length > 0) {
|
// Already split — show existing sub-sessions
|
||||||
onSubSessionsCreated(data.sub_sessions)
|
const subs = sessionData.sub_sessions as { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }[]
|
||||||
onNext()
|
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
|
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 () => {
|
// Run page-split detection
|
||||||
if (!sessionId) return
|
|
||||||
setSplitting(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/page-split`, {
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/page-split`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}))
|
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()
|
const data: PageSplitResult = await res.json()
|
||||||
if (data.sub_sessions?.length > 0) {
|
setSplitResult(data)
|
||||||
onSubSessionsCreated(data.sub_sessions)
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setSplitting(false)
|
setDetecting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checking) {
|
if (!sessionId) return null
|
||||||
return <div className="text-sm text-gray-500 py-8 text-center">Pruefe Seitenformat...</div>
|
|
||||||
}
|
const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/oriented`
|
||||||
|
|
||||||
if (splitResult?.is_double_page) {
|
|
||||||
return (
|
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">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
{/* Image */}
|
||||||
Doppelseite erkannt
|
<div className="relative rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||||
</h3>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
<img
|
||||||
Das Bild scheint eine Doppelseite zu sein. Soll es in zwei Einzelseiten aufgeteilt werden?
|
src={imageUrl}
|
||||||
</p>
|
alt="Orientiertes Bild"
|
||||||
<div className="flex gap-2">
|
className="w-full object-contain max-h-[500px]"
|
||||||
<button
|
onError={(e) => {
|
||||||
onClick={handleSplit}
|
// Fallback to non-oriented image
|
||||||
disabled={splitting}
|
(e.target as HTMLImageElement).src =
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image`
|
||||||
>
|
}}
|
||||||
{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>
|
</div>
|
||||||
{error && <div className="text-sm text-red-500">{error}</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 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"
|
||||||
|
>
|
||||||
|
{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>✓</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
)}
|
||||||
|
|
||||||
return (
|
{/* Error */}
|
||||||
<div className="text-sm text-gray-500 py-8 text-center">
|
{error && (
|
||||||
Einzelseite erkannt — weiter zum naechsten Schritt.
|
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||||
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
{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-6 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 transition-colors"
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const KLAUSUR_API = '/klausur-api'
|
|||||||
|
|
||||||
interface StepUploadProps {
|
interface StepUploadProps {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
onUploaded: (sessionId: string) => void
|
onUploaded: (sessionId: string, name: string) => void
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export function StepUpload({ sessionId, onUploaded, onNext }: StepUploadProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onUploaded(sid)
|
onUploaded(sid, title.trim() || selectedFile.name)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user