From fa5fe4baceca4d5526eeb7c6afadc283d7f031ea Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 9 Feb 2026 10:56:03 +0100 Subject: [PATCH] Restore OCR Compare page (ocr-compare/page.tsx) Restores the full OCR comparison tool with vocab extraction, green grid overlay, labeling, and LLM comparison features. Co-Authored-By: Claude Opus 4.6 --- admin-v2/app/(admin)/ai/ocr-compare/page.tsx | 1412 ++++++++++++++++++ 1 file changed, 1412 insertions(+) create mode 100644 admin-v2/app/(admin)/ai/ocr-compare/page.tsx diff --git a/admin-v2/app/(admin)/ai/ocr-compare/page.tsx b/admin-v2/app/(admin)/ai/ocr-compare/page.tsx new file mode 100644 index 0000000..73a0bb4 --- /dev/null +++ b/admin-v2/app/(admin)/ai/ocr-compare/page.tsx @@ -0,0 +1,1412 @@ +'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 } 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 + 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, + }, +} + +export default function OCRComparePage() { + // Session State + const [sessionId, setSessionId] = useState(null) + const [pageCount, setPageCount] = useState(0) + const [selectedPage, setSelectedPage] = useState(0) + const [thumbnails, setThumbnails] = useState([]) + const [loadingThumbnails, setLoadingThumbnails] = useState(false) + + // Session History + const [sessions, setSessions] = useState([]) + const [loadingSessions, setLoadingSessions] = useState(false) + const [showHistory, setShowHistory] = useState(false) + + // Comparison State + const [comparing, setComparing] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [uploading, setUploading] = useState(false) + + // Method Selection + const [selectedMethods, setSelectedMethods] = useState(['vision_llm', 'tesseract']) + + // QR Upload State + const [showQRModal, setShowQRModal] = useState(false) + const [qrUploadSessionId, setQrUploadSessionId] = useState('') + const [mobileUploadedFiles, setMobileUploadedFiles] = useState([]) + + // View Mode State + const [isFullscreen, setIsFullscreen] = useState(false) + const [expandedMethod, setExpandedMethod] = useState(null) // For single document view + const [visibleMethods, setVisibleMethods] = useState([]) // For custom multi-column view + + // Grid Detection State + const [gridData, setGridData] = useState(null) + const [analyzingGrid, setAnalyzingGrid] = useState(false) + const [showGridOverlay, setShowGridOverlay] = useState(true) + const [selectedCell, setSelectedCell] = useState(null) + const [showCellDialog, setShowCellDialog] = useState(false) + + // Block Review State + const [blockReviewMode, setBlockReviewMode] = useState(false) + const [currentBlockNumber, setCurrentBlockNumber] = useState(1) + const [blockReviewData, setBlockReviewData] = useState>({}) + + 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) => { + 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]) + + // 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 }) => ( +
+ {vocab.map((v, idx) => { + const key = `${v.english}|${v.german}` + const isUnique = highlight?.has(key) + return ( +
+
{v.english}
+
{v.german}
+ {v.example && ( +
{v.example}
+ )} +
+ ) + })} +
+ ) + + const getUniqueVocab = (methodKey: string): Set => { + if (!result?.comparison?.found_by_some_methods) return new Set() + const unique = new Set() + 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> = { + 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' }, + } + return colors[color]?.[type] || colors.slate[type] + } + + // Anzahl der ausgewaehlten Methoden + 1 fuer das Original + const columnCount = selectedMethods.length + 1 + + return ( +
+ + + {/* KI-Werkzeuge Sidebar */} + + +
+ {/* Left Sidebar: Upload & History */} +
+ {/* Upload Section */} +
+

PDF hochladen

+ + + + + + {uploading && ( +
+ + + + + Wird hochgeladen... +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Session History Panel */} +
+ + + {showHistory && ( +
+ {loadingSessions ? ( +
+ + + + + Lade Sessions... +
+ ) : sessions.length === 0 ? ( +
+ Keine Sessions vorhanden +
+ ) : ( + sessions.map(session => ( + + )) + )} +
+ )} +
+ + {/* Method Selection */} + {sessionId && pageCount > 0 && ( +
+

OCR-Methoden

+ +
+ {Object.values(OCR_METHODS).map(method => ( + + ))} +
+ +
+ + + {/* Grid Analysis Button */} + +
+ + {/* Grid Overlay Toggle */} + {gridData && ( +
+ + + {/* Block Review Button */} + {result && nonEmptyBlockCount > 0 && ( + + )} +
+ )} +
+ )} + + {/* Grid Stats */} + {gridData && ( +
+

Grid-Erkennung

+ +
+ +
+
+ )} + + {/* Block Review Summary */} + {blockReviewMode && gridData && Object.keys(blockReviewData).length > 0 && ( +
+ setCurrentBlockNumber(blockNumber)} + /> +
+ )} +
+ + {/* Main Content Area */} +
+ {/* Page Thumbnails Grid */} + {sessionId && pageCount > 0 && ( +
+

+ Seite auswaehlen ({pageCount} Seiten) +

+ + {loadingThumbnails ? ( +
+ + + + + Lade Seitenvorschau... +
+ ) : ( +
+ {thumbnails.map((thumb, idx) => ( + + ))} +
+ )} +
+ )} + + {/* Full-Width Comparison View */} + {(thumbnails[selectedPage] || result) && sessionId && ( +
+ {/* Header with Controls */} +
+

+ Vergleich - Seite {selectedPage + 1} +

+ +
+ {/* Layout Selector - only show after comparison */} + {result && ( +
+ Ansicht: + {[1, 2, 3, 4].map(cols => ( + + ))} + +
+ )} + + {/* Fullscreen Toggle */} + +
+
+ + {/* Single Method Expanded View */} + {expandedMethod && ( +
+ + + {expandedMethod === 'original' ? ( +
+
+

Original - Seite {selectedPage + 1}

+
+
+ {thumbnails[selectedPage] ? ( + {`Seite + ) : ( +
+ Kein Bild verfuegbar +
+ )} +
+
+ ) : ( + (() => { + const method = OCR_METHODS[expandedMethod as keyof typeof OCR_METHODS] + const methodResult = result?.methods?.[expandedMethod] + const isBest = result?.recommendation?.best_method === expandedMethod + return ( +
+
+
+

{method.name}

+

{method.model}

+
+ {isBest && ( + + Beste Methode + + )} +
+
+ {methodResult && ( +
+
+
+
+
{methodResult.duration_seconds}s
+
Dauer
+
+
+
{methodResult.vocabulary_count}
+
Vokabeln
+
+
+
{(methodResult.confidence * 100).toFixed(0)}%
+
Konfidenz
+
+
+
+ {methodResult.vocabulary?.length > 0 && ( +
+ +
+ )} +
+ )} +
+
+ ) + })() + )} +
+ )} + + {/* Grid View (Normal or Custom Selection) */} + {!expandedMethod && ( +
0 ? visibleMethods.length : columnCount + }, minmax(0, 1fr))` + }} + > + {/* Original PDF Column */} + {(visibleMethods.length === 0 || visibleMethods.includes('original')) && ( +
setExpandedMethod('original')} + > +
+
+

Original

+

Seite {selectedPage + 1}

+
+ + + +
+
+ {thumbnails[selectedPage] ? ( +
+ {/* Show Grid Overlay if available */} + {gridData && showGridOverlay ? ( + + ) : ( + {`Seite + )} +
+ ) : ( +
+ Kein Bild verfuegbar +
+ )} +
+
+ )} + + {/* 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 ( +
setExpandedMethod(methodId)} + > +
+
+

{method.shortName}

+

{method.model}

+
+
+ {isBest && ( + + Beste + + )} + + + +
+
+
+ {comparing && !methodResult && ( +
+ + + + + Extrahiere... +
+ )} + {methodResult && ( +
+
+
+ Dauer: + {methodResult.duration_seconds}s +
+
+ Vokabeln: + {methodResult.vocabulary_count} +
+ {methodResult.error && ( +
{methodResult.error}
+ )} +
+ {methodResult.vocabulary?.length > 0 && ( + + )} +
+ )} + {!comparing && !methodResult && ( +
+ Noch keine Ergebnisse +
+ )} +
+
+ ) + })} +
+ )} + + {/* Method Selector Chips (for custom view) */} + {result && visibleMethods.length > 0 && visibleMethods.length < selectedMethods.length + 1 && ( +
+
+ Methoden ein-/ausblenden: + + {selectedMethods.map(methodId => { + const method = OCR_METHODS[methodId as keyof typeof OCR_METHODS] + return ( + + ) + })} +
+
+ )} + + {/* Block Review Panel */} + {blockReviewMode && gridData && result && ( +
+
+

+ + + + Block-Review +

+

+ Prüfen Sie jeden Block und wählen Sie die korrekte Erkennung oder korrigieren Sie manuell. +

+
+ +
+ )} +
+ )} + + {/* Comparison Summary */} + {result?.comparison && ( +
+

Vergleichszusammenfassung

+ +
+
+
+ {result.comparison.total_unique_vocabulary} +
+
Gesamt eindeutig
+
+
+
+ {result.comparison.found_by_all_methods?.length || 0} +
+
Von allen erkannt
+
+
+
+ {result.comparison.found_by_some_methods?.length || 0} +
+
Unterschiede
+
+
+
+ {(result.comparison.agreement_rate * 100).toFixed(0)}% +
+
Uebereinstimmung
+
+
+ + {result.recommendation && ( +
+
+ Empfehlung: + + {OCR_METHODS[result.recommendation.best_method as keyof typeof OCR_METHODS]?.name || result.recommendation.best_method} + +
+

{result.recommendation.reason}

+
+ )} + + {result.comparison.found_by_some_methods?.length > 0 && ( +
+

+ Unterschiede (gelb markiert): +

+
+ {result.comparison.found_by_some_methods.map((v, idx) => ( +
+ {v.english} = {v.german} + + (nur: {v.methods.join(', ')}) + +
+ ))} +
+
+ )} +
+ )} + + {/* Empty State */} + {!sessionId && ( +
+ + + +

PDF hochladen

+

+ Laden Sie ein PDF hoch oder waehlen Sie eine Session aus der Historie, um OCR-Methoden zu vergleichen. +

+
+ )} +
+
+ + {/* QR Code Upload Modal */} + {showQRModal && ( +
+
setShowQRModal(false)} /> +
+ setShowQRModal(false)} + onFilesChanged={(files) => { + setMobileUploadedFiles(files) + }} + /> +
+
+ )} + + {/* Cell Correction Dialog */} + {showCellDialog && selectedCell && sessionId && gridData && ( + { + setShowCellDialog(false) + setSelectedCell(null) + }} + /> + )} +
+ ) +}