feat: BreakPilot PWA - Full codebase (clean push without large binaries)
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
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>
|
||||
)
|
||||
}
|
||||
294
admin-v2/components/ocr/CellCorrectionDialog.tsx
Normal file
294
admin-v2/components/ocr/CellCorrectionDialog.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CellCorrectionDialog Component
|
||||
*
|
||||
* Modal dialog for manually correcting OCR text in problematic or recognized cells.
|
||||
* Shows cropped image of the cell for reference and allows text input.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { GridCell } from './GridOverlay'
|
||||
|
||||
interface CellCorrectionDialogProps {
|
||||
cell: GridCell
|
||||
columnType: 'english' | 'german' | 'example' | 'unknown'
|
||||
sessionId: string
|
||||
pageNumber: number
|
||||
onSave: (text: string) => void
|
||||
onRetryOCR?: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CellCorrectionDialog({
|
||||
cell,
|
||||
columnType,
|
||||
sessionId,
|
||||
pageNumber,
|
||||
onSave,
|
||||
onRetryOCR,
|
||||
onClose,
|
||||
}: CellCorrectionDialogProps) {
|
||||
const [text, setText] = useState(cell.text || '')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const [cropUrl, setCropUrl] = useState<string | null>(null)
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
// Load cell crop image
|
||||
useEffect(() => {
|
||||
const loadCrop = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell-crop/${pageNumber}/${cell.row}/${cell.col}`
|
||||
)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
setCropUrl(URL.createObjectURL(blob))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load cell crop:', e)
|
||||
}
|
||||
}
|
||||
loadCrop()
|
||||
|
||||
return () => {
|
||||
if (cropUrl) {
|
||||
URL.revokeObjectURL(cropUrl)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, pageNumber, cell.row, cell.col])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!text.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('text', text)
|
||||
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell/${cell.row}/${cell.col}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
onSave(text)
|
||||
onClose()
|
||||
} else {
|
||||
console.error('Failed to save cell:', await res.text())
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetryOCR = async () => {
|
||||
if (!onRetryOCR) return
|
||||
|
||||
setRetrying(true)
|
||||
try {
|
||||
await onRetryOCR()
|
||||
} finally {
|
||||
setRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getColumnLabel = () => {
|
||||
switch (columnType) {
|
||||
case 'english':
|
||||
return 'Englisch'
|
||||
case 'german':
|
||||
return 'Deutsch'
|
||||
case 'example':
|
||||
return 'Beispielsatz'
|
||||
default:
|
||||
return 'Text'
|
||||
}
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
switch (columnType) {
|
||||
case 'english':
|
||||
return 'Englisches Wort eingeben...'
|
||||
case 'german':
|
||||
return 'Deutsche Ubersetzung eingeben...'
|
||||
case 'example':
|
||||
return 'Beispielsatz eingeben...'
|
||||
default:
|
||||
return 'Text eingeben...'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative w-full max-w-lg bg-white rounded-xl shadow-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{cell.status === 'problematic' ? 'Nicht erkannter Bereich' : 'Text bearbeiten'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Zeile {cell.row + 1}, Spalte {cell.col + 1} ({getColumnLabel()})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Cell image preview */}
|
||||
<div className="border rounded-lg p-3 bg-slate-50">
|
||||
<p className="text-xs text-slate-500 mb-2 font-medium">Originalbild:</p>
|
||||
{cropUrl ? (
|
||||
<img
|
||||
src={cropUrl}
|
||||
alt="Zellinhalt"
|
||||
className="max-h-32 mx-auto rounded border border-slate-200 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-20 flex items-center justify-center text-slate-400 text-sm">
|
||||
Lade Vorschau...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
{cell.status === 'problematic' && (
|
||||
<div className="flex items-center gap-2 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<svg className="w-5 h-5 text-orange-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-sm text-orange-700">
|
||||
Diese Zelle konnte nicht automatisch erkannt werden.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current recognized text */}
|
||||
{cell.status === 'recognized' && cell.text && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-xs text-green-600 font-medium mb-1">Erkannter Text:</p>
|
||||
<p className="text-sm text-green-800">{cell.text}</p>
|
||||
{cell.confidence < 1 && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Konfidenz: {Math.round(cell.confidence * 100)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{cell.status === 'problematic' ? 'Text eingeben' : 'Text korrigieren'}
|
||||
</label>
|
||||
{columnType === 'example' ? (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
<div>
|
||||
{onRetryOCR && cell.status === 'problematic' && (
|
||||
<button
|
||||
onClick={handleRetryOCR}
|
||||
disabled={retrying}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{retrying ? (
|
||||
<>
|
||||
<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>
|
||||
Erneut versuchen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
OCR erneut versuchen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading || !text.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<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>
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
'Speichern'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CellCorrectionDialog
|
||||
549
admin-v2/components/ocr/GridOverlay.tsx
Normal file
549
admin-v2/components/ocr/GridOverlay.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GridOverlay Component
|
||||
*
|
||||
* SVG overlay for displaying detected OCR grid structure on document images.
|
||||
* Features:
|
||||
* - Cell status visualization (recognized/problematic/manual/empty)
|
||||
* - 1mm grid overlay for A4 pages (210x297mm)
|
||||
* - Text at original bounding-box positions
|
||||
* - Editable text (contentEditable) at original positions
|
||||
* - Click-to-edit for cells
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
|
||||
|
||||
export interface GridCell {
|
||||
row: number
|
||||
col: number
|
||||
x: number // X position as percentage (0-100)
|
||||
y: number // Y position as percentage (0-100)
|
||||
width: number // Width as percentage (0-100)
|
||||
height: number // Height as percentage (0-100)
|
||||
text: string
|
||||
confidence: number
|
||||
status: CellStatus
|
||||
column_type?: 'english' | 'german' | 'example' | 'unknown'
|
||||
x_mm?: number
|
||||
y_mm?: number
|
||||
width_mm?: number
|
||||
height_mm?: number
|
||||
}
|
||||
|
||||
export interface GridData {
|
||||
rows: number
|
||||
columns: number
|
||||
cells: GridCell[][]
|
||||
column_types: string[]
|
||||
column_boundaries: number[]
|
||||
row_boundaries: number[]
|
||||
deskew_angle: number
|
||||
stats: {
|
||||
recognized: number
|
||||
problematic: number
|
||||
empty: number
|
||||
manual?: number
|
||||
total: number
|
||||
coverage: number
|
||||
}
|
||||
page_dimensions?: {
|
||||
width_mm: number
|
||||
height_mm: number
|
||||
format: string
|
||||
}
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface GridOverlayProps {
|
||||
grid: GridData
|
||||
imageUrl?: string
|
||||
onCellClick?: (cell: GridCell) => void
|
||||
onCellTextChange?: (cell: GridCell, newText: string) => void
|
||||
selectedCell?: GridCell | null
|
||||
showEmpty?: boolean
|
||||
showLabels?: boolean
|
||||
showNumbers?: boolean
|
||||
showTextLabels?: boolean
|
||||
showMmGrid?: boolean
|
||||
showTextAtPosition?: boolean
|
||||
editableText?: boolean
|
||||
highlightedBlockNumber?: number | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Status colors
|
||||
const STATUS_COLORS = {
|
||||
recognized: {
|
||||
fill: 'rgba(34, 197, 94, 0.2)',
|
||||
stroke: '#22c55e',
|
||||
hoverFill: 'rgba(34, 197, 94, 0.3)',
|
||||
},
|
||||
problematic: {
|
||||
fill: 'rgba(249, 115, 22, 0.3)',
|
||||
stroke: '#f97316',
|
||||
hoverFill: 'rgba(249, 115, 22, 0.4)',
|
||||
},
|
||||
manual: {
|
||||
fill: 'rgba(59, 130, 246, 0.2)',
|
||||
stroke: '#3b82f6',
|
||||
hoverFill: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
empty: {
|
||||
fill: 'transparent',
|
||||
stroke: 'rgba(148, 163, 184, 0.3)',
|
||||
hoverFill: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
// A4 dimensions for mm grid
|
||||
const A4_WIDTH_MM = 210
|
||||
const A4_HEIGHT_MM = 297
|
||||
|
||||
// Helper to calculate block number (1-indexed, row-by-row)
|
||||
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
|
||||
return cell.row * grid.columns + cell.col + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 1mm Grid SVG Lines for A4 format.
|
||||
* Renders inside a viewBox="0 0 100 100" (percentage-based).
|
||||
*/
|
||||
function MmGridLines() {
|
||||
const lines: React.ReactNode[] = []
|
||||
|
||||
// Vertical lines: 210 lines for 210mm
|
||||
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
|
||||
const x = (mm / A4_WIDTH_MM) * 100
|
||||
const isCm = mm % 10 === 0
|
||||
lines.push(
|
||||
<line
|
||||
key={`v-${mm}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={100}
|
||||
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
|
||||
strokeWidth={isCm ? 0.08 : 0.03}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal lines: 297 lines for 297mm
|
||||
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
|
||||
const y = (mm / A4_HEIGHT_MM) * 100
|
||||
const isCm = mm % 10 === 0
|
||||
lines.push(
|
||||
<line
|
||||
key={`h-${mm}`}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={100}
|
||||
y2={y}
|
||||
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
|
||||
strokeWidth={isCm ? 0.08 : 0.03}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioned text overlay using absolute-positioned HTML divs.
|
||||
* Each cell's text appears at its bounding-box position with matching font size.
|
||||
*/
|
||||
function PositionedTextLayer({
|
||||
cells,
|
||||
editable,
|
||||
onTextChange,
|
||||
}: {
|
||||
cells: GridCell[]
|
||||
editable: boolean
|
||||
onTextChange?: (cell: GridCell, text: string) => void
|
||||
}) {
|
||||
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
|
||||
{cells.map((cell) => {
|
||||
if (cell.status === 'empty' || !cell.text) return null
|
||||
|
||||
const cellKey = `pos-${cell.row}-${cell.col}`
|
||||
const isHovered = hoveredCell === cellKey
|
||||
// Estimate font size from cell height: height_pct maps to roughly pt size
|
||||
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
|
||||
// Font size ~= height_mm * 2.2 (roughly matching print)
|
||||
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
|
||||
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cellKey}
|
||||
className={cn(
|
||||
'absolute overflow-hidden transition-colors duration-100',
|
||||
editable && 'cursor-text hover:bg-yellow-100/40',
|
||||
isHovered && !editable && 'bg-blue-100/30',
|
||||
)}
|
||||
style={{
|
||||
left: `${cell.x}%`,
|
||||
top: `${cell.y}%`,
|
||||
width: `${cell.width}%`,
|
||||
height: `${cell.height}%`,
|
||||
fontSize: `${fontSizePt}pt`,
|
||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||
lineHeight: 1.1,
|
||||
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
|
||||
padding: '0 1px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCell(cellKey)}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
>
|
||||
{editable ? (
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="outline-none w-full"
|
||||
style={{ minHeight: '1em' }}
|
||||
onBlur={(e) => {
|
||||
const newText = e.currentTarget.textContent ?? ''
|
||||
if (newText !== cell.text && onTextChange) {
|
||||
onTextChange(cell, newText)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{cell.text}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GridOverlay({
|
||||
grid,
|
||||
imageUrl,
|
||||
onCellClick,
|
||||
onCellTextChange,
|
||||
selectedCell,
|
||||
showEmpty = false,
|
||||
showLabels = true,
|
||||
showNumbers = false,
|
||||
showTextLabels = false,
|
||||
showMmGrid = false,
|
||||
showTextAtPosition = false,
|
||||
editableText = false,
|
||||
highlightedBlockNumber,
|
||||
className,
|
||||
}: GridOverlayProps) {
|
||||
const handleCellClick = useCallback(
|
||||
(cell: GridCell) => {
|
||||
if (onCellClick && cell.status !== 'empty') {
|
||||
onCellClick(cell)
|
||||
}
|
||||
},
|
||||
[onCellClick]
|
||||
)
|
||||
|
||||
const flatCells = grid.cells.flat()
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Background image */}
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Document"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SVG overlay */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* 1mm Grid */}
|
||||
{showMmGrid && <MmGridLines />}
|
||||
|
||||
{/* Column type labels */}
|
||||
{showLabels && grid.column_types.length > 0 && (
|
||||
<g>
|
||||
{grid.column_types.map((type, idx) => {
|
||||
const x = grid.column_boundaries[idx]
|
||||
const width = grid.column_boundaries[idx + 1] - x
|
||||
const label = type === 'english' ? 'EN' : type === 'german' ? 'DE' : type === 'example' ? 'Ex' : '?'
|
||||
return (
|
||||
<text
|
||||
key={`col-label-${idx}`}
|
||||
x={x + width / 2}
|
||||
y={1.5}
|
||||
textAnchor="middle"
|
||||
fontSize="1.5"
|
||||
fill="#64748b"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Grid cells (skip if showing text at position to avoid double rendering) */}
|
||||
{!showTextAtPosition && flatCells.map((cell) => {
|
||||
const colors = STATUS_COLORS[cell.status]
|
||||
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
|
||||
const isClickable = cell.status !== 'empty' && onCellClick
|
||||
const blockNumber = getCellBlockNumber(cell, grid)
|
||||
const isHighlighted = highlightedBlockNumber === blockNumber
|
||||
|
||||
if (!showEmpty && cell.status === 'empty') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
key={`cell-${cell.row}-${cell.col}`}
|
||||
style={{ pointerEvents: isClickable ? 'auto' : 'none' }}
|
||||
onClick={() => handleCellClick(cell)}
|
||||
className={isClickable ? 'cursor-pointer' : ''}
|
||||
>
|
||||
<rect
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill={isHighlighted ? 'rgba(99, 102, 241, 0.3)' : colors.fill}
|
||||
stroke={isSelected || isHighlighted ? '#4f46e5' : colors.stroke}
|
||||
strokeWidth={isSelected || isHighlighted ? 0.3 : 0.15}
|
||||
rx={0.2}
|
||||
className={cn(
|
||||
'transition-all duration-150',
|
||||
isClickable && 'hover:fill-opacity-40'
|
||||
)}
|
||||
/>
|
||||
|
||||
{showNumbers && cell.status !== 'empty' && (
|
||||
<>
|
||||
<rect
|
||||
x={cell.x + 0.3}
|
||||
y={cell.y + 0.3}
|
||||
width={2.5}
|
||||
height={1.8}
|
||||
fill={isHighlighted ? '#4f46e5' : '#374151'}
|
||||
rx={0.3}
|
||||
/>
|
||||
<text
|
||||
x={cell.x + 1.55}
|
||||
y={cell.y + 1.5}
|
||||
textAnchor="middle"
|
||||
fontSize="1.2"
|
||||
fill="white"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{blockNumber}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showNumbers && !showTextLabels && cell.status !== 'empty' && (
|
||||
<circle
|
||||
cx={cell.x + 0.8}
|
||||
cy={cell.y + 0.8}
|
||||
r={0.5}
|
||||
fill={colors.stroke}
|
||||
stroke="white"
|
||||
strokeWidth={0.1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
|
||||
<text
|
||||
x={cell.x + cell.width / 2}
|
||||
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
|
||||
textAnchor="middle"
|
||||
fontSize={Math.min(cell.height * 0.5, 1.4)}
|
||||
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
|
||||
fontWeight="500"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{cell.status === 'recognized' && cell.confidence < 0.7 && (
|
||||
<text
|
||||
x={cell.x + cell.width - 0.5}
|
||||
y={cell.y + 1.2}
|
||||
fontSize="0.8"
|
||||
fill="#f97316"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
!
|
||||
</text>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill="none"
|
||||
stroke="#4f46e5"
|
||||
strokeWidth={0.4}
|
||||
strokeDasharray="0.5,0.3"
|
||||
rx={0.2}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Show cell outlines when in positioned text mode */}
|
||||
{showTextAtPosition && flatCells.map((cell) => {
|
||||
if (cell.status === 'empty') return null
|
||||
return (
|
||||
<rect
|
||||
key={`outline-${cell.row}-${cell.col}`}
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill="none"
|
||||
stroke="rgba(99, 102, 241, 0.2)"
|
||||
strokeWidth={0.08}
|
||||
rx={0.1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Row boundaries */}
|
||||
{grid.row_boundaries.map((y, idx) => (
|
||||
<line
|
||||
key={`row-line-${idx}`}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={100}
|
||||
y2={y}
|
||||
stroke="rgba(148, 163, 184, 0.2)"
|
||||
strokeWidth={0.05}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Column boundaries */}
|
||||
{grid.column_boundaries.map((x, idx) => (
|
||||
<line
|
||||
key={`col-line-${idx}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={100}
|
||||
stroke="rgba(148, 163, 184, 0.2)"
|
||||
strokeWidth={0.05}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
|
||||
{showTextAtPosition && (
|
||||
<PositionedTextLayer
|
||||
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
|
||||
editable={editableText}
|
||||
onTextChange={onCellTextChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GridStats Component
|
||||
*/
|
||||
interface GridStatsProps {
|
||||
stats: GridData['stats']
|
||||
deskewAngle?: number
|
||||
source?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
|
||||
const coveragePercent = Math.round(stats.coverage * 100)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-3', className)}>
|
||||
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
|
||||
Erkannt: {stats.recognized}
|
||||
</div>
|
||||
{(stats.manual ?? 0) > 0 && (
|
||||
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
|
||||
Manuell: {stats.manual}
|
||||
</div>
|
||||
)}
|
||||
{stats.problematic > 0 && (
|
||||
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
|
||||
Problematisch: {stats.problematic}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
|
||||
Leer: {stats.empty}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
|
||||
Abdeckung: {coveragePercent}%
|
||||
</div>
|
||||
{deskewAngle !== undefined && deskewAngle !== 0 && (
|
||||
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
|
||||
Begradigt: {deskewAngle.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
|
||||
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legend Component for GridOverlay
|
||||
*/
|
||||
export function GridLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
|
||||
<span className="text-slate-600">Erkannt</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
|
||||
<span className="text-slate-600">Problematisch</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
|
||||
<span className="text-slate-600">Manuell korrigiert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
|
||||
<span className="text-slate-600">Leer</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
670
admin-v2/components/ocr/GroundTruthPanel.tsx
Normal file
670
admin-v2/components/ocr/GroundTruthPanel.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GroundTruthPanel — Step-through UI for labeling OCR ground truth.
|
||||
*
|
||||
* Shows page image with SVG overlay (color-coded bounding boxes),
|
||||
* alongside crops of the current entry and editable text fields.
|
||||
* Keyboard-driven: Enter=confirm, Tab=skip, Arrow keys=navigate.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
interface BBox {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
interface GTEntry {
|
||||
row_index: number
|
||||
english: string
|
||||
german: string
|
||||
example: string
|
||||
confidence: number
|
||||
bbox: BBox
|
||||
bbox_en: BBox
|
||||
bbox_de: BBox
|
||||
bbox_ex: BBox
|
||||
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
||||
}
|
||||
|
||||
interface GroundTruthPanelProps {
|
||||
sessionId: string
|
||||
selectedPage: number
|
||||
pageImageUrl: string
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
const STATUS_COLORS: Record<string, { fill: string; stroke: string }> = {
|
||||
current: { fill: 'rgba(250,204,21,0.25)', stroke: '#eab308' }, // yellow
|
||||
confirmed: { fill: 'rgba(34,197,94,0.18)', stroke: '#16a34a' }, // green
|
||||
edited: { fill: 'rgba(59,130,246,0.18)', stroke: '#2563eb' }, // blue
|
||||
skipped: { fill: 'rgba(148,163,184,0.15)', stroke: '#94a3b8' }, // gray
|
||||
pending: { fill: 'rgba(0,0,0,0)', stroke: '#cbd5e1' }, // outline only
|
||||
}
|
||||
|
||||
function getEntryColor(entry: GTEntry, index: number, currentIndex: number) {
|
||||
if (index === currentIndex) return STATUS_COLORS.current
|
||||
return STATUS_COLORS[entry.status || 'pending']
|
||||
}
|
||||
|
||||
// ---------- ImageCrop ----------
|
||||
|
||||
function ImageCrop({ imageUrl, bbox, naturalWidth, naturalHeight, maxWidth = 380, label }: {
|
||||
imageUrl: string
|
||||
bbox: BBox
|
||||
naturalWidth: number
|
||||
naturalHeight: number
|
||||
maxWidth?: number
|
||||
label?: string
|
||||
}) {
|
||||
if (!bbox || bbox.w === 0 || bbox.h === 0) return null
|
||||
|
||||
const cropWPx = (bbox.w / 100) * naturalWidth
|
||||
const cropHPx = (bbox.h / 100) * naturalHeight
|
||||
if (cropWPx < 1 || cropHPx < 1) return null
|
||||
|
||||
const scale = maxWidth / cropWPx
|
||||
const displayH = cropHPx * scale
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <div className="text-xs font-medium text-slate-500 mb-1">{label}</div>}
|
||||
<div
|
||||
className="rounded-lg border border-slate-200 overflow-hidden bg-white"
|
||||
style={{ width: maxWidth, height: Math.min(displayH, 120), overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: naturalWidth * scale,
|
||||
height: naturalHeight * scale,
|
||||
left: -(bbox.x / 100) * naturalWidth * scale,
|
||||
top: -(bbox.y / 100) * naturalHeight * scale,
|
||||
maxWidth: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Main Component ----------
|
||||
|
||||
export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: GroundTruthPanelProps) {
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
// State
|
||||
const [entries, setEntries] = useState<GTEntry[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
|
||||
const [showSummary, setShowSummary] = useState(false)
|
||||
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState(pageImageUrl)
|
||||
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
|
||||
|
||||
// Editable fields for current entry
|
||||
const [editEn, setEditEn] = useState('')
|
||||
const [editDe, setEditDe] = useState('')
|
||||
const [editEx, setEditEx] = useState('')
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const enInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset image URL when page changes
|
||||
useEffect(() => {
|
||||
setImageUrl(pageImageUrl)
|
||||
setDeskewAngle(null)
|
||||
}, [pageImageUrl])
|
||||
|
||||
// Load natural image dimensions
|
||||
useEffect(() => {
|
||||
if (!imageUrl) return
|
||||
const img = new Image()
|
||||
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
img.src = imageUrl
|
||||
}, [imageUrl])
|
||||
|
||||
// Sync edit fields when current entry changes
|
||||
useEffect(() => {
|
||||
const entry = entries[currentIndex]
|
||||
if (entry) {
|
||||
setEditEn(entry.english)
|
||||
setEditDe(entry.german)
|
||||
setEditEx(entry.example)
|
||||
}
|
||||
}, [currentIndex, entries])
|
||||
|
||||
// ---------- Actions ----------
|
||||
|
||||
const handleExtract = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setShowSummary(false)
|
||||
setSavedMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/extract-with-boxes/${selectedPage}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || 'Extract failed')
|
||||
}
|
||||
const data = await res.json()
|
||||
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
|
||||
setEntries(loaded)
|
||||
setCurrentIndex(0)
|
||||
|
||||
// Switch to deskewed image if available
|
||||
if (data.deskewed) {
|
||||
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
|
||||
setDeskewAngle(data.deskew_angle)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraction failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sessionId, selectedPage])
|
||||
|
||||
const confirmEntry = useCallback(() => {
|
||||
if (entries.length === 0) return
|
||||
const entry = entries[currentIndex]
|
||||
const isEdited = editEn !== entry.english || editDe !== entry.german || editEx !== entry.example
|
||||
const updated = [...entries]
|
||||
updated[currentIndex] = {
|
||||
...entry,
|
||||
english: editEn,
|
||||
german: editDe,
|
||||
example: editEx,
|
||||
status: isEdited ? 'edited' : 'confirmed',
|
||||
}
|
||||
setEntries(updated)
|
||||
if (currentIndex < entries.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
} else {
|
||||
setShowSummary(true)
|
||||
}
|
||||
}, [entries, currentIndex, editEn, editDe, editEx])
|
||||
|
||||
const skipEntry = useCallback(() => {
|
||||
if (entries.length === 0) return
|
||||
const updated = [...entries]
|
||||
updated[currentIndex] = { ...updated[currentIndex], status: 'skipped' }
|
||||
setEntries(updated)
|
||||
if (currentIndex < entries.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
} else {
|
||||
setShowSummary(true)
|
||||
}
|
||||
}, [entries, currentIndex])
|
||||
|
||||
const goTo = useCallback((idx: number) => {
|
||||
if (idx >= 0 && idx < entries.length) {
|
||||
setCurrentIndex(idx)
|
||||
setShowSummary(false)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ground-truth/${selectedPage}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entries }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Save failed')
|
||||
const data = await res.json()
|
||||
setSavedMessage(`Gespeichert: ${data.confirmed} bestaetigt, ${data.edited} editiert, ${data.skipped} uebersprungen`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [sessionId, selectedPage, entries])
|
||||
|
||||
// ---------- Keyboard shortcuts ----------
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
e.preventDefault()
|
||||
setIsFullscreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (entries.length === 0 || showSummary) return
|
||||
|
||||
// Don't capture when typing in inputs
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmEntry()
|
||||
} else if (e.key === 'Tab' && !e.shiftKey) {
|
||||
if (!isInput) {
|
||||
e.preventDefault()
|
||||
skipEntry()
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && !isInput) {
|
||||
e.preventDefault()
|
||||
goTo(currentIndex - 1)
|
||||
} else if (e.key === 'ArrowRight' && !isInput) {
|
||||
e.preventDefault()
|
||||
goTo(currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
|
||||
|
||||
// ---------- Computed ----------
|
||||
|
||||
const currentEntry = entries[currentIndex]
|
||||
const confirmedCount = entries.filter(e => e.status === 'confirmed').length
|
||||
const editedCount = entries.filter(e => e.status === 'edited').length
|
||||
const skippedCount = entries.filter(e => e.status === 'skipped').length
|
||||
const processedCount = confirmedCount + editedCount + skippedCount
|
||||
const progress = entries.length > 0 ? Math.round((processedCount / entries.length) * 100) : 0
|
||||
|
||||
// ---------- Render: No entries yet ----------
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center" ref={panelRef}>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Ground Truth Labeling</h3>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Erkennung starten um Vokabeln mit Positionen zu extrahieren.
|
||||
Danach jede Zeile durchgehen und bestaetigen oder korrigieren.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-5 h-5" 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>
|
||||
Erkennung laeuft...
|
||||
</span>
|
||||
) : 'Erkennung starten'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Render: Summary ----------
|
||||
|
||||
if (showSummary) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border border-slate-200 p-6 ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none' : ''
|
||||
}`} ref={panelRef}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Zusammenfassung</h3>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{confirmedCount}</div>
|
||||
<div className="text-sm text-green-600">Bestaetigt</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-700">{editedCount}</div>
|
||||
<div className="text-sm text-blue-600">Editiert</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-slate-700">{skippedCount}</div>
|
||||
<div className="text-sm text-slate-500">Uebersprungen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 px-4 py-2.5 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Ground Truth speichern'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSummary(false); setCurrentIndex(0) }}
|
||||
className="px-4 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200"
|
||||
>
|
||||
Nochmal durchgehen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{savedMessage && (
|
||||
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
||||
{savedMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Entry list for quick review */}
|
||||
<div className="mt-6 max-h-96 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 text-slate-500">#</th>
|
||||
<th className="text-left py-2 px-2 text-slate-500">English</th>
|
||||
<th className="text-left py-2 px-2 text-slate-500">Deutsch</th>
|
||||
<th className="text-left py-2 px-2 text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
||||
>
|
||||
<td className="py-1.5 px-2 text-slate-400">{i + 1}</td>
|
||||
<td className="py-1.5 px-2">{e.english}</td>
|
||||
<td className="py-1.5 px-2">{e.german}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
e.status === 'confirmed' ? 'bg-green-100 text-green-700' :
|
||||
e.status === 'edited' ? 'bg-blue-100 text-blue-700' :
|
||||
e.status === 'skipped' ? 'bg-slate-100 text-slate-500' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{e.status === 'confirmed' ? 'OK' :
|
||||
e.status === 'edited' ? 'Editiert' :
|
||||
e.status === 'skipped' ? 'Skip' : 'Offen'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Render: Main Review UI ----------
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
|
||||
}`} ref={panelRef}>
|
||||
{/* Header with progress + fullscreen toggle */}
|
||||
<div className="flex items-center gap-2 px-4 pt-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
|
||||
<div
|
||||
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
|
||||
{deskewAngle !== null && (
|
||||
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
|
||||
{deskewAngle.toFixed(1)}°
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
|
||||
{/* Left: Page image with SVG overlay (2/3) */}
|
||||
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
|
||||
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Seite ${selectedPage + 1}`}
|
||||
className="w-full"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{/* SVG Overlay */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{entries.map((entry, i) => {
|
||||
const colors = getEntryColor(entry, i, currentIndex)
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={entry.bbox.x}
|
||||
y={entry.bbox.y}
|
||||
width={entry.bbox.w}
|
||||
height={entry.bbox.h}
|
||||
fill={colors.fill}
|
||||
stroke={colors.stroke}
|
||||
strokeWidth={i === currentIndex ? 0.3 : 0.15}
|
||||
style={{ cursor: 'pointer', pointerEvents: 'all' }}
|
||||
onClick={() => goTo(i)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.current.fill, border: `1px solid ${STATUS_COLORS.current.stroke}` }} /> Aktuell
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.confirmed.fill, border: `1px solid ${STATUS_COLORS.confirmed.stroke}` }} /> Bestaetigt
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.edited.fill, border: `1px solid ${STATUS_COLORS.edited.stroke}` }} /> Editiert
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.skipped.fill, border: `1px solid ${STATUS_COLORS.skipped.stroke}` }} /> Uebersprungen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Crops + Edit fields (1/3) */}
|
||||
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
|
||||
{currentEntry && (
|
||||
<>
|
||||
{/* Row crop */}
|
||||
{imageNatural.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
label="Gesamte Zeile"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Column crops */}
|
||||
{imageNatural.w > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{currentEntry.bbox_en.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_en}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
maxWidth={120}
|
||||
label="EN"
|
||||
/>
|
||||
)}
|
||||
{currentEntry.bbox_de.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_de}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
maxWidth={120}
|
||||
label="DE"
|
||||
/>
|
||||
)}
|
||||
{currentEntry.bbox_ex.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_ex}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
maxWidth={120}
|
||||
label="EX"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
currentEntry.confidence >= 70 ? 'bg-green-100 text-green-700' :
|
||||
currentEntry.confidence >= 40 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
Konfidenz: {currentEntry.confidence}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Edit fields */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">English</label>
|
||||
<input
|
||||
ref={enInputRef}
|
||||
type="text"
|
||||
value={editEn}
|
||||
onChange={e => setEditEn(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Deutsch</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDe}
|
||||
onChange={e => setEditDe(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Beispiel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editEx}
|
||||
onChange={e => setEditEx(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={confirmEntry}
|
||||
className="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 text-sm"
|
||||
title="Enter"
|
||||
>
|
||||
OK (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={skipEntry}
|
||||
className="flex-1 px-4 py-2.5 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 text-sm"
|
||||
title="Tab"
|
||||
>
|
||||
Skip (Tab)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => goTo(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
<span className="text-sm text-slate-500 font-medium">
|
||||
{currentIndex + 1} / {entries.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goTo(currentIndex + 1)}
|
||||
disabled={currentIndex === entries.length - 1}
|
||||
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress stats */}
|
||||
<div className="text-xs text-slate-400 text-center">
|
||||
{confirmedCount} bestaetigt · {editedCount} editiert · {skippedCount} uebersprungen · {progress}%
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
|
||||
Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
390
admin-v2/components/ocr/__tests__/BlockReviewPanel.test.tsx
Normal file
390
admin-v2/components/ocr/__tests__/BlockReviewPanel.test.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { BlockReviewPanel, BlockReviewSummary, type BlockReviewData } from '../BlockReviewPanel'
|
||||
import type { GridData, GridCell } from '../GridOverlay'
|
||||
|
||||
// Mock grid data
|
||||
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
|
||||
const cells: GridCell[][] = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row: GridCell[] = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
row.push({
|
||||
row: r,
|
||||
col: c,
|
||||
x: (c / cols) * 100,
|
||||
y: (r / rows) * 100,
|
||||
width: 100 / cols,
|
||||
height: 100 / rows,
|
||||
text: r === 0 || c === 0 ? '' : `cell-${r}-${c}`,
|
||||
confidence: 0.85,
|
||||
status: r === 0 || c === 0 ? 'empty' : 'recognized',
|
||||
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns: cols,
|
||||
cells,
|
||||
column_types: ['unknown', 'english', 'german'],
|
||||
column_boundaries: [0, 33.33, 66.66, 100],
|
||||
row_boundaries: [0, 33.33, 66.66, 100],
|
||||
deskew_angle: 0,
|
||||
stats: {
|
||||
recognized: 4,
|
||||
problematic: 0,
|
||||
empty: 5,
|
||||
total: 9,
|
||||
coverage: 0.44,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createMockMethodResults = () => ({
|
||||
vision_llm: {
|
||||
vocabulary: [
|
||||
{ english: 'word1', german: 'Wort1' },
|
||||
{ english: 'word2', german: 'Wort2' },
|
||||
],
|
||||
},
|
||||
tesseract: {
|
||||
vocabulary: [
|
||||
{ english: 'word1', german: 'Wort1' },
|
||||
{ english: 'word2', german: 'Wort2' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
describe('BlockReviewPanel', () => {
|
||||
const mockOnBlockChange = vi.fn()
|
||||
const mockOnApprove = vi.fn()
|
||||
const mockOnCorrect = vi.fn()
|
||||
const mockOnSkip = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the current block number', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Block 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display progress percentage', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
5: {
|
||||
blockNumber: 5,
|
||||
cell: createMockGrid().cells[1][1],
|
||||
methodResults: [],
|
||||
status: 'approved',
|
||||
correctedText: 'approved text',
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={reviewData}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('25%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show cell position information', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Position:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display method results', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Erkannte Texte:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSkip when skip button is clicked', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Überspringen'))
|
||||
expect(mockOnSkip).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('should show manual correction button', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('+ Manuell korrigieren')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correction input when correction button is clicked', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
|
||||
expect(screen.getByPlaceholderText('Korrekten Text eingeben...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCorrect when correction is submitted', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
|
||||
const input = screen.getByPlaceholderText('Korrekten Text eingeben...')
|
||||
fireEvent.change(input, { target: { value: 'corrected text' } })
|
||||
fireEvent.click(screen.getByText('Übernehmen'))
|
||||
|
||||
expect(mockOnCorrect).toHaveBeenCalledWith(5, 'corrected text')
|
||||
})
|
||||
|
||||
it('should show approved status when block is approved', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
5: {
|
||||
blockNumber: 5,
|
||||
cell: createMockGrid().cells[1][1],
|
||||
methodResults: [],
|
||||
status: 'approved',
|
||||
correctedText: 'approved text',
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={reviewData}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Freigegeben:/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable previous button on first block', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
const prevButton = screen.getByText('Zurück').closest('button')
|
||||
expect(prevButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show empty message when no blocks available', () => {
|
||||
const emptyGrid: GridData = {
|
||||
...createMockGrid(),
|
||||
cells: [[{
|
||||
row: 0,
|
||||
col: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
text: '',
|
||||
confidence: 0,
|
||||
status: 'empty'
|
||||
}]],
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={emptyGrid}
|
||||
methodResults={{}}
|
||||
currentBlockNumber={1}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Keine Blöcke zum Überprüfen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlockReviewSummary', () => {
|
||||
const mockOnBlockClick = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should display summary statistics', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text1' },
|
||||
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 'text2' },
|
||||
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'skipped' },
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={5}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Überprüfungsübersicht')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument() // approved count
|
||||
expect(screen.getByText('Freigegeben')).toBeInTheDocument()
|
||||
expect(screen.getByText('Korrigiert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Übersprungen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ausstehend')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onBlockClick when a block is clicked', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
5: { blockNumber: 5, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text' },
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={10}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Block 5'))
|
||||
expect(mockOnBlockClick).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('should show correct counts for each status', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't1' },
|
||||
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't2' },
|
||||
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 't3' },
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={5}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
// 2 approved, 1 corrected, 0 skipped, 2 pending
|
||||
const approvedCount = screen.getAllByText('2')[0]
|
||||
expect(approvedCount).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should truncate long corrected text', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
1: {
|
||||
blockNumber: 1,
|
||||
cell: {} as GridCell,
|
||||
methodResults: [],
|
||||
status: 'approved',
|
||||
correctedText: 'This is a very long text that should be truncated'
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={1}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/This is a very long t\.\.\./)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
267
admin-v2/components/ocr/__tests__/GridOverlay.test.tsx
Normal file
267
admin-v2/components/ocr/__tests__/GridOverlay.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from '../GridOverlay'
|
||||
import type { GridData, GridCell } from '../GridOverlay'
|
||||
|
||||
// Helper to create mock grid data
|
||||
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
|
||||
const cells: GridCell[][] = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row: GridCell[] = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
row.push({
|
||||
row: r,
|
||||
col: c,
|
||||
x: (c / cols) * 100,
|
||||
y: (r / rows) * 100,
|
||||
width: 100 / cols,
|
||||
height: 100 / rows,
|
||||
text: `cell-${r}-${c}`,
|
||||
confidence: 0.85,
|
||||
status: r === 0 ? 'empty' : c === 0 ? 'problematic' : 'recognized',
|
||||
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns: cols,
|
||||
cells,
|
||||
column_types: ['unknown', 'english', 'german'],
|
||||
column_boundaries: [0, 33.33, 66.66, 100],
|
||||
row_boundaries: [0, 33.33, 66.66, 100],
|
||||
deskew_angle: 0,
|
||||
stats: {
|
||||
recognized: 4,
|
||||
problematic: 2,
|
||||
empty: 3,
|
||||
manual: 0,
|
||||
total: 9,
|
||||
coverage: 0.67,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('getCellBlockNumber', () => {
|
||||
it('should return correct block number for first cell', () => {
|
||||
const grid = createMockGrid(3, 4)
|
||||
const cell: GridCell = { row: 0, col: 0, x: 0, y: 0, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(1)
|
||||
})
|
||||
|
||||
it('should return correct block number for cell in second row', () => {
|
||||
const grid = createMockGrid(3, 4)
|
||||
const cell: GridCell = { row: 1, col: 0, x: 0, y: 33, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(5)
|
||||
})
|
||||
|
||||
it('should return correct block number for last cell', () => {
|
||||
const grid = createMockGrid(3, 4)
|
||||
const cell: GridCell = { row: 2, col: 3, x: 75, y: 66, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(12)
|
||||
})
|
||||
|
||||
it('should calculate correctly for different grid sizes', () => {
|
||||
const grid = createMockGrid(5, 5)
|
||||
const cell: GridCell = { row: 2, col: 3, x: 60, y: 40, width: 20, height: 20, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(14) // row 2 * 5 cols + col 3 + 1 = 14
|
||||
})
|
||||
})
|
||||
|
||||
describe('GridOverlay', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} />)
|
||||
expect(document.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image when imageUrl is provided', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} imageUrl="https://example.com/image.jpg" />)
|
||||
const img = screen.getByAltText('Document')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/image.jpg')
|
||||
})
|
||||
|
||||
it('should call onCellClick when a non-empty cell is clicked', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const grid = createMockGrid()
|
||||
|
||||
render(<GridOverlay grid={grid} onCellClick={mockOnClick} />)
|
||||
|
||||
// Find a recognized cell (non-empty) and click it
|
||||
const recognizedCells = document.querySelectorAll('rect')
|
||||
// Click on a cell that should be clickable (recognized status)
|
||||
recognizedCells.forEach(rect => {
|
||||
if (rect.getAttribute('fill')?.includes('34, 197, 94')) {
|
||||
fireEvent.click(rect)
|
||||
}
|
||||
})
|
||||
|
||||
// onCellClick should have been called for recognized cells
|
||||
expect(mockOnClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onCellClick for empty cells', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const emptyGrid: GridData = {
|
||||
...createMockGrid(),
|
||||
cells: [[{ row: 0, col: 0, x: 0, y: 0, width: 100, height: 100, text: '', confidence: 0, status: 'empty' }]],
|
||||
}
|
||||
|
||||
render(<GridOverlay grid={emptyGrid} onCellClick={mockOnClick} showEmpty />)
|
||||
|
||||
const cell = document.querySelector('rect')
|
||||
if (cell) {
|
||||
fireEvent.click(cell)
|
||||
}
|
||||
|
||||
expect(mockOnClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show column labels when showLabels is true', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} showLabels />)
|
||||
|
||||
// Check for column type labels
|
||||
const svgTexts = document.querySelectorAll('text')
|
||||
const labels = Array.from(svgTexts).map(t => t.textContent)
|
||||
expect(labels).toContain('EN')
|
||||
expect(labels).toContain('DE')
|
||||
})
|
||||
|
||||
it('should hide empty cells when showEmpty is false', () => {
|
||||
const grid = createMockGrid()
|
||||
const emptyCellCount = grid.cells.flat().filter(c => c.status === 'empty').length
|
||||
|
||||
const { container } = render(<GridOverlay grid={grid} showEmpty={false} />)
|
||||
const allRects = container.querySelectorAll('g > rect')
|
||||
|
||||
// Should have fewer rects when empty cells are hidden
|
||||
const totalCells = grid.rows * grid.columns
|
||||
expect(allRects.length).toBeLessThan(totalCells * 2) // each cell can have multiple rects
|
||||
})
|
||||
|
||||
it('should show block numbers when showNumbers is true', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} showNumbers />)
|
||||
|
||||
const svgTexts = document.querySelectorAll('text')
|
||||
const numbers = Array.from(svgTexts).map(t => t.textContent).filter(t => /^\d+$/.test(t || ''))
|
||||
|
||||
expect(numbers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should highlight a specific block when highlightedBlockNumber is set', () => {
|
||||
const grid = createMockGrid()
|
||||
|
||||
render(<GridOverlay grid={grid} showNumbers highlightedBlockNumber={5} />)
|
||||
|
||||
// Check that there's a highlighted element (with indigo color)
|
||||
const rects = document.querySelectorAll('rect')
|
||||
const highlightedRect = Array.from(rects).find(
|
||||
rect => rect.getAttribute('stroke') === '#4f46e5'
|
||||
)
|
||||
|
||||
expect(highlightedRect).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<GridOverlay grid={createMockGrid()} className="custom-class" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render row and column boundary lines', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} />)
|
||||
|
||||
const lines = document.querySelectorAll('line')
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GridStats', () => {
|
||||
const mockStats = {
|
||||
recognized: 10,
|
||||
problematic: 3,
|
||||
empty: 5,
|
||||
manual: 2,
|
||||
total: 20,
|
||||
coverage: 0.75,
|
||||
}
|
||||
|
||||
it('should display recognized count', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Erkannt: 10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display problematic count when greater than 0', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Problematisch: 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display problematic when count is 0', () => {
|
||||
render(<GridStats stats={{ ...mockStats, problematic: 0 }} />)
|
||||
expect(screen.queryByText(/Problematisch:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display manual count when greater than 0', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Manuell: 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display manual when count is 0', () => {
|
||||
render(<GridStats stats={{ ...mockStats, manual: 0 }} />)
|
||||
expect(screen.queryByText(/Manuell:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display empty count', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Leer: 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display coverage percentage', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Abdeckung: 75%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display deskew angle when provided and non-zero', () => {
|
||||
render(<GridStats stats={mockStats} deskewAngle={2.5} />)
|
||||
expect(screen.getByText('Begradigt: 2.5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display deskew angle when 0', () => {
|
||||
render(<GridStats stats={mockStats} deskewAngle={0} />)
|
||||
expect(screen.queryByText(/Begradigt:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<GridStats stats={mockStats} className="custom-stats" />)
|
||||
expect(container.firstChild).toHaveClass('custom-stats')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GridLegend', () => {
|
||||
it('should display all status labels', () => {
|
||||
render(<GridLegend />)
|
||||
|
||||
expect(screen.getByText('Erkannt')).toBeInTheDocument()
|
||||
expect(screen.getByText('Problematisch')).toBeInTheDocument()
|
||||
expect(screen.getByText('Manuell korrigiert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Leer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render color indicators for each status', () => {
|
||||
const { container } = render(<GridLegend />)
|
||||
|
||||
const colorIndicators = container.querySelectorAll('.w-4.h-4.rounded')
|
||||
expect(colorIndicators.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<GridLegend className="custom-legend" />)
|
||||
expect(container.firstChild).toHaveClass('custom-legend')
|
||||
})
|
||||
})
|
||||
15
admin-v2/components/ocr/index.ts
Normal file
15
admin-v2/components/ocr/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* OCR Components
|
||||
*
|
||||
* Components for OCR grid detection and visualization.
|
||||
*/
|
||||
|
||||
export { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from './GridOverlay'
|
||||
export type { GridCell, GridData, CellStatus } from './GridOverlay'
|
||||
|
||||
export { CellCorrectionDialog } from './CellCorrectionDialog'
|
||||
|
||||
export { BlockReviewPanel, BlockReviewSummary } from './BlockReviewPanel'
|
||||
export type { BlockStatus, MethodResult, BlockReviewData } from './BlockReviewPanel'
|
||||
|
||||
export { GroundTruthPanel } from './GroundTruthPanel'
|
||||
Reference in New Issue
Block a user