- LLM Compare Seiten, Configs und alle Referenzen geloescht - Kommunikation-Kategorie in Sidebar mit Video & Chat, Voice Service, Alerts - Compliance SDK Kategorie aus Sidebar entfernt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1638 lines
69 KiB
TypeScript
1638 lines
69 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* OCR Comparison Tool
|
|
*
|
|
* Zeigt Original-PDF neben den Extraktionsergebnissen von verschiedenen OCR-Methoden.
|
|
* Ermoeglicht direkten visuellen Vergleich mit voller Breite.
|
|
* Bietet Session-Historie fuer Verbesserungsvergleiche.
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
|
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
|
import { GridOverlay, GridStats, GridLegend, CellCorrectionDialog, BlockReviewPanel, BlockReviewSummary, getCellBlockNumber, GroundTruthPanel } from '@/components/ocr'
|
|
import type { GridData, GridCell, BlockReviewData, BlockStatus } from '@/components/ocr'
|
|
|
|
interface VocabEntry {
|
|
english: string
|
|
german: string
|
|
example?: string
|
|
}
|
|
|
|
interface MethodResult {
|
|
name: string
|
|
model: string
|
|
duration_seconds: number
|
|
vocabulary_count: number
|
|
vocabulary: VocabEntry[]
|
|
confidence: number
|
|
error?: string
|
|
success: boolean
|
|
}
|
|
|
|
interface ComparisonResult {
|
|
session_id: string
|
|
page_number: number
|
|
methods: Record<string, MethodResult>
|
|
comparison: {
|
|
found_by_all_methods: Array<{ english: string; german: string; methods: string[] }>
|
|
found_by_some_methods: Array<{ english: string; german: string; methods: string[] }>
|
|
total_unique_vocabulary: number
|
|
agreement_rate: number
|
|
}
|
|
recommendation: {
|
|
best_method: string
|
|
reason: string
|
|
}
|
|
}
|
|
|
|
interface SessionInfo {
|
|
id: string
|
|
name: string
|
|
created_at: string
|
|
page_count?: number
|
|
}
|
|
|
|
// OCR-Methoden Konfiguration
|
|
const OCR_METHODS = {
|
|
local_llm: {
|
|
id: 'local_llm',
|
|
name: 'Loesung A: Lokales 32B LLM',
|
|
shortName: 'A: Local LLM',
|
|
model: 'qwen2.5:32b extern',
|
|
color: 'slate',
|
|
description: 'Externes 32B LLM',
|
|
enabled: true,
|
|
},
|
|
vision_llm: {
|
|
id: 'vision_llm',
|
|
name: 'Loesung B: Vision LLM',
|
|
shortName: 'B: Vision LLM',
|
|
model: 'qwen2.5vl:32b',
|
|
color: 'blue',
|
|
description: 'Direkte Bild-zu-Text Extraktion',
|
|
enabled: true,
|
|
},
|
|
paddleocr: {
|
|
id: 'paddleocr',
|
|
name: 'Loesung C: PaddleOCR',
|
|
shortName: 'C: PaddleOCR',
|
|
model: 'paddleocr (x86)',
|
|
color: 'red',
|
|
description: 'Aktuell deaktiviert (Rosetta)',
|
|
enabled: false,
|
|
},
|
|
tesseract: {
|
|
id: 'tesseract',
|
|
name: 'Loesung D: Tesseract',
|
|
shortName: 'D: Tesseract',
|
|
model: 'tesseract + qwen2.5:14b',
|
|
color: 'purple',
|
|
description: 'ARM64-nativ, Standard',
|
|
enabled: true,
|
|
},
|
|
cv_pipeline: {
|
|
id: 'cv_pipeline',
|
|
name: 'Loesung E: Document Reconstruction',
|
|
shortName: 'E: Doc Recon',
|
|
model: 'opencv + tesseract (multi-pass)',
|
|
color: 'green',
|
|
description: 'CV-Pipeline: Deskew, Dewarp, Binarisierung, Multi-Pass OCR',
|
|
enabled: true,
|
|
},
|
|
}
|
|
|
|
export default function OCRComparePage() {
|
|
// Session State
|
|
const [sessionId, setSessionId] = useState<string | null>(null)
|
|
const [pageCount, setPageCount] = useState(0)
|
|
const [selectedPage, setSelectedPage] = useState(0)
|
|
const [thumbnails, setThumbnails] = useState<string[]>([])
|
|
const [loadingThumbnails, setLoadingThumbnails] = useState(false)
|
|
|
|
// Session History
|
|
const [sessions, setSessions] = useState<SessionInfo[]>([])
|
|
const [loadingSessions, setLoadingSessions] = useState(false)
|
|
const [showHistory, setShowHistory] = useState(false)
|
|
|
|
// Comparison State
|
|
const [comparing, setComparing] = useState(false)
|
|
const [result, setResult] = useState<ComparisonResult | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
// Method Selection
|
|
const [selectedMethods, setSelectedMethods] = useState<string[]>(['vision_llm', 'tesseract', 'cv_pipeline'])
|
|
|
|
// QR Upload State
|
|
const [showQRModal, setShowQRModal] = useState(false)
|
|
const [qrUploadSessionId, setQrUploadSessionId] = useState('')
|
|
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
|
|
|
// View Mode State
|
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
const [expandedMethod, setExpandedMethod] = useState<string | null>(null) // For single document view
|
|
const [visibleMethods, setVisibleMethods] = useState<string[]>([]) // For custom multi-column view
|
|
|
|
// Grid Detection State
|
|
const [gridData, setGridData] = useState<GridData | null>(null)
|
|
const [analyzingGrid, setAnalyzingGrid] = useState(false)
|
|
const [showGridOverlay, setShowGridOverlay] = useState(true)
|
|
const [selectedCell, setSelectedCell] = useState<GridCell | null>(null)
|
|
const [showCellDialog, setShowCellDialog] = useState(false)
|
|
const [showMmGrid, setShowMmGrid] = useState(false)
|
|
const [showTextAtPosition, setShowTextAtPosition] = useState(false)
|
|
const [editableText, setEditableText] = useState(false)
|
|
|
|
// Block Review State
|
|
const [blockReviewMode, setBlockReviewMode] = useState(false)
|
|
const [currentBlockNumber, setCurrentBlockNumber] = useState(1)
|
|
const [blockReviewData, setBlockReviewData] = useState<Record<number, BlockReviewData>>({})
|
|
|
|
// Export State
|
|
const [isExporting, setIsExporting] = useState(false)
|
|
const [exportSuccess, setExportSuccess] = useState(false)
|
|
|
|
// Tab State (compare vs ground truth)
|
|
const [activeTab, setActiveTab] = useState<'compare' | 'groundtruth'>('compare')
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
// Load session history
|
|
const loadSessions = useCallback(async () => {
|
|
setLoadingSessions(true)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
// Filter to only show OCR Vergleich sessions and sort by date
|
|
const ocrSessions = (data.sessions || data || [])
|
|
.filter((s: SessionInfo) => s.name?.includes('OCR Vergleich'))
|
|
.sort((a: SessionInfo, b: SessionInfo) =>
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
)
|
|
.slice(0, 20) // Limit to 20 most recent
|
|
setSessions(ocrSessions)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load sessions:', e)
|
|
} finally {
|
|
setLoadingSessions(false)
|
|
}
|
|
}, [])
|
|
|
|
// Initialize and restore session
|
|
useEffect(() => {
|
|
loadSessions()
|
|
|
|
let sid = localStorage.getItem('ocr-compare-upload-session')
|
|
if (!sid) {
|
|
sid = `ocr-compare-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
localStorage.setItem('ocr-compare-upload-session', sid)
|
|
}
|
|
setQrUploadSessionId(sid)
|
|
|
|
// Restore last active session if available
|
|
const lastSessionId = localStorage.getItem('ocr-compare-active-session')
|
|
if (lastSessionId) {
|
|
// Load the session data
|
|
fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${lastSessionId}`)
|
|
.then(res => {
|
|
if (res.ok) return res.json()
|
|
throw new Error('Session not found')
|
|
})
|
|
.then(data => {
|
|
setSessionId(lastSessionId)
|
|
setPageCount(data.page_count || 1)
|
|
setSelectedPage(0)
|
|
loadAllThumbnails(lastSessionId, data.page_count || 1)
|
|
})
|
|
.catch(() => {
|
|
// Session no longer exists, clear localStorage
|
|
localStorage.removeItem('ocr-compare-active-session')
|
|
})
|
|
}
|
|
}, [loadSessions])
|
|
|
|
// ESC key to exit fullscreen
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (expandedMethod) {
|
|
setExpandedMethod(null)
|
|
} else if (isFullscreen) {
|
|
setIsFullscreen(false)
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [isFullscreen, expandedMethod])
|
|
|
|
// Load a session from history
|
|
const loadSession = async (session: SessionInfo) => {
|
|
setSessionId(session.id)
|
|
localStorage.setItem('ocr-compare-active-session', session.id)
|
|
setResult(null)
|
|
setThumbnails([])
|
|
|
|
try {
|
|
// Get session details
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${session.id}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setPageCount(data.page_count || 1)
|
|
setSelectedPage(0)
|
|
|
|
// Load thumbnails
|
|
await loadAllThumbnails(session.id, data.page_count || 1)
|
|
}
|
|
} catch (e) {
|
|
setError('Session konnte nicht geladen werden')
|
|
}
|
|
}
|
|
|
|
// Handle mobile file upload
|
|
const handleMobileFile = useCallback(async (file: UploadedFile) => {
|
|
if (!file.dataUrl) return
|
|
|
|
setUploading(true)
|
|
setError(null)
|
|
setResult(null)
|
|
setThumbnails([])
|
|
|
|
try {
|
|
// Create session
|
|
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: `OCR Vergleich - ${file.name}` })
|
|
})
|
|
|
|
if (!sessionRes.ok) throw new Error('Session konnte nicht erstellt werden')
|
|
const sessionData = await sessionRes.json()
|
|
setSessionId(sessionData.id)
|
|
localStorage.setItem('ocr-compare-active-session', sessionData.id)
|
|
|
|
// Convert dataUrl to blob and upload
|
|
const response = await fetch(file.dataUrl)
|
|
const blob = await response.blob()
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', blob, file.name)
|
|
|
|
const uploadRes = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`,
|
|
{ method: 'POST', body: formData }
|
|
)
|
|
|
|
if (!uploadRes.ok) throw new Error('PDF Upload fehlgeschlagen')
|
|
const uploadData = await uploadRes.json()
|
|
setPageCount(uploadData.page_count || 1)
|
|
setSelectedPage(0)
|
|
|
|
// Load thumbnails
|
|
await loadAllThumbnails(sessionData.id, uploadData.page_count || 1)
|
|
|
|
// Refresh session list
|
|
loadSessions()
|
|
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}, [loadSessions])
|
|
|
|
// Watch for new mobile files
|
|
useEffect(() => {
|
|
if (mobileUploadedFiles.length > 0) {
|
|
const latestFile = mobileUploadedFiles[mobileUploadedFiles.length - 1]
|
|
handleMobileFile(latestFile)
|
|
setShowQRModal(false)
|
|
}
|
|
}, [mobileUploadedFiles, handleMobileFile])
|
|
|
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
|
|
setUploading(true)
|
|
setError(null)
|
|
setResult(null)
|
|
setThumbnails([])
|
|
|
|
try {
|
|
// Create session
|
|
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: `OCR Vergleich - ${file.name}` })
|
|
})
|
|
|
|
if (!sessionRes.ok) throw new Error('Session konnte nicht erstellt werden')
|
|
const sessionData = await sessionRes.json()
|
|
setSessionId(sessionData.id)
|
|
localStorage.setItem('ocr-compare-active-session', sessionData.id)
|
|
|
|
// Upload PDF
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const uploadRes = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`,
|
|
{ method: 'POST', body: formData }
|
|
)
|
|
|
|
if (!uploadRes.ok) throw new Error('PDF Upload fehlgeschlagen')
|
|
const uploadData = await uploadRes.json()
|
|
setPageCount(uploadData.page_count || 1)
|
|
setSelectedPage(0)
|
|
|
|
// Load all thumbnails
|
|
await loadAllThumbnails(sessionData.id, uploadData.page_count || 1)
|
|
|
|
// Refresh session list
|
|
loadSessions()
|
|
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
const loadAllThumbnails = async (sid: string, count: number) => {
|
|
setLoadingThumbnails(true)
|
|
const thumbs: string[] = []
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sid}/pdf-thumbnail/${i}?hires=true`)
|
|
if (res.ok) {
|
|
const blob = await res.blob()
|
|
thumbs.push(URL.createObjectURL(blob))
|
|
} else {
|
|
thumbs.push('')
|
|
}
|
|
} catch {
|
|
thumbs.push('')
|
|
}
|
|
}
|
|
|
|
setThumbnails(thumbs)
|
|
setLoadingThumbnails(false)
|
|
}
|
|
|
|
const toggleMethod = (methodId: string) => {
|
|
setSelectedMethods(prev =>
|
|
prev.includes(methodId)
|
|
? prev.filter(m => m !== methodId)
|
|
: [...prev, methodId]
|
|
)
|
|
}
|
|
|
|
const runComparison = async () => {
|
|
if (!sessionId || selectedMethods.length === 0) return
|
|
|
|
setComparing(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/compare-ocr/${selectedPage}`,
|
|
{ method: 'POST' }
|
|
)
|
|
|
|
if (!res.ok) throw new Error(`Vergleich fehlgeschlagen: ${res.status}`)
|
|
const data = await res.json()
|
|
setResult(data)
|
|
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Vergleich fehlgeschlagen')
|
|
} finally {
|
|
setComparing(false)
|
|
}
|
|
}
|
|
|
|
// Grid Analysis
|
|
const analyzeGrid = async () => {
|
|
if (!sessionId) return
|
|
|
|
setAnalyzingGrid(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/analyze-grid/${selectedPage}`,
|
|
{ method: 'POST' }
|
|
)
|
|
|
|
if (!res.ok) throw new Error(`Grid-Analyse fehlgeschlagen: ${res.status}`)
|
|
const data = await res.json()
|
|
|
|
if (data.success && data.grid) {
|
|
setGridData(data.grid)
|
|
} else {
|
|
setError(data.error || 'Grid-Erkennung fehlgeschlagen')
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Grid-Analyse fehlgeschlagen')
|
|
} finally {
|
|
setAnalyzingGrid(false)
|
|
}
|
|
}
|
|
|
|
// Handle cell click for correction
|
|
const handleCellClick = useCallback((cell: GridCell) => {
|
|
setSelectedCell(cell)
|
|
setShowCellDialog(true)
|
|
}, [])
|
|
|
|
// Handle cell save
|
|
const handleCellSave = useCallback((text: string) => {
|
|
if (!gridData || !selectedCell) return
|
|
|
|
// Update local grid data
|
|
const updatedCells = gridData.cells.map(row =>
|
|
row.map(cell =>
|
|
cell.row === selectedCell.row && cell.col === selectedCell.col
|
|
? { ...cell, text, status: 'manual' as const, confidence: 1.0 }
|
|
: cell
|
|
)
|
|
)
|
|
|
|
// Recalculate stats
|
|
const recognized = updatedCells.flat().filter(c => c.status === 'recognized').length
|
|
const manual = updatedCells.flat().filter(c => c.status === 'manual').length
|
|
const problematic = updatedCells.flat().filter(c => c.status === 'problematic').length
|
|
const total = updatedCells.flat().length
|
|
|
|
setGridData({
|
|
...gridData,
|
|
cells: updatedCells,
|
|
stats: {
|
|
...gridData.stats,
|
|
recognized,
|
|
manual,
|
|
problematic,
|
|
empty: total - recognized - manual - problematic,
|
|
coverage: (recognized + manual) / total
|
|
}
|
|
})
|
|
|
|
setShowCellDialog(false)
|
|
setSelectedCell(null)
|
|
}, [gridData, selectedCell])
|
|
|
|
// Block Review Handlers
|
|
const handleBlockApprove = useCallback((blockNumber: number, methodId: string, text: string) => {
|
|
if (!gridData) return
|
|
|
|
const cell = gridData.cells.flat().find(c => getCellBlockNumber(c, gridData) === blockNumber)
|
|
if (!cell) return
|
|
|
|
setBlockReviewData(prev => ({
|
|
...prev,
|
|
[blockNumber]: {
|
|
blockNumber,
|
|
cell,
|
|
methodResults: [],
|
|
status: 'approved' as BlockStatus,
|
|
correctedText: text,
|
|
approvedMethodId: methodId,
|
|
}
|
|
}))
|
|
}, [gridData])
|
|
|
|
const handleBlockCorrect = useCallback((blockNumber: number, correctedText: string) => {
|
|
if (!gridData) return
|
|
|
|
const cell = gridData.cells.flat().find(c => getCellBlockNumber(c, gridData) === blockNumber)
|
|
if (!cell) return
|
|
|
|
setBlockReviewData(prev => ({
|
|
...prev,
|
|
[blockNumber]: {
|
|
blockNumber,
|
|
cell,
|
|
methodResults: [],
|
|
status: 'corrected' as BlockStatus,
|
|
correctedText,
|
|
}
|
|
}))
|
|
}, [gridData])
|
|
|
|
const handleBlockSkip = useCallback((blockNumber: number) => {
|
|
if (!gridData) return
|
|
|
|
const cell = gridData.cells.flat().find(c => getCellBlockNumber(c, gridData) === blockNumber)
|
|
if (!cell) return
|
|
|
|
setBlockReviewData(prev => ({
|
|
...prev,
|
|
[blockNumber]: {
|
|
blockNumber,
|
|
cell,
|
|
methodResults: [],
|
|
status: 'skipped' as BlockStatus,
|
|
}
|
|
}))
|
|
}, [gridData])
|
|
|
|
// Start block review mode
|
|
const startBlockReview = useCallback(() => {
|
|
if (!gridData) return
|
|
|
|
// Find first non-empty block
|
|
const firstBlock = gridData.cells.flat().find(c => c.status !== 'empty')
|
|
if (firstBlock) {
|
|
setCurrentBlockNumber(getCellBlockNumber(firstBlock, gridData))
|
|
setBlockReviewMode(true)
|
|
}
|
|
}, [gridData])
|
|
|
|
// Export to Worksheet Editor
|
|
const handleExportToEditor = useCallback(async () => {
|
|
if (!gridData || !sessionId) return
|
|
|
|
setIsExporting(true)
|
|
setExportSuccess(false)
|
|
|
|
try {
|
|
// Convert grid cells (percent coordinates) to mm for A4
|
|
const A4_WIDTH_MM = 210
|
|
const A4_HEIGHT_MM = 297
|
|
|
|
const words = gridData.cells.flat()
|
|
.filter(cell => cell.status !== 'empty' && cell.text)
|
|
.map(cell => ({
|
|
text: cell.text,
|
|
x_mm: (cell.x / 100) * A4_WIDTH_MM,
|
|
y_mm: (cell.y / 100) * A4_HEIGHT_MM,
|
|
width_mm: (cell.width / 100) * A4_WIDTH_MM,
|
|
height_mm: (cell.height / 100) * A4_HEIGHT_MM,
|
|
column_type: cell.column_type || 'unknown',
|
|
logical_row: cell.row,
|
|
confidence: cell.confidence,
|
|
}))
|
|
|
|
const detectedColumns = gridData.column_types.map((type, idx) => ({
|
|
column_type: type,
|
|
x_start_mm: (gridData.column_boundaries[idx] / 100) * A4_WIDTH_MM,
|
|
x_end_mm: (gridData.column_boundaries[idx + 1] / 100) * A4_WIDTH_MM,
|
|
}))
|
|
|
|
const exportData = {
|
|
version: '1.0',
|
|
source: 'ocr-compare',
|
|
exported_at: new Date().toISOString(),
|
|
session_id: sessionId,
|
|
page_number: selectedPage + 1,
|
|
page_dimensions: {
|
|
width_mm: A4_WIDTH_MM,
|
|
height_mm: A4_HEIGHT_MM,
|
|
format: 'A4',
|
|
},
|
|
words,
|
|
detected_columns: detectedColumns,
|
|
}
|
|
|
|
const res = await fetch(
|
|
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ocr-export/${selectedPage + 1}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(exportData),
|
|
}
|
|
)
|
|
|
|
if (res.ok) {
|
|
setExportSuccess(true)
|
|
setTimeout(() => setExportSuccess(false), 3000)
|
|
}
|
|
} catch (e) {
|
|
console.error('Export failed:', e)
|
|
} finally {
|
|
setIsExporting(false)
|
|
}
|
|
}, [gridData, sessionId, selectedPage, KLAUSUR_API])
|
|
|
|
// Count non-empty blocks
|
|
const nonEmptyBlockCount = useMemo(() => {
|
|
if (!gridData) return 0
|
|
return gridData.cells.flat().filter(c => c.status !== 'empty').length
|
|
}, [gridData])
|
|
|
|
const VocabList = ({ vocab, highlight }: { vocab: VocabEntry[]; highlight?: Set<string> }) => (
|
|
<div className="space-y-1 max-h-[500px] overflow-y-auto">
|
|
{vocab.map((v, idx) => {
|
|
const key = `${v.english}|${v.german}`
|
|
const isUnique = highlight?.has(key)
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={`p-2 rounded text-sm ${
|
|
isUnique ? 'bg-yellow-100 border border-yellow-300' : 'bg-white border border-slate-200'
|
|
}`}
|
|
>
|
|
<div className="font-medium text-slate-900">{v.english}</div>
|
|
<div className="text-slate-600">{v.german}</div>
|
|
{v.example && (
|
|
<div className="text-xs text-slate-500 mt-1 italic">{v.example}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
|
|
const getUniqueVocab = (methodKey: string): Set<string> => {
|
|
if (!result?.comparison?.found_by_some_methods) return new Set()
|
|
const unique = new Set<string>()
|
|
result.comparison.found_by_some_methods.forEach(v => {
|
|
if (v.methods.includes(methodKey) && v.methods.length === 1) {
|
|
unique.add(`${v.english}|${v.german}`)
|
|
}
|
|
})
|
|
return unique
|
|
}
|
|
|
|
const getMethodColor = (color: string, type: 'bg' | 'border' | 'text') => {
|
|
const colors: Record<string, Record<string, string>> = {
|
|
slate: { bg: 'bg-slate-50', border: 'border-slate-300', text: 'text-slate-700' },
|
|
blue: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
|
|
red: { bg: 'bg-red-50', border: 'border-red-300', text: 'text-red-700' },
|
|
purple: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
|
|
green: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' },
|
|
}
|
|
return colors[color]?.[type] || colors.slate[type]
|
|
}
|
|
|
|
// Anzahl der ausgewaehlten Methoden + 1 fuer das Original
|
|
const columnCount = selectedMethods.length + 1
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<PagePurpose
|
|
title="OCR Vergleich"
|
|
purpose="Visueller Vergleich verschiedener OCR-Methoden. Laden Sie ein PDF hoch und sehen Sie das Original neben den Extraktionsergebnissen. Nutzen Sie die Session-Historie um Verbesserungen zu vergleichen."
|
|
audience={['Entwickler', 'Lehrkraefte', 'QA']}
|
|
architecture={{
|
|
services: ['klausur-service', 'Ollama (qwen2.5vl:32b)', 'Tesseract OCR'],
|
|
databases: ['PostgreSQL (Sessions)'],
|
|
}}
|
|
relatedPages={[
|
|
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Ground Truth erstellen' },
|
|
]}
|
|
collapsible={true}
|
|
defaultCollapsed={true}
|
|
/>
|
|
|
|
{/* KI-Werkzeuge Sidebar */}
|
|
<AIToolsSidebarResponsive currentTool="ocr-compare" />
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Left Sidebar: Upload & History */}
|
|
<div className="lg:col-span-1 space-y-4">
|
|
{/* Upload Section */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
<h2 className="font-semibold text-slate-900 mb-3">PDF hochladen</h2>
|
|
|
|
<input
|
|
type="file"
|
|
accept=".pdf"
|
|
onChange={handleFileUpload}
|
|
disabled={uploading}
|
|
className="w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-teal-50 file:text-teal-700 hover:file:bg-teal-100 disabled:opacity-50 mb-3"
|
|
/>
|
|
|
|
<button
|
|
onClick={() => setShowQRModal(true)}
|
|
className="w-full px-4 py-2 bg-purple-100 text-purple-700 rounded-lg text-sm font-medium hover:bg-purple-200 flex items-center justify-center gap-2"
|
|
>
|
|
<span>📱</span>
|
|
Mit Handy hochladen
|
|
</button>
|
|
|
|
{uploading && (
|
|
<div className="mt-3 flex items-center gap-2 text-slate-600 text-sm">
|
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Wird hochgeladen...
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded-lg text-red-700 text-xs">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Session History Panel */}
|
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
<button
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50"
|
|
>
|
|
<span className="font-semibold text-slate-900">
|
|
Session-Historie ({sessions.length})
|
|
</span>
|
|
<svg
|
|
className={`w-5 h-5 transition-transform ${showHistory ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{showHistory && (
|
|
<div className="border-t border-slate-200 max-h-96 overflow-y-auto">
|
|
{loadingSessions ? (
|
|
<div className="p-4 text-center text-slate-500 text-sm">
|
|
<svg className="animate-spin w-5 h-5 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Lade Sessions...
|
|
</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="p-4 text-center text-slate-500 text-sm">
|
|
Keine Sessions vorhanden
|
|
</div>
|
|
) : (
|
|
sessions.map(session => (
|
|
<button
|
|
key={session.id}
|
|
onClick={() => loadSession(session)}
|
|
className={`w-full px-4 py-3 text-left hover:bg-slate-50 border-b border-slate-100 last:border-0 transition-colors ${
|
|
sessionId === session.id ? 'bg-teal-50' : ''
|
|
}`}
|
|
>
|
|
<div className="text-sm text-slate-700 truncate">
|
|
{session.name?.replace('OCR Vergleich - ', '') || 'Unbenannt'}
|
|
</div>
|
|
<div className="text-xs text-slate-400 mt-0.5">
|
|
{new Date(session.created_at).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</div>
|
|
{sessionId === session.id && (
|
|
<span className="inline-block mt-1 px-2 py-0.5 bg-teal-200 text-teal-800 text-xs rounded">
|
|
Aktiv
|
|
</span>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Method Selection */}
|
|
{sessionId && pageCount > 0 && (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
<h3 className="font-semibold text-slate-900 mb-3 text-sm">OCR-Methoden</h3>
|
|
|
|
<div className="space-y-2">
|
|
{Object.values(OCR_METHODS).map(method => (
|
|
<label
|
|
key={method.id}
|
|
className={`flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors text-sm ${
|
|
!method.enabled ? 'opacity-50 cursor-not-allowed' : ''
|
|
} ${
|
|
selectedMethods.includes(method.id)
|
|
? `border-2 ${getMethodColor(method.color, 'border')} ${getMethodColor(method.color, 'bg')}`
|
|
: 'border-slate-200 hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedMethods.includes(method.id)}
|
|
onChange={() => method.enabled && toggleMethod(method.id)}
|
|
disabled={!method.enabled}
|
|
className="rounded"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-slate-900">{method.shortName}</div>
|
|
<div className="text-xs text-slate-500 truncate">{method.model}</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-2">
|
|
<button
|
|
onClick={runComparison}
|
|
disabled={comparing || selectedMethods.length === 0}
|
|
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
>
|
|
{comparing ? (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Vergleiche...
|
|
</span>
|
|
) : (
|
|
'Vergleich starten'
|
|
)}
|
|
</button>
|
|
|
|
{/* Grid Analysis Button */}
|
|
<button
|
|
onClick={analyzeGrid}
|
|
disabled={analyzingGrid || !sessionId || !result}
|
|
className="w-full px-4 py-2 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
>
|
|
{analyzingGrid ? (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Analysiere Grid...
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
</svg>
|
|
Grid analysieren
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Grid Overlay Toggle */}
|
|
{gridData && (
|
|
<div className="mt-4 pt-4 border-t border-slate-200 space-y-3">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={showGridOverlay}
|
|
onChange={(e) => setShowGridOverlay(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-slate-700">Grid-Overlay anzeigen</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={showMmGrid}
|
|
onChange={(e) => setShowMmGrid(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-slate-700">1mm Raster anzeigen</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={showTextAtPosition}
|
|
onChange={(e) => {
|
|
setShowTextAtPosition(e.target.checked)
|
|
if (!e.target.checked) setEditableText(false)
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-slate-700">Text an Originalposition</span>
|
|
</label>
|
|
|
|
{showTextAtPosition && (
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer ml-5">
|
|
<input
|
|
type="checkbox"
|
|
checked={editableText}
|
|
onChange={(e) => setEditableText(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-slate-700">Text bearbeitbar</span>
|
|
</label>
|
|
)}
|
|
|
|
{/* Block Review Button */}
|
|
{result && nonEmptyBlockCount > 0 && (
|
|
<button
|
|
onClick={() => blockReviewMode ? setBlockReviewMode(false) : startBlockReview()}
|
|
className={`w-full px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
|
|
blockReviewMode
|
|
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200'
|
|
: 'bg-indigo-600 text-white hover:bg-indigo-700'
|
|
}`}
|
|
>
|
|
{blockReviewMode ? (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
Block-Review beenden
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center justify-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>
|
|
Block-Review starten ({nonEmptyBlockCount} Blöcke)
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Export to Editor Button */}
|
|
<button
|
|
onClick={handleExportToEditor}
|
|
disabled={isExporting}
|
|
className={`w-full px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
|
|
exportSuccess
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-green-600 text-white hover:bg-green-700'
|
|
}`}
|
|
>
|
|
<span className="flex items-center justify-center gap-2">
|
|
{isExporting ? (
|
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
) : exportSuccess ? (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
)}
|
|
{exportSuccess ? 'Exportiert!' : 'Zum Editor exportieren'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Grid Stats */}
|
|
{gridData && (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
<h3 className="font-semibold text-slate-900 mb-3 text-sm">Grid-Erkennung</h3>
|
|
<GridStats stats={gridData.stats} deskewAngle={gridData.deskew_angle} />
|
|
<div className="mt-3">
|
|
<GridLegend className="text-xs" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Block Review Summary */}
|
|
{blockReviewMode && gridData && Object.keys(blockReviewData).length > 0 && (
|
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
|
<BlockReviewSummary
|
|
reviewData={blockReviewData}
|
|
totalBlocks={nonEmptyBlockCount}
|
|
onBlockClick={(blockNumber) => setCurrentBlockNumber(blockNumber)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="lg:col-span-3 space-y-4">
|
|
{/* Page Thumbnails Grid */}
|
|
{sessionId && pageCount > 0 && (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
|
<h3 className="font-semibold text-slate-900 mb-3">
|
|
Seite auswaehlen ({pageCount} Seiten)
|
|
</h3>
|
|
|
|
{loadingThumbnails ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<svg className="animate-spin w-8 h-8 text-teal-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<span className="ml-3 text-slate-600">Lade Seitenvorschau...</span>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-2">
|
|
{thumbnails.map((thumb, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => {
|
|
setSelectedPage(idx)
|
|
setResult(null)
|
|
setGridData(null) // Reset grid when changing page
|
|
setSelectedCell(null)
|
|
}}
|
|
className={`relative group rounded-lg overflow-hidden border-2 transition-all ${
|
|
selectedPage === idx
|
|
? 'border-teal-500 ring-2 ring-teal-200'
|
|
: 'border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
{thumb ? (
|
|
<img
|
|
src={thumb}
|
|
alt={`Seite ${idx + 1}`}
|
|
className="w-full aspect-[3/4] object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full aspect-[3/4] bg-slate-100 flex items-center justify-center">
|
|
<span className="text-slate-400 text-xs">?</span>
|
|
</div>
|
|
)}
|
|
<div className={`absolute bottom-0 left-0 right-0 py-0.5 text-center text-xs font-medium ${
|
|
selectedPage === idx
|
|
? 'bg-teal-600 text-white'
|
|
: 'bg-black/50 text-white'
|
|
}`}>
|
|
{idx + 1}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab Bar */}
|
|
{sessionId && pageCount > 0 && (
|
|
<div className="flex gap-1 bg-slate-100 rounded-lg p-1">
|
|
<button
|
|
onClick={() => setActiveTab('compare')}
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === 'compare'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
OCR Vergleich
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('groundtruth')}
|
|
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === 'groundtruth'
|
|
? 'bg-white text-slate-900 shadow-sm'
|
|
: 'text-slate-600 hover:text-slate-900'
|
|
}`}
|
|
>
|
|
Ground Truth
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ground Truth Panel */}
|
|
{activeTab === 'groundtruth' && sessionId && (
|
|
<GroundTruthPanel
|
|
sessionId={sessionId}
|
|
selectedPage={selectedPage}
|
|
pageImageUrl={`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/pdf-thumbnail/${selectedPage}?hires=true`}
|
|
/>
|
|
)}
|
|
|
|
{/* Full-Width Comparison View */}
|
|
{activeTab === 'compare' && (thumbnails[selectedPage] || result) && sessionId && (
|
|
<div className={`bg-white rounded-xl border border-slate-200 p-4 ${
|
|
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-slate-50' : ''
|
|
}`}>
|
|
{/* Header with Controls */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-slate-900">
|
|
Vergleich - Seite {selectedPage + 1}
|
|
</h3>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Layout Selector - only show after comparison */}
|
|
{result && (
|
|
<div className="flex items-center gap-1 bg-slate-100 rounded-lg p-1">
|
|
<span className="text-xs text-slate-500 px-2">Ansicht:</span>
|
|
{[1, 2, 3, 4].map(cols => (
|
|
<button
|
|
key={cols}
|
|
onClick={() => {
|
|
if (cols === 1) {
|
|
// Show selector for which single item to view
|
|
setExpandedMethod(null)
|
|
setVisibleMethods([])
|
|
} else {
|
|
setExpandedMethod(null)
|
|
// Auto-select first N methods
|
|
const methodsToShow = ['original', ...selectedMethods].slice(0, cols)
|
|
setVisibleMethods(methodsToShow)
|
|
}
|
|
}}
|
|
className={`w-8 h-8 flex items-center justify-center rounded text-sm font-medium transition-colors ${
|
|
(visibleMethods.length === cols || (cols === selectedMethods.length + 1 && visibleMethods.length === 0 && !expandedMethod))
|
|
? 'bg-indigo-600 text-white'
|
|
: 'text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
title={`${cols} ${cols === 1 ? 'Spalte' : 'Spalten'}`}
|
|
>
|
|
{cols}
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={() => {
|
|
setExpandedMethod(null)
|
|
setVisibleMethods([])
|
|
}}
|
|
className={`px-2 h-8 flex items-center justify-center rounded text-sm font-medium transition-colors ${
|
|
visibleMethods.length === 0 && !expandedMethod
|
|
? 'bg-indigo-600 text-white'
|
|
: 'text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
title="Alle anzeigen"
|
|
>
|
|
Alle
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Fullscreen Toggle */}
|
|
<button
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
|
|
title={isFullscreen ? 'Vollbild beenden (Esc)' : 'Vollbild'}
|
|
>
|
|
{isFullscreen ? (
|
|
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Single Method Expanded View */}
|
|
{expandedMethod && (
|
|
<div className="mb-4">
|
|
<button
|
|
onClick={() => setExpandedMethod(null)}
|
|
className="mb-3 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-lg text-sm text-slate-600 flex items-center gap-2"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Zurueck zur Uebersicht
|
|
</button>
|
|
|
|
{expandedMethod === 'original' ? (
|
|
<div className="bg-slate-50 rounded-xl border border-slate-200 overflow-hidden">
|
|
<div className="px-4 py-3 bg-slate-100 border-b border-slate-200">
|
|
<h4 className="font-semibold text-slate-900 text-lg">Original - Seite {selectedPage + 1}</h4>
|
|
</div>
|
|
<div className="p-4">
|
|
{thumbnails[selectedPage] ? (
|
|
gridData && showGridOverlay ? (
|
|
<GridOverlay
|
|
grid={gridData}
|
|
imageUrl={thumbnails[selectedPage]}
|
|
onCellClick={handleCellClick}
|
|
selectedCell={selectedCell}
|
|
showEmpty={false}
|
|
showNumbers={blockReviewMode}
|
|
showTextLabels={!showTextAtPosition}
|
|
showMmGrid={showMmGrid}
|
|
showTextAtPosition={showTextAtPosition}
|
|
editableText={editableText}
|
|
onCellTextChange={(cell, newText) => {
|
|
if (!gridData) return
|
|
const newCells = gridData.cells.map(row =>
|
|
row.map(c => c.row === cell.row && c.col === cell.col
|
|
? { ...c, text: newText, status: 'manual' as const }
|
|
: c
|
|
)
|
|
)
|
|
setGridData({ ...gridData, cells: newCells })
|
|
}}
|
|
highlightedBlockNumber={blockReviewMode ? currentBlockNumber : null}
|
|
className={`rounded-lg border border-slate-200 overflow-hidden ${isFullscreen ? 'max-h-[80vh] mx-auto' : 'w-full max-w-2xl mx-auto'}`}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={thumbnails[selectedPage]}
|
|
alt={`Seite ${selectedPage + 1}`}
|
|
className={`rounded-lg border border-slate-200 ${isFullscreen ? 'max-h-[80vh] mx-auto' : 'w-full max-w-2xl mx-auto'}`}
|
|
/>
|
|
)
|
|
) : (
|
|
<div className="h-96 bg-slate-100 rounded-lg flex items-center justify-center text-slate-500">
|
|
Kein Bild verfuegbar
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
(() => {
|
|
const method = OCR_METHODS[expandedMethod as keyof typeof OCR_METHODS]
|
|
const methodResult = result?.methods?.[expandedMethod]
|
|
const isBest = result?.recommendation?.best_method === expandedMethod
|
|
return (
|
|
<div className={`rounded-xl border overflow-hidden ${isBest ? 'border-green-500 border-2' : 'border-slate-200'}`}>
|
|
<div className={`px-4 py-3 border-b flex items-center justify-between ${isBest ? 'bg-green-50 border-green-200' : getMethodColor(method.color, 'bg')}`}>
|
|
<div>
|
|
<h4 className="font-semibold text-slate-900 text-lg">{method.name}</h4>
|
|
<p className="text-sm text-slate-500">{method.model}</p>
|
|
</div>
|
|
{isBest && (
|
|
<span className="px-3 py-1.5 bg-green-200 text-green-800 text-sm font-medium rounded">
|
|
Beste Methode
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="p-4">
|
|
{methodResult && (
|
|
<div className="max-w-4xl mx-auto">
|
|
<div className={`mb-4 p-4 rounded-lg ${getMethodColor(method.color, 'bg')}`}>
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
<div>
|
|
<div className="text-2xl font-bold text-slate-900">{methodResult.duration_seconds}s</div>
|
|
<div className={`text-sm ${getMethodColor(method.color, 'text')}`}>Dauer</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-slate-900">{methodResult.vocabulary_count}</div>
|
|
<div className={`text-sm ${getMethodColor(method.color, 'text')}`}>Vokabeln</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-slate-900">{(methodResult.confidence * 100).toFixed(0)}%</div>
|
|
<div className={`text-sm ${getMethodColor(method.color, 'text')}`}>Konfidenz</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{methodResult.vocabulary?.length > 0 && (
|
|
<div className={`${isFullscreen ? 'max-h-[60vh]' : 'max-h-[600px]'} overflow-y-auto`}>
|
|
<VocabList vocab={methodResult.vocabulary} highlight={getUniqueVocab(expandedMethod)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})()
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Grid View (Normal or Custom Selection) */}
|
|
{!expandedMethod && (
|
|
<div
|
|
className="grid gap-4"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${
|
|
visibleMethods.length > 0 ? visibleMethods.length : columnCount
|
|
}, minmax(0, 1fr))`
|
|
}}
|
|
>
|
|
{/* Original PDF Column */}
|
|
{(visibleMethods.length === 0 || visibleMethods.includes('original')) && (
|
|
<div
|
|
className="bg-slate-50 rounded-xl border border-slate-200 overflow-hidden cursor-pointer hover:border-slate-400 transition-colors group"
|
|
onClick={() => setExpandedMethod('original')}
|
|
>
|
|
<div className="px-4 py-3 bg-slate-100 border-b border-slate-200 flex items-center justify-between">
|
|
<div>
|
|
<h4 className="font-semibold text-slate-900">Original</h4>
|
|
<p className="text-xs text-slate-500">Seite {selectedPage + 1}</p>
|
|
</div>
|
|
<svg className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|
</svg>
|
|
</div>
|
|
<div className="p-3">
|
|
{thumbnails[selectedPage] ? (
|
|
<div className="relative">
|
|
{/* Show Grid Overlay if available */}
|
|
{gridData && showGridOverlay ? (
|
|
<GridOverlay
|
|
grid={gridData}
|
|
imageUrl={thumbnails[selectedPage]}
|
|
onCellClick={handleCellClick}
|
|
selectedCell={selectedCell}
|
|
showEmpty={false}
|
|
showNumbers={blockReviewMode}
|
|
showTextLabels={!blockReviewMode && !showTextAtPosition}
|
|
showMmGrid={showMmGrid}
|
|
showTextAtPosition={showTextAtPosition}
|
|
editableText={editableText}
|
|
onCellTextChange={(cell, newText) => {
|
|
if (!gridData) return
|
|
const newCells = gridData.cells.map(row =>
|
|
row.map(c => c.row === cell.row && c.col === cell.col
|
|
? { ...c, text: newText, status: 'manual' as const }
|
|
: c
|
|
)
|
|
)
|
|
setGridData({ ...gridData, cells: newCells })
|
|
}}
|
|
highlightedBlockNumber={blockReviewMode ? currentBlockNumber : null}
|
|
className="rounded-lg border border-slate-200 overflow-hidden"
|
|
/>
|
|
) : (
|
|
<img
|
|
src={thumbnails[selectedPage]}
|
|
alt={`Seite ${selectedPage + 1}`}
|
|
className="w-full rounded-lg border border-slate-200"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="h-64 bg-slate-100 rounded-lg flex items-center justify-center text-slate-500">
|
|
Kein Bild verfuegbar
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Method Result Columns */}
|
|
{selectedMethods
|
|
.filter(methodId => visibleMethods.length === 0 || visibleMethods.includes(methodId))
|
|
.map(methodId => {
|
|
const method = OCR_METHODS[methodId as keyof typeof OCR_METHODS]
|
|
const methodResult = result?.methods?.[methodId]
|
|
const isBest = result?.recommendation?.best_method === methodId
|
|
|
|
return (
|
|
<div
|
|
key={methodId}
|
|
className={`rounded-xl border overflow-hidden cursor-pointer hover:border-slate-400 transition-colors group ${
|
|
isBest ? 'border-green-500 border-2' : 'border-slate-200'
|
|
}`}
|
|
onClick={() => setExpandedMethod(methodId)}
|
|
>
|
|
<div className={`px-4 py-3 border-b flex items-center justify-between ${
|
|
isBest ? 'bg-green-50 border-green-200' : getMethodColor(method.color, 'bg')
|
|
}`}>
|
|
<div>
|
|
<h4 className="font-semibold text-slate-900">{method.shortName}</h4>
|
|
<p className="text-xs text-slate-500">{method.model}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isBest && (
|
|
<span className="px-2 py-1 bg-green-200 text-green-800 text-xs font-medium rounded">
|
|
Beste
|
|
</span>
|
|
)}
|
|
<svg className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div className="p-3">
|
|
{comparing && !methodResult && (
|
|
<div className="h-64 flex flex-col items-center justify-center text-slate-500">
|
|
<svg className="animate-spin w-8 h-8 mb-2" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Extrahiere...
|
|
</div>
|
|
)}
|
|
{methodResult && (
|
|
<div>
|
|
<div className={`mb-3 p-2 rounded-lg text-sm ${getMethodColor(method.color, 'bg')}`}>
|
|
<div className="flex justify-between">
|
|
<span className={getMethodColor(method.color, 'text')}>Dauer:</span>
|
|
<span className="font-medium">{methodResult.duration_seconds}s</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className={getMethodColor(method.color, 'text')}>Vokabeln:</span>
|
|
<span className="font-medium">{methodResult.vocabulary_count}</span>
|
|
</div>
|
|
{methodResult.error && (
|
|
<div className="text-red-600 text-xs mt-1">{methodResult.error}</div>
|
|
)}
|
|
</div>
|
|
{methodResult.vocabulary?.length > 0 && (
|
|
<VocabList
|
|
vocab={methodResult.vocabulary}
|
|
highlight={getUniqueVocab(methodId)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!comparing && !methodResult && (
|
|
<div className="h-64 bg-slate-50 rounded-lg flex items-center justify-center text-slate-500">
|
|
Noch keine Ergebnisse
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Method Selector Chips (for custom view) */}
|
|
{result && visibleMethods.length > 0 && visibleMethods.length < selectedMethods.length + 1 && (
|
|
<div className="mt-4 pt-4 border-t border-slate-200">
|
|
<div className="flex flex-wrap gap-2">
|
|
<span className="text-sm text-slate-500 mr-2">Methoden ein-/ausblenden:</span>
|
|
<button
|
|
onClick={() => setVisibleMethods(prev =>
|
|
prev.includes('original')
|
|
? prev.filter(m => m !== 'original')
|
|
: [...prev, 'original']
|
|
)}
|
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
|
visibleMethods.includes('original')
|
|
? 'bg-slate-600 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
Original
|
|
</button>
|
|
{selectedMethods.map(methodId => {
|
|
const method = OCR_METHODS[methodId as keyof typeof OCR_METHODS]
|
|
return (
|
|
<button
|
|
key={methodId}
|
|
onClick={() => setVisibleMethods(prev =>
|
|
prev.includes(methodId)
|
|
? prev.filter(m => m !== methodId)
|
|
: [...prev, methodId]
|
|
)}
|
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
|
visibleMethods.includes(methodId)
|
|
? 'bg-indigo-600 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
{method.shortName}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Block Review Panel */}
|
|
{blockReviewMode && gridData && result && (
|
|
<div className="mt-6 bg-white rounded-xl border-2 border-indigo-300 shadow-lg overflow-hidden">
|
|
<div className="bg-indigo-50 px-4 py-3 border-b border-indigo-200">
|
|
<h3 className="font-semibold text-indigo-900 flex items-center gap-2">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>
|
|
Block-Review
|
|
</h3>
|
|
<p className="text-sm text-indigo-600 mt-1">
|
|
Prüfen Sie jeden Block und wählen Sie die korrekte Erkennung oder korrigieren Sie manuell.
|
|
</p>
|
|
</div>
|
|
<BlockReviewPanel
|
|
grid={gridData}
|
|
methodResults={result.methods}
|
|
currentBlockNumber={currentBlockNumber}
|
|
onBlockChange={setCurrentBlockNumber}
|
|
onApprove={handleBlockApprove}
|
|
onCorrect={handleBlockCorrect}
|
|
onSkip={handleBlockSkip}
|
|
reviewData={blockReviewData}
|
|
className="h-[400px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Comparison Summary */}
|
|
{activeTab === 'compare' && result?.comparison && (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="font-semibold text-slate-900 mb-4">Vergleichszusammenfassung</h3>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="p-4 bg-slate-50 rounded-lg text-center">
|
|
<div className="text-2xl font-bold text-slate-900">
|
|
{result.comparison.total_unique_vocabulary}
|
|
</div>
|
|
<div className="text-sm text-slate-600">Gesamt eindeutig</div>
|
|
</div>
|
|
<div className="p-4 bg-green-50 rounded-lg text-center">
|
|
<div className="text-2xl font-bold text-green-700">
|
|
{result.comparison.found_by_all_methods?.length || 0}
|
|
</div>
|
|
<div className="text-sm text-green-600">Von allen erkannt</div>
|
|
</div>
|
|
<div className="p-4 bg-yellow-50 rounded-lg text-center">
|
|
<div className="text-2xl font-bold text-yellow-700">
|
|
{result.comparison.found_by_some_methods?.length || 0}
|
|
</div>
|
|
<div className="text-sm text-yellow-600">Unterschiede</div>
|
|
</div>
|
|
<div className="p-4 bg-blue-50 rounded-lg text-center">
|
|
<div className="text-2xl font-bold text-blue-700">
|
|
{(result.comparison.agreement_rate * 100).toFixed(0)}%
|
|
</div>
|
|
<div className="text-sm text-blue-600">Uebereinstimmung</div>
|
|
</div>
|
|
</div>
|
|
|
|
{result.recommendation && (
|
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-green-600 font-medium">Empfehlung:</span>
|
|
<span className="font-semibold text-green-800">
|
|
{OCR_METHODS[result.recommendation.best_method as keyof typeof OCR_METHODS]?.name || result.recommendation.best_method}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-green-700 mt-1">{result.recommendation.reason}</p>
|
|
</div>
|
|
)}
|
|
|
|
{result.comparison.found_by_some_methods?.length > 0 && (
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-2">
|
|
Unterschiede (gelb markiert):
|
|
</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
{result.comparison.found_by_some_methods.map((v, idx) => (
|
|
<div key={idx} className="p-2 bg-yellow-50 border border-yellow-200 rounded text-sm">
|
|
<span className="font-medium">{v.english}</span> = {v.german}
|
|
<span className="ml-2 text-yellow-700 text-xs">
|
|
(nur: {v.methods.join(', ')})
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!sessionId && (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
|
|
<svg
|
|
className="w-16 h-16 mx-auto text-slate-300 mb-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
<h3 className="text-lg font-medium text-slate-700 mb-2">PDF hochladen</h3>
|
|
<p className="text-slate-500 max-w-md mx-auto">
|
|
Laden Sie ein PDF hoch oder waehlen Sie eine Session aus der Historie, um OCR-Methoden zu vergleichen.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* QR Code Upload Modal */}
|
|
{showQRModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
|
<div className="relative w-full max-w-md">
|
|
<QRCodeUpload
|
|
sessionId={qrUploadSessionId}
|
|
onClose={() => setShowQRModal(false)}
|
|
onFilesChanged={(files) => {
|
|
setMobileUploadedFiles(files)
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cell Correction Dialog */}
|
|
{showCellDialog && selectedCell && sessionId && gridData && (
|
|
<CellCorrectionDialog
|
|
cell={selectedCell}
|
|
columnType={gridData.column_types[selectedCell.col] as 'english' | 'german' | 'example' | 'unknown' || 'unknown'}
|
|
sessionId={sessionId}
|
|
pageNumber={selectedPage}
|
|
onSave={handleCellSave}
|
|
onClose={() => {
|
|
setShowCellDialog(false)
|
|
setSelectedCell(null)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|