fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
458
admin-v2/components/ocr/BlockReviewPanel.tsx
Normal file
458
admin-v2/components/ocr/BlockReviewPanel.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* BlockReviewPanel Component
|
||||
*
|
||||
* Provides a block-by-block review interface for OCR comparison results.
|
||||
* Shows what each OCR method (A, B, C, D) detected for each grid block.
|
||||
* Allows approving or correcting each block.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GridData, GridCell } from './GridOverlay'
|
||||
import { getCellBlockNumber } from './GridOverlay'
|
||||
|
||||
export type BlockStatus = 'pending' | 'approved' | 'corrected' | 'skipped'
|
||||
|
||||
export interface MethodResult {
|
||||
methodId: string
|
||||
methodName: string
|
||||
text: string
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface BlockReviewData {
|
||||
blockNumber: number
|
||||
cell: GridCell
|
||||
methodResults: MethodResult[]
|
||||
status: BlockStatus
|
||||
correctedText?: string
|
||||
approvedMethodId?: string
|
||||
}
|
||||
|
||||
interface BlockReviewPanelProps {
|
||||
grid: GridData
|
||||
methodResults: Record<string, { vocabulary: Array<{ english: string; german: string; example?: string }> }>
|
||||
currentBlockNumber: number
|
||||
onBlockChange: (blockNumber: number) => void
|
||||
onApprove: (blockNumber: number, methodId: string, text: string) => void
|
||||
onCorrect: (blockNumber: number, correctedText: string) => void
|
||||
onSkip: (blockNumber: number) => void
|
||||
reviewData: Record<number, BlockReviewData>
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Method colors for consistent display
|
||||
const METHOD_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
vision_llm: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
|
||||
tesseract: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' },
|
||||
paddleocr: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
|
||||
claude_vision: { bg: 'bg-amber-50', border: 'border-amber-300', text: 'text-amber-700' },
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
vision_llm: 'B: Vision LLM',
|
||||
tesseract: 'D: Tesseract',
|
||||
paddleocr: 'C: PaddleOCR',
|
||||
claude_vision: 'E: Claude Vision',
|
||||
}
|
||||
|
||||
export function BlockReviewPanel({
|
||||
grid,
|
||||
methodResults,
|
||||
currentBlockNumber,
|
||||
onBlockChange,
|
||||
onApprove,
|
||||
onCorrect,
|
||||
onSkip,
|
||||
reviewData,
|
||||
className,
|
||||
}: BlockReviewPanelProps) {
|
||||
const [correctionText, setCorrectionText] = useState('')
|
||||
const [showCorrection, setShowCorrection] = useState(false)
|
||||
|
||||
// Get all non-empty blocks
|
||||
const nonEmptyBlocks = useMemo(() => {
|
||||
const blocks: { blockNumber: number; cell: GridCell }[] = []
|
||||
grid.cells.flat().forEach((cell) => {
|
||||
if (cell.status !== 'empty') {
|
||||
blocks.push({
|
||||
blockNumber: getCellBlockNumber(cell, grid),
|
||||
cell,
|
||||
})
|
||||
}
|
||||
})
|
||||
return blocks.sort((a, b) => a.blockNumber - b.blockNumber)
|
||||
}, [grid])
|
||||
|
||||
// Current block data
|
||||
const currentBlock = useMemo(() => {
|
||||
return nonEmptyBlocks.find((b) => b.blockNumber === currentBlockNumber)
|
||||
}, [nonEmptyBlocks, currentBlockNumber])
|
||||
|
||||
// Current block index for navigation
|
||||
const currentIndex = useMemo(() => {
|
||||
return nonEmptyBlocks.findIndex((b) => b.blockNumber === currentBlockNumber)
|
||||
}, [nonEmptyBlocks, currentBlockNumber])
|
||||
|
||||
// Find matching vocabulary for current cell from each method
|
||||
const blockMethodResults = useMemo(() => {
|
||||
if (!currentBlock) return []
|
||||
|
||||
const results: MethodResult[] = []
|
||||
const cellRow = currentBlock.cell.row
|
||||
const cellCol = currentBlock.cell.col
|
||||
|
||||
// For each method, try to find vocabulary that matches this cell position
|
||||
Object.entries(methodResults).forEach(([methodId, data]) => {
|
||||
if (!data.vocabulary || data.vocabulary.length === 0) return
|
||||
|
||||
// Get the text from the grid cell itself (from grid detection)
|
||||
const cellText = currentBlock.cell.text
|
||||
|
||||
// Try to match vocabulary entries
|
||||
// This is a simplified matching - in production you'd match by position
|
||||
const vocabIndex = cellRow * grid.columns + cellCol
|
||||
|
||||
let matchedText = ''
|
||||
if (data.vocabulary[vocabIndex]) {
|
||||
const v = data.vocabulary[vocabIndex]
|
||||
matchedText = currentBlock.cell.column_type === 'english'
|
||||
? v.english
|
||||
: currentBlock.cell.column_type === 'german'
|
||||
? v.german
|
||||
: v.example || ''
|
||||
}
|
||||
|
||||
results.push({
|
||||
methodId,
|
||||
methodName: METHOD_LABELS[methodId] || methodId,
|
||||
text: matchedText || cellText || '(nicht erkannt)',
|
||||
confidence: currentBlock.cell.confidence,
|
||||
})
|
||||
})
|
||||
|
||||
// Always include the grid detection result
|
||||
if (currentBlock.cell.text) {
|
||||
results.unshift({
|
||||
methodId: 'grid_detection',
|
||||
methodName: 'Grid-OCR',
|
||||
text: currentBlock.cell.text,
|
||||
confidence: currentBlock.cell.confidence,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}, [currentBlock, methodResults, grid.columns])
|
||||
|
||||
// Navigation handlers
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
onBlockChange(nonEmptyBlocks[currentIndex - 1].blockNumber)
|
||||
setShowCorrection(false)
|
||||
setCorrectionText('')
|
||||
}
|
||||
}, [currentIndex, nonEmptyBlocks, onBlockChange])
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < nonEmptyBlocks.length - 1) {
|
||||
onBlockChange(nonEmptyBlocks[currentIndex + 1].blockNumber)
|
||||
setShowCorrection(false)
|
||||
setCorrectionText('')
|
||||
}
|
||||
}, [currentIndex, nonEmptyBlocks, onBlockChange])
|
||||
|
||||
// Action handlers
|
||||
const handleApprove = useCallback((methodId: string, text: string) => {
|
||||
onApprove(currentBlockNumber, methodId, text)
|
||||
goToNext()
|
||||
}, [currentBlockNumber, onApprove, goToNext])
|
||||
|
||||
const handleCorrect = useCallback(() => {
|
||||
if (correctionText.trim()) {
|
||||
onCorrect(currentBlockNumber, correctionText.trim())
|
||||
goToNext()
|
||||
}
|
||||
}, [currentBlockNumber, correctionText, onCorrect, goToNext])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onSkip(currentBlockNumber)
|
||||
goToNext()
|
||||
}, [currentBlockNumber, onSkip, goToNext])
|
||||
|
||||
// Calculate progress
|
||||
const reviewedCount = Object.values(reviewData).filter(
|
||||
(r) => r.status !== 'pending'
|
||||
).length
|
||||
const progress = nonEmptyBlocks.length > 0
|
||||
? Math.round((reviewedCount / nonEmptyBlocks.length) * 100)
|
||||
: 0
|
||||
|
||||
const currentReview = reviewData[currentBlockNumber]
|
||||
|
||||
if (!currentBlock) {
|
||||
return (
|
||||
<div className={cn('p-4 text-center text-slate-500', className)}>
|
||||
Keine Blöcke zum Überprüfen
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
{/* Header with progress */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-slate-700">
|
||||
Block {currentBlockNumber}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
({currentIndex + 1} von {nonEmptyBlocks.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block status indicator */}
|
||||
{currentReview && currentReview.status !== 'pending' && (
|
||||
<div className={cn(
|
||||
'px-4 py-2 text-sm font-medium',
|
||||
currentReview.status === 'approved' && 'bg-green-100 text-green-700',
|
||||
currentReview.status === 'corrected' && 'bg-blue-100 text-blue-700',
|
||||
currentReview.status === 'skipped' && 'bg-slate-100 text-slate-600',
|
||||
)}>
|
||||
{currentReview.status === 'approved' && `✓ Freigegeben: "${currentReview.correctedText}"`}
|
||||
{currentReview.status === 'corrected' && `✎ Korrigiert: "${currentReview.correctedText}"`}
|
||||
{currentReview.status === 'skipped' && '○ Übersprungen'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cell info */}
|
||||
<div className="px-4 py-2 bg-slate-50 border-b text-sm">
|
||||
<span className="text-slate-500">Position: </span>
|
||||
<span className="font-medium">Zeile {currentBlock.cell.row + 1}, Spalte {currentBlock.cell.col + 1}</span>
|
||||
{currentBlock.cell.column_type && (
|
||||
<>
|
||||
<span className="text-slate-500 ml-3">Typ: </span>
|
||||
<span className="font-medium capitalize">{currentBlock.cell.column_type}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Method results */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-600 mb-2">Erkannte Texte:</h4>
|
||||
|
||||
{blockMethodResults.map((result) => {
|
||||
const colors = METHOD_COLORS[result.methodId] || {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-300',
|
||||
text: 'text-slate-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.methodId}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border-2 transition-all',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
'hover:shadow-md cursor-pointer'
|
||||
)}
|
||||
onClick={() => handleApprove(result.methodId, result.text)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={cn('text-xs font-semibold', colors.text)}>
|
||||
{result.methodName}
|
||||
</span>
|
||||
{result.confidence !== undefined && result.confidence < 0.7 && (
|
||||
<span className="text-xs text-orange-500">
|
||||
⚠ {Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{result.text || <span className="text-slate-400 italic">(leer)</span>}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
Klicken zum Übernehmen
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Manual correction */}
|
||||
{showCorrection ? (
|
||||
<div className="p-3 rounded-lg border-2 border-indigo-300 bg-indigo-50">
|
||||
<label className="text-xs font-semibold text-indigo-700 block mb-2">
|
||||
Manuelle Korrektur:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={correctionText}
|
||||
onChange={(e) => setCorrectionText(e.target.value)}
|
||||
placeholder="Korrekten Text eingeben..."
|
||||
className="w-full px-3 py-2 border border-indigo-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCorrect()
|
||||
if (e.key === 'Escape') setShowCorrection(false)
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleCorrect}
|
||||
disabled={!correctionText.trim()}
|
||||
className="flex-1 px-3 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCorrection(false)}
|
||||
className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCorrection(true)}
|
||||
className="w-full p-3 rounded-lg border-2 border-dashed border-slate-300 text-slate-500 text-sm hover:border-indigo-400 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
+ Manuell korrigieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
disabled={currentIndex === 0}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex === nonEmptyBlocks.length - 1}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BlockReviewSummary Component
|
||||
*
|
||||
* Shows a summary of all reviewed blocks.
|
||||
*/
|
||||
interface BlockReviewSummaryProps {
|
||||
reviewData: Record<number, BlockReviewData>
|
||||
totalBlocks: number
|
||||
onBlockClick: (blockNumber: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlockReviewSummary({
|
||||
reviewData,
|
||||
totalBlocks,
|
||||
onBlockClick,
|
||||
className,
|
||||
}: BlockReviewSummaryProps) {
|
||||
const stats = useMemo(() => {
|
||||
const values = Object.values(reviewData)
|
||||
return {
|
||||
approved: values.filter((r) => r.status === 'approved').length,
|
||||
corrected: values.filter((r) => r.status === 'corrected').length,
|
||||
skipped: values.filter((r) => r.status === 'skipped').length,
|
||||
pending: totalBlocks - values.length,
|
||||
}
|
||||
}, [reviewData, totalBlocks])
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 space-y-4', className)}>
|
||||
<h3 className="font-semibold text-slate-800">Überprüfungsübersicht</h3>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 bg-green-50 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-green-700">{stats.approved}</div>
|
||||
<div className="text-xs text-green-600">Freigegeben</div>
|
||||
</div>
|
||||
<div className="p-2 bg-blue-50 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-blue-700">{stats.corrected}</div>
|
||||
<div className="text-xs text-blue-600">Korrigiert</div>
|
||||
</div>
|
||||
<div className="p-2 bg-slate-100 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-slate-600">{stats.skipped}</div>
|
||||
<div className="text-xs text-slate-500">Übersprungen</div>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-50 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
|
||||
<div className="text-xs text-amber-500">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{Object.entries(reviewData)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([blockNum, data]) => (
|
||||
<button
|
||||
key={blockNum}
|
||||
onClick={() => onBlockClick(Number(blockNum))}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
data.status === 'approved' && 'bg-green-50 hover:bg-green-100',
|
||||
data.status === 'corrected' && 'bg-blue-50 hover:bg-blue-100',
|
||||
data.status === 'skipped' && 'bg-slate-50 hover:bg-slate-100',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-slate-700">Block {blockNum}</span>
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
data.status === 'approved' && 'text-green-600',
|
||||
data.status === 'corrected' && 'text-blue-600',
|
||||
data.status === 'skipped' && 'text-slate-500',
|
||||
)}>
|
||||
{data.status === 'approved' && '✓'}
|
||||
{data.status === 'corrected' && '✎'}
|
||||
{data.status === 'skipped' && '○'}
|
||||
{' '}
|
||||
{data.correctedText?.substring(0, 20)}
|
||||
{data.correctedText && data.correctedText.length > 20 && '...'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user