refactor: independent sessions for page-split + URL-based pipeline navigation
Page-split now creates independent sessions (no parent_session_id), parent marked as status='split' and hidden from list. Navigation uses useSearchParams for URL-based step tracking (browser back/forward works). page.tsx reduced from 684 to 443 lines via usePipelineNavigation hook. Box sub-sessions (column detection) remain unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -383,7 +383,7 @@ export default function OcrOverlayPage() {
|
|||||||
if (mode === 'paddle-direct' || mode === 'kombi') {
|
if (mode === 'paddle-direct' || mode === 'kombi') {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 0:
|
case 0:
|
||||||
return <StepOrientation key={sessionId} sessionId={sessionId} onNext={handleOrientationComplete} onSubSessionsCreated={handleBoxSessionsCreated} />
|
return <StepOrientation key={sessionId} sessionId={sessionId} onNext={handleOrientationComplete} onSessionList={() => { loadSessions(); setSessionId(null) }} />
|
||||||
case 1:
|
case 1:
|
||||||
return <StepDeskew key={sessionId} sessionId={sessionId} onNext={handleNext} />
|
return <StepDeskew key={sessionId} sessionId={sessionId} onNext={handleNext} />
|
||||||
case 2:
|
case 2:
|
||||||
@@ -421,7 +421,7 @@ export default function OcrOverlayPage() {
|
|||||||
}
|
}
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 0:
|
case 0:
|
||||||
return <StepOrientation key={sessionId} sessionId={sessionId} onNext={handleOrientationComplete} onSubSessionsCreated={handleBoxSessionsCreated} />
|
return <StepOrientation key={sessionId} sessionId={sessionId} onNext={handleOrientationComplete} onSessionList={() => { loadSessions(); setSessionId(null) }} />
|
||||||
case 1:
|
case 1:
|
||||||
return <StepDeskew key={sessionId} sessionId={sessionId} onNext={handleNext} />
|
return <StepDeskew key={sessionId} sessionId={sessionId} onNext={handleNext} />
|
||||||
case 2:
|
case 2:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { Suspense, useCallback, useEffect, useState } from 'react'
|
||||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||||
import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper'
|
import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper'
|
||||||
import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation'
|
import { StepOrientation } from '@/components/ocr-pipeline/StepOrientation'
|
||||||
@@ -14,37 +14,28 @@ import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecogniti
|
|||||||
import { StepLlmReview } from '@/components/ocr-pipeline/StepLlmReview'
|
import { StepLlmReview } from '@/components/ocr-pipeline/StepLlmReview'
|
||||||
import { StepReconstruction } from '@/components/ocr-pipeline/StepReconstruction'
|
import { StepReconstruction } from '@/components/ocr-pipeline/StepReconstruction'
|
||||||
import { StepGroundTruth } from '@/components/ocr-pipeline/StepGroundTruth'
|
import { StepGroundTruth } from '@/components/ocr-pipeline/StepGroundTruth'
|
||||||
import { BoxSessionTabs } from '@/components/ocr-pipeline/BoxSessionTabs'
|
import { DOCUMENT_CATEGORIES, type SessionListItem, type DocumentTypeResult, type DocumentCategory, type SubSession } from './types'
|
||||||
import { PIPELINE_STEPS, DOCUMENT_CATEGORIES, type PipelineStep, type SessionListItem, type DocumentTypeResult, type DocumentCategory, type SubSession } from './types'
|
import { usePipelineNavigation } from './usePipelineNavigation'
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
export default function OcrPipelinePage() {
|
const STEP_NAMES: Record<number, string> = {
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
1: 'Orientierung', 2: 'Begradigung', 3: 'Entzerrung', 4: 'Zuschneiden',
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
5: 'Spalten', 6: 'Zeilen', 7: 'Woerter', 8: 'Struktur',
|
||||||
const [sessionName, setSessionName] = useState<string>('')
|
9: 'Korrektur', 10: 'Rekonstruktion', 11: 'Validierung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function OcrPipelineContent() {
|
||||||
|
const nav = usePipelineNavigation()
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
const [sessions, setSessions] = useState<SessionListItem[]>([])
|
||||||
const [loadingSessions, setLoadingSessions] = useState(true)
|
const [loadingSessions, setLoadingSessions] = useState(true)
|
||||||
const [editingName, setEditingName] = useState<string | null>(null)
|
const [editingName, setEditingName] = useState<string | null>(null)
|
||||||
const [editNameValue, setEditNameValue] = useState('')
|
const [editNameValue, setEditNameValue] = useState('')
|
||||||
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
const [editingCategory, setEditingCategory] = useState<string | null>(null)
|
||||||
const [docTypeResult, setDocTypeResult] = useState<DocumentTypeResult | null>(null)
|
const [sessionName, setSessionName] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<DocumentCategory | undefined>(undefined)
|
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,
|
|
||||||
status: i === 0 ? 'active' : 'pending',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Load session list on mount
|
const loadSessions = useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
loadSessions()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadSessions = async () => {
|
|
||||||
setLoadingSessions(true)
|
setLoadingSessions(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`)
|
||||||
@@ -57,103 +48,42 @@ export default function OcrPipelinePage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingSessions(false)
|
setLoadingSessions(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
setSessionId(sid)
|
|
||||||
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)
|
|
||||||
// Parent has sub-sessions — open the first incomplete one (or most advanced if all done)
|
|
||||||
const incomplete = data.sub_sessions.find(
|
|
||||||
(s: SubSession) => !s.current_step || s.current_step < 10,
|
|
||||||
)
|
|
||||||
const target = incomplete || [...data.sub_sessions].sort(
|
|
||||||
(a: SubSession, b: SubSession) => (b.current_step || 0) - (a.current_step || 0),
|
|
||||||
)[0]
|
|
||||||
if (target) {
|
|
||||||
openSession(target.id, true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
|
|
||||||
// Determine which step to jump to based on current_step
|
|
||||||
const dbStep = data.current_step || 1
|
|
||||||
// DB steps: 1=start, 2=orientation, 3=deskew, 4=dewarp, 5=crop, 6=columns, ...
|
|
||||||
// UI steps are 0-indexed: 0=orientation, 1=deskew, 2=dewarp, 3=crop, 4=columns, ...
|
|
||||||
let uiStep = Math.max(0, dbStep - 1)
|
|
||||||
const skipSteps = [...(savedDocType?.skip_steps || [])]
|
|
||||||
|
|
||||||
// Sub-session handling depends on how they were created:
|
|
||||||
// - Crop-based (current_step >= 5): image already cropped, skip all pre-processing
|
|
||||||
// - Page-split (current_step 2): orientation done on parent, skip only orientation
|
|
||||||
// - Page-split from original (current_step 1): needs full pipeline
|
|
||||||
const isSubSession = !!data.parent_session_id
|
|
||||||
if (isSubSession) {
|
|
||||||
if (dbStep >= 5) {
|
|
||||||
// Crop-based sub-sessions: image already cropped
|
|
||||||
const SUB_SESSION_SKIP = ['orientation', 'deskew', 'dewarp', 'crop']
|
|
||||||
for (const s of SUB_SESSION_SKIP) {
|
|
||||||
if (!skipSteps.includes(s)) skipSteps.push(s)
|
|
||||||
}
|
|
||||||
if (uiStep < 4) uiStep = 4 // columns step (index 4)
|
|
||||||
} else if (dbStep >= 2) {
|
|
||||||
// Page-split sub-session: parent orientation applied, skip only orientation
|
|
||||||
if (!skipSteps.includes('orientation')) skipSteps.push('orientation')
|
|
||||||
if (uiStep < 1) uiStep = 1 // advance past skipped orientation to deskew
|
|
||||||
}
|
|
||||||
// dbStep === 1: page-split from original image, needs full pipeline
|
|
||||||
}
|
|
||||||
|
|
||||||
setSteps(
|
|
||||||
PIPELINE_STEPS.map((s, i) => ({
|
|
||||||
...s,
|
|
||||||
status: skipSteps.includes(s.id)
|
|
||||||
? 'skipped'
|
|
||||||
: i < uiStep ? 'completed' : i === uiStep ? 'active' : 'pending',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
setCurrentStep(uiStep)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to open session:', e)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadSessions() }, [loadSessions])
|
||||||
|
|
||||||
|
// Sync session name when nav.sessionId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!nav.sessionId) {
|
||||||
|
setSessionName('')
|
||||||
|
setActiveCategory(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${nav.sessionId}`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
setSessionName(data.name || data.filename || '')
|
||||||
|
setActiveCategory(data.document_category || undefined)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [nav.sessionId])
|
||||||
|
|
||||||
|
const openSession = useCallback((sid: string) => {
|
||||||
|
nav.goToSession(sid)
|
||||||
|
}, [nav])
|
||||||
|
|
||||||
const deleteSession = useCallback(async (sid: string) => {
|
const deleteSession = useCallback(async (sid: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, { method: 'DELETE' })
|
||||||
setSessions((prev) => prev.filter((s) => s.id !== sid))
|
setSessions(prev => prev.filter(s => s.id !== sid))
|
||||||
if (sessionId === sid) {
|
if (nav.sessionId === sid) nav.goToSessionList()
|
||||||
setSessionId(null)
|
|
||||||
setCurrentStep(0)
|
|
||||||
setDocTypeResult(null)
|
|
||||||
setSubSessions([])
|
|
||||||
setParentSessionId(null)
|
|
||||||
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete session:', e)
|
console.error('Failed to delete session:', e)
|
||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [nav])
|
||||||
|
|
||||||
const renameSession = useCallback(async (sid: string, newName: string) => {
|
const renameSession = useCallback(async (sid: string, newName: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -162,13 +92,13 @@ export default function OcrPipelinePage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name: newName }),
|
body: JSON.stringify({ name: newName }),
|
||||||
})
|
})
|
||||||
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, name: newName } : s)))
|
setSessions(prev => prev.map(s => (s.id === sid ? { ...s, name: newName } : s)))
|
||||||
if (sessionId === sid) setSessionName(newName)
|
if (nav.sessionId === sid) setSessionName(newName)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to rename session:', e)
|
console.error('Failed to rename session:', e)
|
||||||
}
|
}
|
||||||
setEditingName(null)
|
setEditingName(null)
|
||||||
}, [sessionId])
|
}, [nav.sessionId])
|
||||||
|
|
||||||
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
const updateCategory = useCallback(async (sid: string, category: DocumentCategory) => {
|
||||||
try {
|
try {
|
||||||
@@ -177,275 +107,107 @@ export default function OcrPipelinePage() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ document_category: category }),
|
body: JSON.stringify({ document_category: category }),
|
||||||
})
|
})
|
||||||
setSessions((prev) => prev.map((s) => (s.id === sid ? { ...s, document_category: category } : s)))
|
setSessions(prev => prev.map(s => (s.id === sid ? { ...s, document_category: category } : s)))
|
||||||
if (sessionId === sid) setActiveCategory(category)
|
if (nav.sessionId === sid) setActiveCategory(category)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to update category:', e)
|
console.error('Failed to update category:', e)
|
||||||
}
|
}
|
||||||
setEditingCategory(null)
|
setEditingCategory(null)
|
||||||
}, [sessionId])
|
}, [nav.sessionId])
|
||||||
|
|
||||||
const deleteAllSessions = useCallback(async () => {
|
const deleteAllSessions = useCallback(async () => {
|
||||||
if (!confirm('Alle Sessions loeschen? Dies kann nicht rueckgaengig gemacht werden.')) return
|
if (!confirm('Alle Sessions loeschen? Dies kann nicht rueckgaengig gemacht werden.')) return
|
||||||
try {
|
try {
|
||||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, { method: 'DELETE' })
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, { method: 'DELETE' })
|
||||||
setSessions([])
|
setSessions([])
|
||||||
setSessionId(null)
|
nav.goToSessionList()
|
||||||
setCurrentStep(0)
|
|
||||||
setDocTypeResult(null)
|
|
||||||
setActiveCategory(undefined)
|
|
||||||
setSubSessions([])
|
|
||||||
setParentSessionId(null)
|
|
||||||
setSteps(PIPELINE_STEPS.map((s, i) => ({ ...s, status: i === 0 ? 'active' : 'pending' })))
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete all sessions:', e)
|
console.error('Failed to delete all sessions:', e)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [nav])
|
||||||
|
|
||||||
const handleStepClick = (index: number) => {
|
const handleStepClick = (index: number) => {
|
||||||
if (index <= currentStep || steps[index].status === 'completed') {
|
if (index <= nav.currentStepIndex || nav.steps[index].status === 'completed') {
|
||||||
setCurrentStep(index)
|
nav.goToStep(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToStep = (step: number) => {
|
// Orientation: after upload, navigate to session at deskew step
|
||||||
setCurrentStep(step)
|
const handleOrientationComplete = useCallback(async (sid: string) => {
|
||||||
setSteps((prev) =>
|
|
||||||
prev.map((s, i) => ({
|
|
||||||
...s,
|
|
||||||
status: i < step ? 'completed' : i === step ? 'active' : 'pending',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (currentStep >= steps.length - 1) {
|
|
||||||
// Last step completed
|
|
||||||
if (parentSessionId && sessionId !== parentSessionId) {
|
|
||||||
// Sub-session completed — mark it and find next incomplete one
|
|
||||||
const updatedSubs = subSessions.map((s) =>
|
|
||||||
s.id === sessionId ? { ...s, status: 'completed' as const, current_step: 10 } : s,
|
|
||||||
)
|
|
||||||
setSubSessions(updatedSubs)
|
|
||||||
|
|
||||||
// Find next incomplete sub-session
|
|
||||||
const nextIncomplete = updatedSubs.find(
|
|
||||||
(s) => s.id !== sessionId && (!s.current_step || s.current_step < 10),
|
|
||||||
)
|
|
||||||
if (nextIncomplete) {
|
|
||||||
// Open next incomplete sub-session
|
|
||||||
openSession(nextIncomplete.id, true)
|
|
||||||
} else {
|
|
||||||
// All sub-sessions done — 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
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the next non-skipped step
|
|
||||||
const skipSteps = docTypeResult?.skip_steps || []
|
|
||||||
let nextStep = currentStep + 1
|
|
||||||
while (nextStep < steps.length && skipSteps.includes(PIPELINE_STEPS[nextStep]?.id)) {
|
|
||||||
nextStep++
|
|
||||||
}
|
|
||||||
if (nextStep >= steps.length) nextStep = steps.length - 1
|
|
||||||
|
|
||||||
setSteps((prev) =>
|
|
||||||
prev.map((s, i) => {
|
|
||||||
if (i === currentStep) return { ...s, status: 'completed' }
|
|
||||||
if (i === nextStep) return { ...s, status: 'active' }
|
|
||||||
// Mark skipped steps between current and next
|
|
||||||
if (i > currentStep && i < nextStep && skipSteps.includes(PIPELINE_STEPS[i]?.id)) {
|
|
||||||
return { ...s, status: 'skipped' }
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
setCurrentStep(nextStep)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOrientationComplete = async (sid: string) => {
|
|
||||||
setSessionId(sid)
|
|
||||||
loadSessions()
|
loadSessions()
|
||||||
|
// Navigate directly to deskew step (index 1) for this session
|
||||||
|
nav.goToSession(sid)
|
||||||
|
}, [nav, loadSessions])
|
||||||
|
|
||||||
// Check for page-split sub-sessions directly from API
|
// Crop: detect doc type then advance
|
||||||
// (React state may not be committed yet due to batching)
|
const handleCropNext = useCallback(async () => {
|
||||||
try {
|
if (nav.sessionId) {
|
||||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`)
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.sub_sessions?.length > 0) {
|
|
||||||
const subs: SubSession[] = data.sub_sessions.map((s: SubSession) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
box_index: s.box_index,
|
|
||||||
current_step: s.current_step,
|
|
||||||
}))
|
|
||||||
setSubSessions(subs)
|
|
||||||
setParentSessionId(sid)
|
|
||||||
openSession(subs[0].id, true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to check for sub-sessions:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleNext()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCropNext = async () => {
|
|
||||||
// Auto-detect document type after crop (last image-processing step), then advance
|
|
||||||
if (sessionId) {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-type`,
|
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${nav.sessionId}/detect-type`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
)
|
)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data: DocumentTypeResult = await res.json()
|
const data: DocumentTypeResult = await res.json()
|
||||||
setDocTypeResult(data)
|
nav.setDocType(data)
|
||||||
|
|
||||||
// Mark skipped steps immediately
|
|
||||||
const skipSteps = data.skip_steps || []
|
|
||||||
if (skipSteps.length > 0) {
|
|
||||||
setSteps((prev) =>
|
|
||||||
prev.map((s) =>
|
|
||||||
skipSteps.includes(s.id) ? { ...s, status: 'skipped' } : s,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Doc type detection failed:', e)
|
console.error('Doc type detection failed:', e)
|
||||||
// Not critical — continue without it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleNext()
|
nav.goToNextStep()
|
||||||
}
|
}, [nav])
|
||||||
|
|
||||||
const handleDocTypeChange = (newDocType: DocumentTypeResult['doc_type']) => {
|
const handleDocTypeChange = (newDocType: DocumentTypeResult['doc_type']) => {
|
||||||
if (!docTypeResult) return
|
if (!nav.docTypeResult) return
|
||||||
|
|
||||||
// Build new skip_steps based on doc type
|
|
||||||
let skipSteps: string[] = []
|
let skipSteps: string[] = []
|
||||||
if (newDocType === 'full_text') {
|
if (newDocType === 'full_text') skipSteps = ['columns', 'rows']
|
||||||
skipSteps = ['columns', 'rows']
|
|
||||||
}
|
|
||||||
// vocab_table and generic_table: no skips
|
|
||||||
|
|
||||||
const updated: DocumentTypeResult = {
|
nav.setDocType({
|
||||||
...docTypeResult,
|
...nav.docTypeResult,
|
||||||
doc_type: newDocType,
|
doc_type: newDocType,
|
||||||
skip_steps: skipSteps,
|
skip_steps: skipSteps,
|
||||||
pipeline: newDocType === 'full_text' ? 'full_page' : 'cell_first',
|
pipeline: newDocType === 'full_text' ? 'full_page' : 'cell_first',
|
||||||
}
|
})
|
||||||
setDocTypeResult(updated)
|
|
||||||
|
|
||||||
// Update step statuses
|
|
||||||
setSteps((prev) =>
|
|
||||||
prev.map((s) => {
|
|
||||||
if (skipSteps.includes(s.id)) return { ...s, status: 'skipped' as const }
|
|
||||||
if (s.status === 'skipped') return { ...s, status: 'pending' as const }
|
|
||||||
return s
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNewSession = () => {
|
// Box sub-sessions (column detection) — still supported
|
||||||
setSessionId(null)
|
const handleBoxSessionsCreated = useCallback((_subs: SubSession[]) => {
|
||||||
setSessionName('')
|
// Box sub-sessions are tracked by the backend; no client-side state needed anymore
|
||||||
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',
|
|
||||||
3: 'Entzerrung',
|
|
||||||
4: 'Zuschneiden',
|
|
||||||
5: 'Spalten',
|
|
||||||
6: 'Zeilen',
|
|
||||||
7: 'Woerter',
|
|
||||||
8: 'Struktur',
|
|
||||||
9: 'Korrektur',
|
|
||||||
10: 'Rekonstruktion',
|
|
||||||
11: 'Validierung',
|
|
||||||
}
|
|
||||||
|
|
||||||
const reprocessFromStep = useCallback(async (uiStep: number) => {
|
|
||||||
if (!sessionId) return
|
|
||||||
const dbStep = uiStep + 1 // UI is 0-indexed, DB is 1-indexed
|
|
||||||
if (!confirm(`Ab Schritt ${dbStep} (${stepNames[dbStep] || '?'}) neu verarbeiten? Nachfolgende Daten werden geloescht.`)) return
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ from_step: dbStep }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
console.error('Reprocess failed:', data.detail || res.status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Reset UI steps
|
|
||||||
goToStep(uiStep)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Reprocess error:', e)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [sessionId, goToStep])
|
|
||||||
|
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
switch (currentStep) {
|
const sid = nav.sessionId
|
||||||
|
switch (nav.currentStepIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
return <StepOrientation key={sessionId} sessionId={sessionId} onNext={handleOrientationComplete} onSubSessionsCreated={handleBoxSessionsCreated} />
|
return (
|
||||||
|
<StepOrientation
|
||||||
|
key={sid}
|
||||||
|
sessionId={sid}
|
||||||
|
onNext={handleOrientationComplete}
|
||||||
|
onSessionList={() => { loadSessions(); nav.goToSessionList() }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
case 1:
|
case 1:
|
||||||
return <StepDeskew key={sessionId} sessionId={sessionId} onNext={handleNext} />
|
return <StepDeskew key={sid} sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
case 2:
|
case 2:
|
||||||
return <StepDewarp key={sessionId} sessionId={sessionId} onNext={handleNext} />
|
return <StepDewarp key={sid} sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
case 3:
|
case 3:
|
||||||
return <StepCrop key={sessionId} sessionId={sessionId} onNext={handleCropNext} />
|
return <StepCrop key={sid} sessionId={sid} onNext={handleCropNext} />
|
||||||
case 4:
|
case 4:
|
||||||
return <StepColumnDetection sessionId={sessionId} onNext={handleNext} onBoxSessionsCreated={handleBoxSessionsCreated} />
|
return <StepColumnDetection sessionId={sid} onNext={nav.goToNextStep} onBoxSessionsCreated={handleBoxSessionsCreated} />
|
||||||
case 5:
|
case 5:
|
||||||
return <StepRowDetection sessionId={sessionId} onNext={handleNext} />
|
return <StepRowDetection sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
case 6:
|
case 6:
|
||||||
return <StepWordRecognition sessionId={sessionId} onNext={handleNext} goToStep={goToStep} />
|
return <StepWordRecognition sessionId={sid} onNext={nav.goToNextStep} goToStep={nav.goToStep} />
|
||||||
case 7:
|
case 7:
|
||||||
return <StepStructureDetection sessionId={sessionId} onNext={handleNext} />
|
return <StepStructureDetection sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
case 8:
|
case 8:
|
||||||
return <StepLlmReview sessionId={sessionId} onNext={handleNext} />
|
return <StepLlmReview sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
case 9:
|
case 9:
|
||||||
return <StepReconstruction sessionId={sessionId} onNext={handleNext} />
|
return <StepReconstruction sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
case 10:
|
case 10:
|
||||||
return <StepGroundTruth sessionId={sessionId} onNext={handleNext} />
|
return <StepGroundTruth sessionId={sid} onNext={nav.goToNextStep} />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -485,7 +247,7 @@ export default function OcrPipelinePage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleNewSession}
|
onClick={() => nav.goToSessionList()}
|
||||||
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
className="text-xs px-3 py-1.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors"
|
||||||
>
|
>
|
||||||
+ Neue Session
|
+ Neue Session
|
||||||
@@ -505,7 +267,7 @@ export default function OcrPipelinePage() {
|
|||||||
<div
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
className={`relative flex items-start gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||||
sessionId === s.id
|
nav.sessionId === s.id
|
||||||
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
? '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'
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -561,13 +323,12 @@ export default function OcrPipelinePage() {
|
|||||||
</button>
|
</button>
|
||||||
<div className="text-xs text-gray-400 flex gap-2 mt-0.5">
|
<div className="text-xs text-gray-400 flex gap-2 mt-0.5">
|
||||||
<span>{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
<span>{new Date(s.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
<span>Schritt {s.current_step}: {stepNames[s.current_step] || '?'}</span>
|
<span>Schritt {s.current_step}: {STEP_NAMES[s.current_step] || '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badges */}
|
{/* Badges */}
|
||||||
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="flex flex-col gap-1 items-end flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Category Badge */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingCategory(editingCategory === s.id ? null : s.id)}
|
onClick={() => setEditingCategory(editingCategory === s.id ? null : s.id)}
|
||||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition-colors ${
|
||||||
@@ -579,7 +340,6 @@ export default function OcrPipelinePage() {
|
|||||||
>
|
>
|
||||||
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
{catInfo ? `${catInfo.icon} ${catInfo.label}` : '+ Kategorie'}
|
||||||
</button>
|
</button>
|
||||||
{/* Doc Type Badge (read-only) */}
|
|
||||||
{s.doc_type && (
|
{s.doc_type && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
||||||
{s.doc_type}
|
{s.doc_type}
|
||||||
@@ -616,7 +376,7 @@ export default function OcrPipelinePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category dropdown (inline) */}
|
{/* Category dropdown */}
|
||||||
{editingCategory === s.id && (
|
{editingCategory === s.id && (
|
||||||
<div
|
<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"
|
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"
|
||||||
@@ -645,40 +405,39 @@ export default function OcrPipelinePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active session info */}
|
{/* Active session info */}
|
||||||
{sessionId && sessionName && (
|
{nav.sessionId && sessionName && (
|
||||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
|
<span>Aktive Session: <span className="font-medium text-gray-700 dark:text-gray-300">{sessionName}</span></span>
|
||||||
{activeCategory && (() => {
|
{activeCategory && (() => {
|
||||||
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
const cat = DOCUMENT_CATEGORIES.find(c => c.value === activeCategory)
|
||||||
return cat ? <span className="text-xs px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300">{cat.icon} {cat.label}</span> : null
|
return cat ? <span className="text-xs px-2 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700 text-teal-700 dark:text-teal-300">{cat.icon} {cat.label}</span> : null
|
||||||
})()}
|
})()}
|
||||||
{docTypeResult && (
|
{nav.docTypeResult && (
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600">
|
||||||
{docTypeResult.doc_type}
|
{nav.docTypeResult.doc_type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PipelineStepper
|
<PipelineStepper
|
||||||
steps={steps}
|
steps={nav.steps}
|
||||||
currentStep={currentStep}
|
currentStep={nav.currentStepIndex}
|
||||||
onStepClick={handleStepClick}
|
onStepClick={handleStepClick}
|
||||||
onReprocess={sessionId ? reprocessFromStep : undefined}
|
onReprocess={nav.sessionId ? nav.reprocessFromStep : undefined}
|
||||||
docTypeResult={docTypeResult}
|
docTypeResult={nav.docTypeResult}
|
||||||
onDocTypeChange={handleDocTypeChange}
|
onDocTypeChange={handleDocTypeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{subSessions.length > 0 && parentSessionId && sessionId && (
|
|
||||||
<BoxSessionTabs
|
|
||||||
parentSessionId={parentSessionId}
|
|
||||||
subSessions={subSessions}
|
|
||||||
activeSessionId={sessionId}
|
|
||||||
onSessionChange={handleSessionChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-[400px]">{renderStep()}</div>
|
<div className="min-h-[400px]">{renderStep()}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function OcrPipelinePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-8 text-gray-400">Lade Pipeline...</div>}>
|
||||||
|
<OcrPipelineContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ export interface SessionListItem {
|
|||||||
doc_type?: string
|
doc_type?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
parent_session_id?: string | null
|
|
||||||
box_index?: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Box sub-session (from column detection zone_type='box') */
|
||||||
export interface SubSession {
|
export interface SubSession {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { PIPELINE_STEPS, type PipelineStep, type PipelineStepStatus, type DocumentTypeResult } from './types'
|
||||||
|
|
||||||
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
|
export interface PipelineNav {
|
||||||
|
sessionId: string | null
|
||||||
|
currentStepIndex: number
|
||||||
|
currentStepId: string
|
||||||
|
steps: PipelineStep[]
|
||||||
|
docTypeResult: DocumentTypeResult | null
|
||||||
|
|
||||||
|
goToNextStep: () => void
|
||||||
|
goToStep: (index: number) => void
|
||||||
|
goToSession: (sessionId: string) => void
|
||||||
|
goToSessionList: () => void
|
||||||
|
setDocType: (result: DocumentTypeResult) => void
|
||||||
|
reprocessFromStep: (uiStep: number) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_NAMES: Record<number, string> = {
|
||||||
|
1: 'Orientierung', 2: 'Begradigung', 3: 'Entzerrung', 4: 'Zuschneiden',
|
||||||
|
5: 'Spalten', 6: 'Zeilen', 7: 'Woerter', 8: 'Struktur',
|
||||||
|
9: 'Korrektur', 10: 'Rekonstruktion', 11: 'Validierung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(uiStep: number, skipSteps: string[]): PipelineStep[] {
|
||||||
|
return PIPELINE_STEPS.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
status: (
|
||||||
|
skipSteps.includes(s.id) ? 'skipped'
|
||||||
|
: i < uiStep ? 'completed'
|
||||||
|
: i === uiStep ? 'active'
|
||||||
|
: 'pending'
|
||||||
|
) as PipelineStepStatus,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePipelineNavigation(): PipelineNav {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const paramSession = searchParams.get('session')
|
||||||
|
const paramStep = searchParams.get('step')
|
||||||
|
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(paramSession)
|
||||||
|
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||||
|
const [docTypeResult, setDocTypeResult] = useState<DocumentTypeResult | null>(null)
|
||||||
|
const [steps, setSteps] = useState<PipelineStep[]>(buildSteps(0, []))
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
// Load session info when session param changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paramSession) {
|
||||||
|
setSessionId(null)
|
||||||
|
setCurrentStepIndex(0)
|
||||||
|
setDocTypeResult(null)
|
||||||
|
setSteps(buildSteps(0, []))
|
||||||
|
setLoaded(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${paramSession}`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
setSessionId(paramSession)
|
||||||
|
|
||||||
|
const savedDocType: DocumentTypeResult | null = data.doc_type_result || null
|
||||||
|
setDocTypeResult(savedDocType)
|
||||||
|
|
||||||
|
const dbStep = data.current_step || 1
|
||||||
|
let uiStep = Math.max(0, dbStep - 1)
|
||||||
|
const skipSteps = [...(savedDocType?.skip_steps || [])]
|
||||||
|
|
||||||
|
// Box sub-sessions (from column detection) skip pre-processing
|
||||||
|
const isBoxSubSession = !!data.parent_session_id
|
||||||
|
if (isBoxSubSession && dbStep >= 5) {
|
||||||
|
const SUB_SESSION_SKIP = ['orientation', 'deskew', 'dewarp', 'crop']
|
||||||
|
for (const s of SUB_SESSION_SKIP) {
|
||||||
|
if (!skipSteps.includes(s)) skipSteps.push(s)
|
||||||
|
}
|
||||||
|
if (uiStep < 4) uiStep = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL has a step param, use that instead
|
||||||
|
if (paramStep) {
|
||||||
|
const stepIdx = PIPELINE_STEPS.findIndex(s => s.id === paramStep)
|
||||||
|
if (stepIdx >= 0) uiStep = stepIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStepIndex(uiStep)
|
||||||
|
setSteps(buildSteps(uiStep, skipSteps))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load session:', e)
|
||||||
|
} finally {
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
}, [paramSession, paramStep])
|
||||||
|
|
||||||
|
const updateUrl = useCallback((sid: string | null, stepIdx?: number) => {
|
||||||
|
if (!sid) {
|
||||||
|
router.push('/ai/ocr-pipeline')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const stepId = stepIdx !== undefined ? PIPELINE_STEPS[stepIdx]?.id : undefined
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('session', sid)
|
||||||
|
if (stepId) params.set('step', stepId)
|
||||||
|
router.push(`/ai/ocr-pipeline?${params.toString()}`)
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const goToNextStep = useCallback(() => {
|
||||||
|
if (currentStepIndex >= steps.length - 1) {
|
||||||
|
// Last step — return to session list
|
||||||
|
setSessionId(null)
|
||||||
|
setCurrentStepIndex(0)
|
||||||
|
setDocTypeResult(null)
|
||||||
|
setSteps(buildSteps(0, []))
|
||||||
|
router.push('/ai/ocr-pipeline')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSteps = docTypeResult?.skip_steps || []
|
||||||
|
let nextStep = currentStepIndex + 1
|
||||||
|
while (nextStep < steps.length && skipSteps.includes(PIPELINE_STEPS[nextStep]?.id)) {
|
||||||
|
nextStep++
|
||||||
|
}
|
||||||
|
if (nextStep >= steps.length) nextStep = steps.length - 1
|
||||||
|
|
||||||
|
setSteps(prev =>
|
||||||
|
prev.map((s, i) => {
|
||||||
|
if (i === currentStepIndex) return { ...s, status: 'completed' as PipelineStepStatus }
|
||||||
|
if (i === nextStep) return { ...s, status: 'active' as PipelineStepStatus }
|
||||||
|
if (i > currentStepIndex && i < nextStep && skipSteps.includes(PIPELINE_STEPS[i]?.id)) {
|
||||||
|
return { ...s, status: 'skipped' as PipelineStepStatus }
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setCurrentStepIndex(nextStep)
|
||||||
|
if (sessionId) updateUrl(sessionId, nextStep)
|
||||||
|
}, [currentStepIndex, steps.length, docTypeResult, sessionId, updateUrl, router])
|
||||||
|
|
||||||
|
const goToStep = useCallback((index: number) => {
|
||||||
|
setCurrentStepIndex(index)
|
||||||
|
setSteps(prev =>
|
||||||
|
prev.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
status: s.status === 'skipped' ? 'skipped'
|
||||||
|
: i < index ? 'completed'
|
||||||
|
: i === index ? 'active'
|
||||||
|
: 'pending' as PipelineStepStatus,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (sessionId) updateUrl(sessionId, index)
|
||||||
|
}, [sessionId, updateUrl])
|
||||||
|
|
||||||
|
const goToSession = useCallback((sid: string) => {
|
||||||
|
updateUrl(sid)
|
||||||
|
}, [updateUrl])
|
||||||
|
|
||||||
|
const goToSessionList = useCallback(() => {
|
||||||
|
setSessionId(null)
|
||||||
|
setCurrentStepIndex(0)
|
||||||
|
setDocTypeResult(null)
|
||||||
|
setSteps(buildSteps(0, []))
|
||||||
|
router.push('/ai/ocr-pipeline')
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const setDocType = useCallback((result: DocumentTypeResult) => {
|
||||||
|
setDocTypeResult(result)
|
||||||
|
const skipSteps = result.skip_steps || []
|
||||||
|
if (skipSteps.length > 0) {
|
||||||
|
setSteps(prev =>
|
||||||
|
prev.map(s =>
|
||||||
|
skipSteps.includes(s.id) ? { ...s, status: 'skipped' as PipelineStepStatus } : s,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reprocessFromStep = useCallback(async (uiStep: number) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
const dbStep = uiStep + 1
|
||||||
|
if (!confirm(`Ab Schritt ${dbStep} (${STEP_NAMES[dbStep] || '?'}) neu verarbeiten? Nachfolgende Daten werden geloescht.`)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reprocess`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ from_step: dbStep }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
console.error('Reprocess failed:', data.detail || res.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
goToStep(uiStep)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Reprocess error:', e)
|
||||||
|
}
|
||||||
|
}, [sessionId, goToStep])
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
currentStepIndex,
|
||||||
|
currentStepId: PIPELINE_STEPS[currentStepIndex]?.id || 'orientation',
|
||||||
|
steps,
|
||||||
|
docTypeResult,
|
||||||
|
goToNextStep,
|
||||||
|
goToStep,
|
||||||
|
goToSession,
|
||||||
|
goToSessionList,
|
||||||
|
setDocType,
|
||||||
|
reprocessFromStep,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ function getStatusIcon(sub: SubSession): string {
|
|||||||
return STATUS_ICONS.pending
|
return STATUS_ICONS.pending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tabs for box sub-sessions (from column detection zone_type='box'). */
|
||||||
export function BoxSessionTabs({ parentSessionId, subSessions, activeSessionId, onSessionChange }: BoxSessionTabsProps) {
|
export function BoxSessionTabs({ parentSessionId, subSessions, activeSessionId, onSessionChange }: BoxSessionTabsProps) {
|
||||||
if (subSessions.length === 0) return null
|
if (subSessions.length === 0) return null
|
||||||
|
|
||||||
@@ -28,7 +29,6 @@ export function BoxSessionTabs({ parentSessionId, subSessions, activeSessionId,
|
|||||||
|
|
||||||
return (
|
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">
|
<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
|
<button
|
||||||
onClick={() => onSessionChange(parentSessionId)}
|
onClick={() => onSessionChange(parentSessionId)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
@@ -42,7 +42,6 @@ export function BoxSessionTabs({ parentSessionId, subSessions, activeSessionId,
|
|||||||
|
|
||||||
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
|
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
|
||||||
{/* Sub-session tabs */}
|
|
||||||
{subSessions.map((sub) => {
|
{subSessions.map((sub) => {
|
||||||
const isActive = activeSessionId === sub.id
|
const isActive = activeSessionId === sub.id
|
||||||
const icon = getStatusIcon(sub)
|
const icon = getStatusIcon(sub)
|
||||||
@@ -59,7 +58,7 @@ export function BoxSessionTabs({ parentSessionId, subSessions, activeSessionId,
|
|||||||
title={sub.name}
|
title={sub.name}
|
||||||
>
|
>
|
||||||
<span className="mr-1">{icon}</span>
|
<span className="mr-1">{icon}</span>
|
||||||
Seite {sub.box_index + 1}
|
Box {sub.box_index + 1}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { OrientationResult, SessionInfo, SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { OrientationResult, SessionInfo } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
import { ImageCompareView } from './ImageCompareView'
|
import { ImageCompareView } from './ImageCompareView'
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
const KLAUSUR_API = '/klausur-api'
|
||||||
@@ -17,10 +17,10 @@ interface PageSplitResult {
|
|||||||
interface StepOrientationProps {
|
interface StepOrientationProps {
|
||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
onNext: (sessionId: string) => void
|
onNext: (sessionId: string) => void
|
||||||
onSubSessionsCreated?: (subs: SubSession[]) => void
|
onSessionList?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepOrientation({ sessionId: existingSessionId, onNext, onSubSessionsCreated }: StepOrientationProps) {
|
export function StepOrientation({ sessionId: existingSessionId, onNext, onSessionList }: StepOrientationProps) {
|
||||||
const [session, setSession] = useState<SessionInfo | null>(null)
|
const [session, setSession] = useState<SessionInfo | null>(null)
|
||||||
const [orientationResult, setOrientationResult] = useState<OrientationResult | null>(null)
|
const [orientationResult, setOrientationResult] = useState<OrientationResult | null>(null)
|
||||||
const [pageSplitResult, setPageSplitResult] = useState<PageSplitResult | null>(null)
|
const [pageSplitResult, setPageSplitResult] = useState<PageSplitResult | null>(null)
|
||||||
@@ -112,16 +112,6 @@ export function StepOrientation({ sessionId: existingSessionId, onNext, onSubSes
|
|||||||
if (splitRes.ok) {
|
if (splitRes.ok) {
|
||||||
const splitData: PageSplitResult = await splitRes.json()
|
const splitData: PageSplitResult = await splitRes.json()
|
||||||
setPageSplitResult(splitData)
|
setPageSplitResult(splitData)
|
||||||
if (splitData.multi_page && splitData.sub_sessions && onSubSessionsCreated) {
|
|
||||||
onSubSessionsCreated(
|
|
||||||
splitData.sub_sessions.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
box_index: s.page_index,
|
|
||||||
current_step: splitData.used_original ? 1 : 2,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Page-split detection failed:', e)
|
console.error('Page-split detection failed:', e)
|
||||||
@@ -133,7 +123,7 @@ export function StepOrientation({ sessionId: existingSessionId, onNext, onSubSes
|
|||||||
setUploading(false)
|
setUploading(false)
|
||||||
setDetecting(false)
|
setDetecting(false)
|
||||||
}
|
}
|
||||||
}, [sessionName, onSubSessionsCreated])
|
}, [sessionName])
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -264,10 +254,10 @@ export function StepOrientation({ sessionId: existingSessionId, onNext, onSubSes
|
|||||||
{pageSplitResult?.multi_page && (
|
{pageSplitResult?.multi_page && (
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700 p-4">
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700 p-4">
|
||||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||||
Doppelseite erkannt — {pageSplitResult.page_count} Seiten
|
Doppelseite erkannt — {pageSplitResult.page_count} unabhaengige Sessions erstellt
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||||
Jede Seite wird einzeln durch die Pipeline (Begradigung, Entzerrung, Zuschnitt, ...) verarbeitet.
|
Jede Seite wird als eigene Session durch die Pipeline verarbeitet.
|
||||||
{pageSplitResult.used_original && ' (Seitentrennung auf dem Originalbild, da die Orientierung die Doppelseite gedreht hat.)'}
|
{pageSplitResult.used_original && ' (Seitentrennung auf dem Originalbild, da die Orientierung die Doppelseite gedreht hat.)'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
@@ -286,12 +276,21 @@ export function StepOrientation({ sessionId: existingSessionId, onNext, onSubSes
|
|||||||
{/* Next button */}
|
{/* Next button */}
|
||||||
{orientationResult && (
|
{orientationResult && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
{pageSplitResult?.multi_page ? (
|
||||||
onClick={() => onNext(session.session_id)}
|
<button
|
||||||
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
onClick={() => onSessionList?.()}
|
||||||
>
|
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
||||||
{pageSplitResult?.multi_page ? 'Seiten verarbeiten' : 'Weiter'} →
|
>
|
||||||
</button>
|
Zur Session-Liste →
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onNext(session.session_id)}
|
||||||
|
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ async def list_sessions_db(
|
|||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
where = "" if include_sub_sessions else "WHERE parent_session_id IS NULL"
|
where = "" if include_sub_sessions else "WHERE parent_session_id IS NULL AND (status IS NULL OR status != 'split')"
|
||||||
rows = await conn.fetch(f"""
|
rows = await conn.fetch(f"""
|
||||||
SELECT id, name, filename, status, current_step,
|
SELECT id, name, filename, status, current_step,
|
||||||
document_category, doc_type,
|
document_category, doc_type,
|
||||||
|
|||||||
@@ -191,12 +191,12 @@ async def get_session_info(session_id: str):
|
|||||||
if session.get("ground_truth"):
|
if session.get("ground_truth"):
|
||||||
result["ground_truth"] = session["ground_truth"]
|
result["ground_truth"] = session["ground_truth"]
|
||||||
|
|
||||||
# Sub-session info
|
# Box sub-session info (zone_type='box' from column detection — NOT page-split)
|
||||||
if session.get("parent_session_id"):
|
if session.get("parent_session_id"):
|
||||||
result["parent_session_id"] = session["parent_session_id"]
|
result["parent_session_id"] = session["parent_session_id"]
|
||||||
result["box_index"] = session.get("box_index")
|
result["box_index"] = session.get("box_index")
|
||||||
else:
|
else:
|
||||||
# Check for sub-sessions
|
# Check for box sub-sessions (column detection creates these)
|
||||||
subs = await get_sub_sessions(session_id)
|
subs = await get_sub_sessions(session_id)
|
||||||
if subs:
|
if subs:
|
||||||
result["sub_sessions"] = [
|
result["sub_sessions"] = [
|
||||||
|
|||||||
@@ -238,8 +238,8 @@ async def detect_page_split(session_id: str):
|
|||||||
"duration_seconds": round(duration, 2),
|
"duration_seconds": round(duration, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mark parent session as split (store info in crop_result for backward compat)
|
# Mark parent session as split and hidden from session list
|
||||||
await update_session_db(session_id, crop_result=split_info)
|
await update_session_db(session_id, crop_result=split_info, status='split')
|
||||||
cached["crop_result"] = split_info
|
cached["crop_result"] = split_info
|
||||||
|
|
||||||
await _append_pipeline_log(session_id, "page_split", {
|
await _append_pipeline_log(session_id, "page_split", {
|
||||||
@@ -346,6 +346,7 @@ async def auto_crop(session_id: str):
|
|||||||
cropped_png=png_buf.tobytes() if ok else b"",
|
cropped_png=png_buf.tobytes() if ok else b"",
|
||||||
crop_result=crop_info,
|
crop_result=crop_info,
|
||||||
current_step=5,
|
current_step=5,
|
||||||
|
status='split',
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -461,8 +462,6 @@ async def _create_page_sub_sessions(
|
|||||||
name=sub_name,
|
name=sub_name,
|
||||||
filename=parent_filename,
|
filename=parent_filename,
|
||||||
original_png=page_png,
|
original_png=page_png,
|
||||||
parent_session_id=parent_session_id,
|
|
||||||
box_index=pi,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-populate: set cropped = original (already cropped)
|
# Pre-populate: set cropped = original (already cropped)
|
||||||
@@ -540,8 +539,6 @@ async def _create_page_sub_sessions_full(
|
|||||||
name=sub_name,
|
name=sub_name,
|
||||||
filename=parent_filename,
|
filename=parent_filename,
|
||||||
original_png=page_png,
|
original_png=page_png,
|
||||||
parent_session_id=parent_session_id,
|
|
||||||
box_index=pi,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# start_step=2 → ready for deskew (orientation already done on spread)
|
# start_step=2 → ready for deskew (orientation already done on spread)
|
||||||
@@ -553,7 +550,6 @@ async def _create_page_sub_sessions_full(
|
|||||||
"id": sub_id,
|
"id": sub_id,
|
||||||
"filename": parent_filename,
|
"filename": parent_filename,
|
||||||
"name": sub_name,
|
"name": sub_name,
|
||||||
"parent_session_id": parent_session_id,
|
|
||||||
"original_bgr": page_bgr,
|
"original_bgr": page_bgr,
|
||||||
"oriented_bgr": None,
|
"oriented_bgr": None,
|
||||||
"cropped_bgr": None,
|
"cropped_bgr": None,
|
||||||
|
|||||||
Reference in New Issue
Block a user