[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)
Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
226
admin-lehrer/components/ai/LossChart.tsx
Normal file
226
admin-lehrer/components/ai/LossChart.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* LossChart & ProgressRing Components
|
||||
*
|
||||
* SVG-based visualizations for training metrics:
|
||||
* - LossChart: Line chart for training/validation loss curves
|
||||
* - ProgressRing: Circular progress indicator
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import type { TrainingDataPoint } from './training-metrics-types'
|
||||
|
||||
/**
|
||||
* Simple line chart component for loss visualization
|
||||
*/
|
||||
export 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
|
||||
*/
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -9,344 +9,15 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TrainingStatus, TrainingMetricsProps, TrainingDataPoint } from './training-metrics-types'
|
||||
import { formatDuration } from './training-metrics-types'
|
||||
import { useTrainingMetricsSSE } from './useTrainingMetricsSSE'
|
||||
import { LossChart, ProgressRing } from './LossChart'
|
||||
|
||||
// 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`
|
||||
}
|
||||
// Re-export public API so existing imports keep working
|
||||
export { useTrainingMetricsSSE } from './useTrainingMetricsSSE'
|
||||
export type { TrainingStatus, TrainingDataPoint, TrainingMetricsProps } from './training-metrics-types'
|
||||
|
||||
/**
|
||||
* Training Metrics Component - Full Dashboard
|
||||
|
||||
66
admin-lehrer/components/ai/training-metrics-types.ts
Normal file
66
admin-lehrer/components/ai/training-metrics-types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Training Metrics Types & Utilities
|
||||
*
|
||||
* Shared types for training dashboard components.
|
||||
*/
|
||||
|
||||
/** Training data point for charts */
|
||||
export interface TrainingDataPoint {
|
||||
epoch: number
|
||||
step: number
|
||||
loss: number
|
||||
val_loss?: number
|
||||
learning_rate: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Training job status */
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time duration from milliseconds to human-readable string
|
||||
*/
|
||||
export 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`
|
||||
}
|
||||
72
admin-lehrer/components/ai/useTrainingMetricsSSE.ts
Normal file
72
admin-lehrer/components/ai/useTrainingMetricsSSE.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* useTrainingMetricsSSE Hook
|
||||
*
|
||||
* SSE (Server-Sent Events) hook for real-time training metrics.
|
||||
* Connects to the training metrics stream and provides live updates.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { TrainingStatus } from './training-metrics-types'
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user