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>
614 lines
18 KiB
TypeScript
614 lines
18 KiB
TypeScript
/**
|
|
* Training Metrics Component
|
|
*
|
|
* Real-time visualization of training progress with loss curves and metrics.
|
|
* Supports SSE (Server-Sent Events) for live updates during LoRA fine-tuning.
|
|
*
|
|
* Phase 3.3: Training Dashboard with Live Metrics
|
|
*/
|
|
|
|
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
|
// Training data point for charts
|
|
interface TrainingDataPoint {
|
|
epoch: number
|
|
step: number
|
|
loss: number
|
|
val_loss?: number
|
|
learning_rate: number
|
|
timestamp: number
|
|
}
|
|
|
|
// Training job status
|
|
interface TrainingStatus {
|
|
job_id: string
|
|
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
progress: number // 0-100
|
|
current_epoch: number
|
|
total_epochs: number
|
|
current_step: number
|
|
total_steps: number
|
|
elapsed_time_ms: number
|
|
estimated_remaining_ms: number
|
|
metrics: {
|
|
loss: number
|
|
val_loss?: number
|
|
accuracy?: number
|
|
learning_rate: number
|
|
}
|
|
history: TrainingDataPoint[]
|
|
error?: string
|
|
}
|
|
|
|
interface TrainingMetricsProps {
|
|
/** API base URL */
|
|
apiBase: string
|
|
/** Job ID to track (null for simulation mode) */
|
|
jobId?: string | null
|
|
/** Simulate training progress for demo */
|
|
simulateMode?: boolean
|
|
/** Custom class names */
|
|
className?: string
|
|
/** Callback when training completes */
|
|
onComplete?: (status: TrainingStatus) => void
|
|
/** Callback on error */
|
|
onError?: (error: string) => void
|
|
}
|
|
|
|
/**
|
|
* Custom hook for SSE-based training metrics
|
|
*/
|
|
export function useTrainingMetricsSSE(
|
|
apiBase: string,
|
|
jobId: string | null,
|
|
onUpdate?: (status: TrainingStatus) => void
|
|
) {
|
|
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
|
const [connected, setConnected] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const eventSourceRef = useRef<EventSource | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!jobId) return
|
|
|
|
const url = `${apiBase}/api/klausur/trocr/training/metrics/stream?job_id=${jobId}`
|
|
const eventSource = new EventSource(url)
|
|
eventSourceRef.current = eventSource
|
|
|
|
eventSource.onopen = () => {
|
|
setConnected(true)
|
|
setError(null)
|
|
}
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data) as TrainingStatus
|
|
setStatus(data)
|
|
onUpdate?.(data)
|
|
|
|
// Close connection when training is done
|
|
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
|
|
eventSource.close()
|
|
setConnected(false)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse SSE message:', e)
|
|
}
|
|
}
|
|
|
|
eventSource.onerror = () => {
|
|
setError('Verbindung zum Server verloren')
|
|
setConnected(false)
|
|
eventSource.close()
|
|
}
|
|
|
|
return () => {
|
|
eventSource.close()
|
|
setConnected(false)
|
|
}
|
|
}, [apiBase, jobId, onUpdate])
|
|
|
|
const disconnect = useCallback(() => {
|
|
eventSourceRef.current?.close()
|
|
setConnected(false)
|
|
}, [])
|
|
|
|
return { status, connected, error, disconnect }
|
|
}
|
|
|
|
/**
|
|
* Simple line chart component for loss visualization
|
|
*/
|
|
function LossChart({
|
|
data,
|
|
height = 200,
|
|
className = ''
|
|
}: {
|
|
data: TrainingDataPoint[]
|
|
height?: number
|
|
className?: string
|
|
}) {
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className={`flex items-center justify-center bg-slate-50 rounded-lg ${className}`} style={{ height }}>
|
|
<span className="text-slate-400 text-sm">Keine Daten</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Calculate bounds
|
|
const losses = data.map(d => d.loss)
|
|
const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!)
|
|
const allLosses = [...losses, ...valLosses]
|
|
const minLoss = Math.min(...allLosses) * 0.9
|
|
const maxLoss = Math.max(...allLosses) * 1.1
|
|
const lossRange = maxLoss - minLoss || 1
|
|
|
|
// SVG dimensions
|
|
const width = 400
|
|
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
|
|
const chartWidth = width - padding.left - padding.right
|
|
const chartHeight = height - padding.top - padding.bottom
|
|
|
|
// Generate path for loss line
|
|
const generatePath = (values: number[]) => {
|
|
if (values.length === 0) return ''
|
|
return values
|
|
.map((loss, idx) => {
|
|
const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth
|
|
const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight
|
|
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
|
})
|
|
.join(' ')
|
|
}
|
|
|
|
const lossPath = generatePath(losses)
|
|
const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : ''
|
|
|
|
// Y-axis labels
|
|
const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3))
|
|
|
|
return (
|
|
<svg viewBox={`0 0 ${width} ${height}`} className={`w-full ${className}`} preserveAspectRatio="xMidYMid meet">
|
|
{/* Grid lines */}
|
|
{[0, 0.5, 1].map((ratio, idx) => (
|
|
<line
|
|
key={idx}
|
|
x1={padding.left}
|
|
y1={padding.top + chartHeight * ratio}
|
|
x2={width - padding.right}
|
|
y2={padding.top + chartHeight * ratio}
|
|
stroke="#e2e8f0"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
|
|
{/* Y-axis labels */}
|
|
{yLabels.map((label, idx) => (
|
|
<text
|
|
key={idx}
|
|
x={padding.left - 5}
|
|
y={padding.top + (idx / 2) * chartHeight + 4}
|
|
textAnchor="end"
|
|
className="fill-slate-500 text-xs"
|
|
>
|
|
{label}
|
|
</text>
|
|
))}
|
|
|
|
{/* X-axis label */}
|
|
<text
|
|
x={width / 2}
|
|
y={height - 5}
|
|
textAnchor="middle"
|
|
className="fill-slate-500 text-xs"
|
|
>
|
|
Epoche / Schritt
|
|
</text>
|
|
|
|
{/* Y-axis label */}
|
|
<text
|
|
x={12}
|
|
y={height / 2}
|
|
textAnchor="middle"
|
|
className="fill-slate-500 text-xs"
|
|
transform={`rotate(-90, 12, ${height / 2})`}
|
|
>
|
|
Loss
|
|
</text>
|
|
|
|
{/* Training loss line */}
|
|
<path
|
|
d={lossPath}
|
|
fill="none"
|
|
stroke="#8b5cf6"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
|
|
{/* Validation loss line (dashed) */}
|
|
{valLossPath && (
|
|
<path
|
|
d={valLossPath}
|
|
fill="none"
|
|
stroke="#22c55e"
|
|
strokeWidth="2"
|
|
strokeDasharray="5,5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
)}
|
|
|
|
{/* Data points */}
|
|
{data.map((point, idx) => {
|
|
const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth
|
|
const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight
|
|
return (
|
|
<circle
|
|
key={idx}
|
|
cx={x}
|
|
cy={y}
|
|
r="3"
|
|
fill="#8b5cf6"
|
|
className="hover:r-4 transition-all"
|
|
>
|
|
<title>Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)}</title>
|
|
</circle>
|
|
)
|
|
})}
|
|
|
|
{/* Legend */}
|
|
<g transform={`translate(${padding.left}, ${height - 25})`}>
|
|
<line x1="0" y1="0" x2="20" y2="0" stroke="#8b5cf6" strokeWidth="2" />
|
|
<text x="25" y="4" className="fill-slate-600 text-xs">Training Loss</text>
|
|
|
|
{valLossPath && (
|
|
<>
|
|
<line x1="120" y1="0" x2="140" y2="0" stroke="#22c55e" strokeWidth="2" strokeDasharray="5,5" />
|
|
<text x="145" y="4" className="fill-slate-600 text-xs">Val Loss</text>
|
|
</>
|
|
)}
|
|
</g>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Progress ring component
|
|
*/
|
|
function ProgressRing({
|
|
progress,
|
|
size = 80,
|
|
strokeWidth = 6,
|
|
className = ''
|
|
}: {
|
|
progress: number
|
|
size?: number
|
|
strokeWidth?: number
|
|
className?: string
|
|
}) {
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = radius * 2 * Math.PI
|
|
const offset = circumference - (progress / 100) * circumference
|
|
|
|
return (
|
|
<svg width={size} height={size} className={className}>
|
|
{/* Background circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#e2e8f0"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
{/* Progress circle */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="#8b5cf6"
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
className="transition-all duration-300"
|
|
/>
|
|
{/* Center text */}
|
|
<text
|
|
x={size / 2}
|
|
y={size / 2}
|
|
textAnchor="middle"
|
|
dominantBaseline="middle"
|
|
className="fill-slate-900 font-bold text-lg"
|
|
>
|
|
{progress.toFixed(0)}%
|
|
</text>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Format time duration
|
|
*/
|
|
function formatDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`
|
|
const seconds = Math.floor(ms / 1000)
|
|
if (seconds < 60) return `${seconds}s`
|
|
const minutes = Math.floor(seconds / 60)
|
|
const remainingSeconds = seconds % 60
|
|
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
|
|
const hours = Math.floor(minutes / 60)
|
|
const remainingMinutes = minutes % 60
|
|
return `${hours}h ${remainingMinutes}m`
|
|
}
|
|
|
|
/**
|
|
* Training Metrics Component - Full Dashboard
|
|
*/
|
|
export function TrainingMetrics({
|
|
apiBase,
|
|
jobId = null,
|
|
simulateMode = false,
|
|
className = '',
|
|
onComplete,
|
|
onError
|
|
}: TrainingMetricsProps) {
|
|
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
|
const [simulationInterval, setSimulationInterval] = useState<NodeJS.Timeout | null>(null)
|
|
|
|
// SSE hook for real connection
|
|
const { status: sseStatus, connected, error } = useTrainingMetricsSSE(
|
|
apiBase,
|
|
simulateMode ? null : jobId,
|
|
(newStatus) => {
|
|
if (newStatus.status === 'completed') {
|
|
onComplete?.(newStatus)
|
|
} else if (newStatus.status === 'failed' && newStatus.error) {
|
|
onError?.(newStatus.error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Use SSE status if available
|
|
useEffect(() => {
|
|
if (sseStatus) {
|
|
setStatus(sseStatus)
|
|
}
|
|
}, [sseStatus])
|
|
|
|
// Simulation mode for demo
|
|
useEffect(() => {
|
|
if (!simulateMode) return
|
|
|
|
let step = 0
|
|
const totalSteps = 100
|
|
const history: TrainingDataPoint[] = []
|
|
|
|
const interval = setInterval(() => {
|
|
step++
|
|
const epoch = Math.floor((step / totalSteps) * 3) + 1
|
|
const progress = (step / totalSteps) * 100
|
|
|
|
// Simulate decreasing loss with noise
|
|
const baseLoss = 2.5 * Math.exp(-step / 30) + 0.1
|
|
const noise = (Math.random() - 0.5) * 0.1
|
|
const loss = Math.max(0.05, baseLoss + noise)
|
|
|
|
const dataPoint: TrainingDataPoint = {
|
|
epoch,
|
|
step,
|
|
loss,
|
|
val_loss: step % 10 === 0 ? loss * (1 + Math.random() * 0.2) : undefined,
|
|
learning_rate: 0.00005 * Math.pow(0.95, epoch - 1),
|
|
timestamp: Date.now()
|
|
}
|
|
history.push(dataPoint)
|
|
|
|
const newStatus: TrainingStatus = {
|
|
job_id: 'simulation',
|
|
status: step >= totalSteps ? 'completed' : 'running',
|
|
progress,
|
|
current_epoch: epoch,
|
|
total_epochs: 3,
|
|
current_step: step,
|
|
total_steps: totalSteps,
|
|
elapsed_time_ms: step * 500,
|
|
estimated_remaining_ms: (totalSteps - step) * 500,
|
|
metrics: {
|
|
loss,
|
|
val_loss: dataPoint.val_loss,
|
|
accuracy: 0.7 + (step / totalSteps) * 0.25,
|
|
learning_rate: dataPoint.learning_rate
|
|
},
|
|
history: [...history]
|
|
}
|
|
|
|
setStatus(newStatus)
|
|
|
|
if (step >= totalSteps) {
|
|
clearInterval(interval)
|
|
onComplete?.(newStatus)
|
|
}
|
|
}, 500)
|
|
|
|
setSimulationInterval(interval)
|
|
|
|
return () => {
|
|
clearInterval(interval)
|
|
}
|
|
}, [simulateMode, onComplete])
|
|
|
|
// Stop simulation
|
|
const stopSimulation = () => {
|
|
if (simulationInterval) {
|
|
clearInterval(simulationInterval)
|
|
setSimulationInterval(null)
|
|
}
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className={`bg-red-50 border border-red-200 rounded-xl p-6 ${className}`}>
|
|
<div className="flex items-center gap-3 text-red-700">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>{error}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!status) {
|
|
return (
|
|
<div className={`bg-slate-50 rounded-xl p-6 ${className}`}>
|
|
<div className="flex items-center justify-center gap-3 text-slate-500">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600"></div>
|
|
<span>Warte auf Training-Daten...</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-lg font-semibold text-slate-900">Training Dashboard</h3>
|
|
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
|
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
|
status.status === 'completed' ? 'bg-green-100 text-green-700' :
|
|
status.status === 'failed' ? 'bg-red-100 text-red-700' :
|
|
'bg-slate-100 text-slate-700'
|
|
}`}>
|
|
{status.status === 'running' && (
|
|
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
|
)}
|
|
{status.status === 'running' ? 'Laeuft' :
|
|
status.status === 'completed' ? 'Abgeschlossen' :
|
|
status.status === 'failed' ? 'Fehlgeschlagen' :
|
|
status.status}
|
|
</div>
|
|
{connected && (
|
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
|
Live
|
|
</span>
|
|
)}
|
|
</div>
|
|
{simulateMode && status.status === 'running' && (
|
|
<button
|
|
onClick={stopSimulation}
|
|
className="px-3 py-1 text-sm bg-slate-200 hover:bg-slate-300 text-slate-700 rounded transition-colors"
|
|
>
|
|
Stoppen
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div className="p-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Progress section */}
|
|
<div className="flex flex-col items-center justify-center gap-4">
|
|
<ProgressRing progress={status.progress} size={120} strokeWidth={8} />
|
|
<div className="text-center">
|
|
<div className="text-sm text-slate-500">
|
|
Epoche {status.current_epoch} / {status.total_epochs}
|
|
</div>
|
|
<div className="text-xs text-slate-400">
|
|
Schritt {status.current_step} / {status.total_steps}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loss chart */}
|
|
<div className="lg:col-span-2">
|
|
<h4 className="text-sm font-medium text-slate-700 mb-2">Loss-Verlauf</h4>
|
|
<LossChart data={status.history} height={180} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics grid */}
|
|
<div className="px-4 pb-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-slate-900">{status.metrics.loss.toFixed(4)}</div>
|
|
<div className="text-xs text-slate-500">Aktueller Loss</div>
|
|
</div>
|
|
{status.metrics.val_loss !== undefined && (
|
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-slate-900">{status.metrics.val_loss.toFixed(4)}</div>
|
|
<div className="text-xs text-slate-500">Validation Loss</div>
|
|
</div>
|
|
)}
|
|
{status.metrics.accuracy !== undefined && (
|
|
<div className="bg-green-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-green-700">{(status.metrics.accuracy * 100).toFixed(1)}%</div>
|
|
<div className="text-xs text-slate-500">Genauigkeit</div>
|
|
</div>
|
|
)}
|
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-slate-900">{status.metrics.learning_rate.toExponential(1)}</div>
|
|
<div className="text-xs text-slate-500">Learning Rate</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Time info */}
|
|
<div className="px-4 pb-4 flex items-center justify-between text-sm text-slate-500">
|
|
<div>Vergangen: {formatDuration(status.elapsed_time_ms)}</div>
|
|
{status.status === 'running' && (
|
|
<div>Geschaetzt: {formatDuration(status.estimated_remaining_ms)} verbleibend</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Compact Training Metrics for inline display
|
|
*/
|
|
export function TrainingMetricsCompact({
|
|
progress,
|
|
currentEpoch,
|
|
totalEpochs,
|
|
loss,
|
|
status,
|
|
className = ''
|
|
}: {
|
|
progress: number
|
|
currentEpoch: number
|
|
totalEpochs: number
|
|
loss: number
|
|
status: 'running' | 'completed' | 'failed'
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`flex items-center gap-4 ${className}`}>
|
|
<ProgressRing progress={progress} size={48} strokeWidth={4} />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="font-medium text-slate-900">Epoche {currentEpoch}/{totalEpochs}</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
|
status === 'running' ? 'bg-blue-100 text-blue-700' :
|
|
status === 'completed' ? 'bg-green-100 text-green-700' :
|
|
'bg-red-100 text-red-700'
|
|
}`}>
|
|
{status === 'running' ? 'Laeuft' : status === 'completed' ? 'Fertig' : 'Fehler'}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-slate-500 mt-1">
|
|
Loss: {loss.toFixed(4)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TrainingMetrics
|