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 28s
CI / test-python-klausur (push) Failing after 2m24s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 20s
Phase 1 of the clean architecture refactor: Replaces the 751-line ocr-overlay monolith with a modular pipeline. Each step gets its own component file. Frontend: /ai/ocr-kombi route with 11 steps (Upload, Orientation, PageSplit, Deskew, Dewarp, ContentCrop, OCR, Structure, GridBuild, GridReview, GroundTruth). Session list supports document grouping for multi-page uploads. Backend: New ocr_kombi/ module with multi-page PDF upload (splits PDF into N sessions with shared document_group_id). DB migration adds document_group_id and page_number columns. Old /ai/ocr-overlay remains fully functional for A/B testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback } from 'react'
|
|
import { DOCUMENT_CATEGORIES, type DocumentCategory } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface StepUploadProps {
|
|
onUploaded: (sessionId: string) => void
|
|
}
|
|
|
|
export function StepUpload({ onUploaded }: StepUploadProps) {
|
|
const [dragging, setDragging] = useState(false)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [title, setTitle] = useState('')
|
|
const [category, setCategory] = useState<DocumentCategory>('vokabelseite')
|
|
const [error, setError] = useState('')
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
setUploading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
if (title.trim()) formData.append('name', title.trim())
|
|
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data.detail || `Upload fehlgeschlagen (${res.status})`)
|
|
}
|
|
|
|
const data = await res.json()
|
|
const sid = data.session_id || data.id
|
|
|
|
// Set category
|
|
if (category) {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sid}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ document_category: category }),
|
|
})
|
|
}
|
|
|
|
onUploaded(sid)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}, [title, category, onUploaded])
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDragging(false)
|
|
const file = e.dataTransfer.files[0]
|
|
if (file) handleUpload(file)
|
|
}, [handleUpload])
|
|
|
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleUpload(file)
|
|
}, [handleUpload])
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Title input */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Titel (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="z.B. Vokabeln Unit 3"
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category selector */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Kategorie
|
|
</label>
|
|
<div className="grid grid-cols-4 gap-1.5">
|
|
{DOCUMENT_CATEGORIES.map(cat => (
|
|
<button
|
|
key={cat.value}
|
|
onClick={() => setCategory(cat.value)}
|
|
className={`text-xs px-2 py-1.5 rounded-md text-left transition-colors ${
|
|
category === cat.value
|
|
? 'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300 ring-1 ring-teal-400'
|
|
: 'bg-gray-50 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
{cat.icon} {cat.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drop zone */}
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={handleDrop}
|
|
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
|
|
dragging
|
|
? 'border-teal-400 bg-teal-50 dark:bg-teal-900/20'
|
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
{uploading ? (
|
|
<div className="text-sm text-gray-500">Wird hochgeladen...</div>
|
|
) : (
|
|
<>
|
|
<div className="text-4xl mb-3">📤</div>
|
|
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
Bild oder PDF hierher ziehen
|
|
</div>
|
|
<label className="inline-block px-4 py-2 bg-teal-600 text-white text-sm rounded-lg cursor-pointer hover:bg-teal-700">
|
|
Datei auswaehlen
|
|
<input
|
|
type="file"
|
|
accept="image/*,.pdf"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|