fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
439
admin-v2/components/ai/ConfidenceHeatmap.tsx
Normal file
439
admin-v2/components/ai/ConfidenceHeatmap.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Confidence Heatmap Component
|
||||
*
|
||||
* Displays an OCR result with visual confidence overlay on the original image.
|
||||
* Shows word-level or character-level confidence using color gradients.
|
||||
*
|
||||
* Phase 3.1: Wow-Feature for Magic Help
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
|
||||
interface WordBox {
|
||||
text: string
|
||||
confidence: number
|
||||
bbox: [number, number, number, number] // [x, y, width, height] as percentages (0-100)
|
||||
}
|
||||
|
||||
interface ConfidenceHeatmapProps {
|
||||
/** Image source URL or data URL */
|
||||
imageSrc: string
|
||||
/** Detected text result */
|
||||
text: string
|
||||
/** Overall confidence score (0-1) */
|
||||
confidence: number
|
||||
/** Word-level boxes with confidence */
|
||||
wordBoxes?: WordBox[]
|
||||
/** Character-level confidences (aligned with text) */
|
||||
charConfidences?: number[]
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Show legend */
|
||||
showLegend?: boolean
|
||||
/** Allow toggling overlay visibility */
|
||||
toggleable?: boolean
|
||||
/** Callback when a word box is clicked */
|
||||
onWordClick?: (word: WordBox) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color based on confidence value
|
||||
* Green (high) -> Yellow (medium) -> Red (low)
|
||||
*/
|
||||
function getConfidenceColor(confidence: number, opacity = 0.5): string {
|
||||
if (confidence >= 0.9) {
|
||||
return `rgba(34, 197, 94, ${opacity})` // green-500
|
||||
} else if (confidence >= 0.7) {
|
||||
return `rgba(234, 179, 8, ${opacity})` // yellow-500
|
||||
} else if (confidence >= 0.5) {
|
||||
return `rgba(249, 115, 22, ${opacity})` // orange-500
|
||||
} else {
|
||||
return `rgba(239, 68, 68, ${opacity})` // red-500
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get border color (more saturated version)
|
||||
*/
|
||||
function getConfidenceBorderColor(confidence: number): string {
|
||||
if (confidence >= 0.9) return '#22c55e' // green-500
|
||||
if (confidence >= 0.7) return '#eab308' // yellow-500
|
||||
if (confidence >= 0.5) return '#f97316' // orange-500
|
||||
return '#ef4444' // red-500
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence Heatmap Component
|
||||
*/
|
||||
export function ConfidenceHeatmap({
|
||||
imageSrc,
|
||||
text,
|
||||
confidence,
|
||||
wordBoxes = [],
|
||||
charConfidences = [],
|
||||
className = '',
|
||||
showLegend = true,
|
||||
toggleable = true,
|
||||
onWordClick
|
||||
}: ConfidenceHeatmapProps) {
|
||||
const [showOverlay, setShowOverlay] = useState(true)
|
||||
const [hoveredWord, setHoveredWord] = useState<WordBox | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate simulated word boxes if not provided
|
||||
const displayBoxes = useMemo(() => {
|
||||
if (wordBoxes.length > 0) return wordBoxes
|
||||
|
||||
// Simulate word boxes from text and char confidences
|
||||
if (!text || charConfidences.length === 0) return []
|
||||
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0)
|
||||
const boxes: WordBox[] = []
|
||||
let charIndex = 0
|
||||
|
||||
words.forEach((word, idx) => {
|
||||
// Calculate average confidence for this word
|
||||
const wordConfidences = charConfidences.slice(charIndex, charIndex + word.length)
|
||||
const avgConfidence = wordConfidences.length > 0
|
||||
? wordConfidences.reduce((a, b) => a + b, 0) / wordConfidences.length
|
||||
: confidence
|
||||
|
||||
// Simulate bbox positions (simple grid layout for demo)
|
||||
const wordsPerRow = 5
|
||||
const row = Math.floor(idx / wordsPerRow)
|
||||
const col = idx % wordsPerRow
|
||||
const wordWidth = Math.min(18, 5 + word.length * 1.5)
|
||||
|
||||
boxes.push({
|
||||
text: word,
|
||||
confidence: avgConfidence,
|
||||
bbox: [
|
||||
5 + col * 19, // x
|
||||
10 + row * 12, // y
|
||||
wordWidth, // width
|
||||
8 // height
|
||||
]
|
||||
})
|
||||
|
||||
charIndex += word.length + 1 // +1 for space
|
||||
})
|
||||
|
||||
return boxes
|
||||
}, [text, wordBoxes, charConfidences, confidence])
|
||||
|
||||
// Handle mouse wheel zoom
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
setZoom(prev => Math.max(1, Math.min(3, prev + delta)))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle panning
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsPanning(true)
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isPanning && zoom > 1) {
|
||||
const dx = e.clientX - lastPanPoint.x
|
||||
const dy = e.clientY - lastPanPoint.y
|
||||
setPan(prev => ({
|
||||
x: Math.max(-100, Math.min(100, prev.x + dx / zoom)),
|
||||
y: Math.max(-100, Math.min(100, prev.y + dy / zoom))
|
||||
}))
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false)
|
||||
}
|
||||
|
||||
// Reset zoom and pan
|
||||
const resetView = () => {
|
||||
setZoom(1)
|
||||
setPan({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{toggleable && (
|
||||
<button
|
||||
onClick={() => setShowOverlay(prev => !prev)}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||
showOverlay
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{showOverlay ? 'Overlay An' : 'Overlay Aus'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-slate-100 rounded p-1">
|
||||
<button
|
||||
onClick={() => setZoom(prev => Math.max(1, prev - 0.25))}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs font-mono w-12 text-center">{(zoom * 100).toFixed(0)}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{zoom > 1 && (
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors ml-1"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall confidence badge */}
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
confidence >= 0.9 ? 'bg-green-100 text-green-700' :
|
||||
confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
Gesamt: {(confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container with overlay */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-100"
|
||||
style={{ cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="relative transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
>
|
||||
{/* Original image */}
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="OCR Dokument"
|
||||
className="w-full h-auto"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* SVG Overlay */}
|
||||
{showOverlay && displayBoxes.length > 0 && (
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{displayBoxes.map((box, idx) => (
|
||||
<g
|
||||
key={idx}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onMouseEnter={() => setHoveredWord(box)}
|
||||
onMouseLeave={() => setHoveredWord(null)}
|
||||
onClick={() => onWordClick?.(box)}
|
||||
>
|
||||
{/* Background fill */}
|
||||
<rect
|
||||
x={box.bbox[0]}
|
||||
y={box.bbox[1]}
|
||||
width={box.bbox[2]}
|
||||
height={box.bbox[3]}
|
||||
fill={getConfidenceColor(box.confidence, 0.3)}
|
||||
stroke={getConfidenceBorderColor(box.confidence)}
|
||||
strokeWidth="0.3"
|
||||
rx="0.5"
|
||||
className="transition-all duration-150"
|
||||
style={{
|
||||
filter: hoveredWord === box ? 'brightness(1.2)' : 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hover highlight */}
|
||||
{hoveredWord === box && (
|
||||
<rect
|
||||
x={box.bbox[0] - 0.5}
|
||||
y={box.bbox[1] - 0.5}
|
||||
width={box.bbox[2] + 1}
|
||||
height={box.bbox[3] + 1}
|
||||
fill="none"
|
||||
stroke="#7c3aed"
|
||||
strokeWidth="0.5"
|
||||
rx="0.5"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hovered word tooltip */}
|
||||
{hoveredWord && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-900 text-white text-sm rounded-lg shadow-lg z-10">
|
||||
<div className="font-mono">"{hoveredWord.text}"</div>
|
||||
<div className="text-slate-300 text-xs">
|
||||
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45 -mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(34, 197, 94, 0.5)' }} />
|
||||
<span>>90%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(234, 179, 8, 0.5)' }} />
|
||||
<span>70-90%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(249, 115, 22, 0.5)' }} />
|
||||
<span>50-70%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(239, 68, 68, 0.5)' }} />
|
||||
<span><50%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<p className="mt-2 text-xs text-slate-400 text-center">
|
||||
Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline character confidence display
|
||||
* Shows text with color-coded background for each character
|
||||
*/
|
||||
interface InlineConfidenceTextProps {
|
||||
text: string
|
||||
charConfidences: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InlineConfidenceText({
|
||||
text,
|
||||
charConfidences,
|
||||
className = ''
|
||||
}: InlineConfidenceTextProps) {
|
||||
if (charConfidences.length === 0) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`font-mono ${className}`}>
|
||||
{text.split('').map((char, idx) => {
|
||||
const conf = charConfidences[idx] ?? 0.5
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="relative group"
|
||||
style={{ backgroundColor: getConfidenceColor(conf, 0.3) }}
|
||||
title={`'${char}': ${(conf * 100).toFixed(0)}%`}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence Statistics Summary
|
||||
*/
|
||||
interface ConfidenceStatsProps {
|
||||
wordBoxes: WordBox[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ConfidenceStats({ wordBoxes, className = '' }: ConfidenceStatsProps) {
|
||||
const stats = useMemo(() => {
|
||||
if (wordBoxes.length === 0) return null
|
||||
|
||||
const confidences = wordBoxes.map(w => w.confidence)
|
||||
const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length
|
||||
const min = Math.min(...confidences)
|
||||
const max = Math.max(...confidences)
|
||||
const highConf = confidences.filter(c => c >= 0.9).length
|
||||
const lowConf = confidences.filter(c => c < 0.7).length
|
||||
|
||||
return { avg, min, max, highConf, lowConf, total: wordBoxes.length }
|
||||
}, [wordBoxes])
|
||||
|
||||
if (!stats) return null
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 md:grid-cols-5 gap-3 ${className}`}>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.avg * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Durchschnitt</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.min * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Minimum</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.max * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Maximum</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-700">{stats.highConf}</div>
|
||||
<div className="text-xs text-slate-500">Sicher (>90%)</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-red-700">{stats.lowConf}</div>
|
||||
<div className="text-xs text-slate-500">Unsicher (<70%)</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfidenceHeatmap
|
||||
Reference in New Issue
Block a user