Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1232 lines
52 KiB
TypeScript
1232 lines
52 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Quality Dashboard - BQAS (Breakpilot Quality Assurance System)
|
|
*
|
|
* Umfassendes Qualitaetssicherungs-Dashboard mit:
|
|
* - Golden Test Suite Ergebnisse
|
|
* - Synthetic Test Generierung
|
|
* - Regression Tracking
|
|
* - Test Run Historie
|
|
* - RAG Correction Tests
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import AdminLayout from '@/components/admin/AdminLayout'
|
|
|
|
// Types
|
|
interface TestResult {
|
|
test_id: string
|
|
test_name: string
|
|
passed: boolean
|
|
composite_score: number
|
|
intent_accuracy: number
|
|
faithfulness: number
|
|
relevance: number
|
|
coherence: number
|
|
safety: string
|
|
reasoning: string
|
|
expected_intent: string
|
|
detected_intent: string
|
|
}
|
|
|
|
interface TestRun {
|
|
id: number
|
|
timestamp: string
|
|
git_commit: string
|
|
golden_score: number
|
|
synthetic_score: number
|
|
total_tests: number
|
|
passed_tests: number
|
|
failed_tests: number
|
|
duration_seconds: number
|
|
}
|
|
|
|
interface BQASMetrics {
|
|
total_tests: number
|
|
passed_tests: number
|
|
failed_tests: number
|
|
avg_intent_accuracy: number
|
|
avg_faithfulness: number
|
|
avg_relevance: number
|
|
avg_coherence: number
|
|
safety_pass_rate: number
|
|
avg_composite_score: number
|
|
scores_by_intent: Record<string, number>
|
|
failed_test_ids: string[]
|
|
}
|
|
|
|
interface TrendData {
|
|
dates: string[]
|
|
scores: number[]
|
|
trend: 'improving' | 'stable' | 'declining' | 'insufficient_data'
|
|
}
|
|
|
|
// API Configuration
|
|
const VOICE_SERVICE_URL = process.env.NEXT_PUBLIC_VOICE_SERVICE_URL || 'http://localhost:8091'
|
|
|
|
// Components
|
|
function MetricCard({
|
|
title,
|
|
value,
|
|
subtitle,
|
|
trend,
|
|
color = 'blue',
|
|
}: {
|
|
title: string
|
|
value: string | number
|
|
subtitle?: string
|
|
trend?: 'up' | 'down' | 'stable'
|
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
|
|
}) {
|
|
const colorClasses = {
|
|
blue: 'bg-blue-50 border-blue-200',
|
|
green: 'bg-emerald-50 border-emerald-200',
|
|
red: 'bg-red-50 border-red-200',
|
|
yellow: 'bg-amber-50 border-amber-200',
|
|
purple: 'bg-purple-50 border-purple-200',
|
|
}
|
|
|
|
const trendIcons = {
|
|
up: (
|
|
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
</svg>
|
|
),
|
|
down: (
|
|
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
</svg>
|
|
),
|
|
stable: (
|
|
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
|
</svg>
|
|
),
|
|
}
|
|
|
|
return (
|
|
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-600">{title}</p>
|
|
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
|
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
|
|
</div>
|
|
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TestSuiteCard({
|
|
title,
|
|
description,
|
|
metrics,
|
|
onRun,
|
|
isRunning,
|
|
lastRun,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
metrics?: BQASMetrics
|
|
onRun: () => void
|
|
isRunning: boolean
|
|
lastRun?: string
|
|
}) {
|
|
const passRate = metrics ? (metrics.passed_tests / metrics.total_tests) * 100 : 0
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
|
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
|
</div>
|
|
<button
|
|
onClick={onRun}
|
|
disabled={isRunning}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
isRunning
|
|
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
|
: 'bg-primary-600 text-white hover:bg-primary-700'
|
|
}`}
|
|
>
|
|
{isRunning ? (
|
|
<span className="flex items-center gap-2">
|
|
<svg className="animate-spin h-4 w-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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
Laeuft...
|
|
</span>
|
|
) : (
|
|
'Tests starten'
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{metrics && (
|
|
<div className="mt-6">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-slate-600">Pass Rate</span>
|
|
<span className="font-medium text-slate-900">{passRate.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="mt-2 h-2 bg-slate-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
|
}`}
|
|
style={{ width: `${passRate}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-slate-900">{metrics.total_tests}</p>
|
|
<p className="text-xs text-slate-500">Tests</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-emerald-600">{metrics.passed_tests}</p>
|
|
<p className="text-xs text-slate-500">Bestanden</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-red-600">{metrics.failed_tests}</p>
|
|
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-4 border-t border-slate-100">
|
|
<p className="text-xs text-slate-500">
|
|
Durchschnittlicher Score: <span className="font-medium">{metrics.avg_composite_score.toFixed(2)}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{lastRun && (
|
|
<p className="mt-4 text-xs text-slate-400">Letzter Lauf: {new Date(lastRun).toLocaleString('de-DE')}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TrendChart({ data }: { data: TrendData }) {
|
|
if (!data || data.dates.length === 0) {
|
|
return (
|
|
<div className="h-48 flex items-center justify-center text-slate-400">
|
|
Keine Trend-Daten verfuegbar
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const maxScore = Math.max(...data.scores, 5)
|
|
const minScore = Math.min(...data.scores, 0)
|
|
const range = maxScore - minScore || 1
|
|
|
|
return (
|
|
<div className="h-48 relative">
|
|
{/* Y-Axis Labels */}
|
|
<div className="absolute left-0 top-0 bottom-4 w-8 flex flex-col justify-between text-xs text-slate-400">
|
|
<span>{maxScore.toFixed(1)}</span>
|
|
<span>{((maxScore + minScore) / 2).toFixed(1)}</span>
|
|
<span>{minScore.toFixed(1)}</span>
|
|
</div>
|
|
|
|
{/* Chart Area */}
|
|
<div className="ml-10 h-full pr-4">
|
|
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
|
{/* Grid Lines */}
|
|
<line x1="0" y1="0" x2="100" y2="0" stroke="#e2e8f0" strokeWidth="0.5" />
|
|
<line x1="0" y1="50" x2="100" y2="50" stroke="#e2e8f0" strokeWidth="0.5" />
|
|
<line x1="0" y1="100" x2="100" y2="100" stroke="#e2e8f0" strokeWidth="0.5" />
|
|
|
|
{/* Line Chart */}
|
|
<polyline
|
|
fill="none"
|
|
stroke="#0ea5e9"
|
|
strokeWidth="2"
|
|
points={data.scores
|
|
.map((score, i) => {
|
|
const x = (i / (data.scores.length - 1 || 1)) * 100
|
|
const y = 100 - ((score - minScore) / range) * 100
|
|
return `${x},${y}`
|
|
})
|
|
.join(' ')}
|
|
/>
|
|
|
|
{/* Data Points */}
|
|
{data.scores.map((score, i) => {
|
|
const x = (i / (data.scores.length - 1 || 1)) * 100
|
|
const y = 100 - ((score - minScore) / range) * 100
|
|
return <circle key={i} cx={x} cy={y} r="2" fill="#0ea5e9" />
|
|
})}
|
|
</svg>
|
|
</div>
|
|
|
|
{/* X-Axis Labels */}
|
|
<div className="ml-10 flex justify-between text-xs text-slate-400 mt-1">
|
|
{data.dates.slice(0, 5).map((date, i) => (
|
|
<span key={i}>{new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Trend Indicator */}
|
|
<div className="absolute top-2 right-2">
|
|
<span
|
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
|
data.trend === 'improving'
|
|
? 'bg-emerald-100 text-emerald-700'
|
|
: data.trend === 'declining'
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-slate-100 text-slate-700'
|
|
}`}
|
|
>
|
|
{data.trend === 'improving' ? 'Verbessernd' : data.trend === 'declining' ? 'Verschlechternd' : 'Stabil'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TestRunsTable({ runs }: { runs: TestRun[] }) {
|
|
if (runs.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-slate-400">
|
|
Keine Test-Laeufe vorhanden
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-slate-200">
|
|
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
|
|
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
|
|
<th className="text-left py-3 px-4 font-medium text-slate-600">Commit</th>
|
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Golden Score</th>
|
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
|
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
|
|
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{runs.map((run) => (
|
|
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
<td className="py-3 px-4 font-mono text-slate-900">#{run.id}</td>
|
|
<td className="py-3 px-4 text-slate-600">
|
|
{new Date(run.timestamp).toLocaleString('de-DE')}
|
|
</td>
|
|
<td className="py-3 px-4 font-mono text-xs text-slate-500">
|
|
{run.git_commit?.slice(0, 7) || '-'}
|
|
</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<span
|
|
className={`font-medium ${
|
|
run.golden_score >= 4 ? 'text-emerald-600' : run.golden_score >= 3 ? 'text-amber-600' : 'text-red-600'
|
|
}`}
|
|
>
|
|
{run.golden_score.toFixed(2)}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<span className="text-emerald-600">{run.passed_tests}</span>
|
|
<span className="text-slate-400"> / </span>
|
|
<span className="text-red-600">{run.failed_tests}</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-right text-slate-500">
|
|
{run.duration_seconds.toFixed(1)}s
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IntentScoresChart({ scores }: { scores: Record<string, number> }) {
|
|
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1])
|
|
|
|
if (entries.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-slate-400">
|
|
Keine Intent-Scores verfuegbar
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{entries.map(([intent, score]) => (
|
|
<div key={intent}>
|
|
<div className="flex items-center justify-between text-sm mb-1">
|
|
<span className="text-slate-600 truncate max-w-[200px]">{intent.replace(/_/g, ' ')}</span>
|
|
<span
|
|
className={`font-medium ${
|
|
score >= 4 ? 'text-emerald-600' : score >= 3 ? 'text-amber-600' : 'text-red-600'
|
|
}`}
|
|
>
|
|
{score.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
score >= 4 ? 'bg-emerald-500' : score >= 3 ? 'bg-amber-500' : 'bg-red-500'
|
|
}`}
|
|
style={{ width: `${(score / 5) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FailedTestsList({ testIds, onViewDetails }: { testIds: string[]; onViewDetails?: (id: string) => void }) {
|
|
if (testIds.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-emerald-600">
|
|
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
Alle Tests bestanden!
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
|
{testIds.map((testId) => (
|
|
<div
|
|
key={testId}
|
|
className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-100"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
<span className="font-mono text-sm text-red-700">{testId}</span>
|
|
</div>
|
|
{onViewDetails && (
|
|
<button
|
|
onClick={() => onViewDetails(testId)}
|
|
className="text-xs text-red-600 hover:text-red-800"
|
|
>
|
|
Details
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Scheduler Status Component
|
|
function SchedulerStatusCard({
|
|
title,
|
|
status,
|
|
description,
|
|
icon,
|
|
}: {
|
|
title: string
|
|
status: 'active' | 'inactive' | 'warning' | 'unknown'
|
|
description: string
|
|
icon: React.ReactNode
|
|
}) {
|
|
const statusColors = {
|
|
active: 'bg-emerald-100 border-emerald-200 text-emerald-700',
|
|
inactive: 'bg-slate-100 border-slate-200 text-slate-700',
|
|
warning: 'bg-amber-100 border-amber-200 text-amber-700',
|
|
unknown: 'bg-slate-100 border-slate-200 text-slate-500',
|
|
}
|
|
|
|
const statusBadges = {
|
|
active: 'bg-emerald-500',
|
|
inactive: 'bg-slate-400',
|
|
warning: 'bg-amber-500',
|
|
unknown: 'bg-slate-300',
|
|
}
|
|
|
|
return (
|
|
<div className={`rounded-xl border p-5 ${statusColors[status]}`}>
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex-shrink-0">{icon}</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-semibold">{title}</h4>
|
|
<span className={`w-2 h-2 rounded-full ${statusBadges[status]}`} />
|
|
</div>
|
|
<p className="text-sm mt-1 opacity-80">{description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Main Component
|
|
export default function QualityDashboard() {
|
|
const [activeTab, setActiveTab] = useState<'overview' | 'golden' | 'rag' | 'synthetic' | 'history' | 'scheduler'>('overview')
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Data states
|
|
const [goldenMetrics, setGoldenMetrics] = useState<BQASMetrics | null>(null)
|
|
const [syntheticMetrics, setSyntheticMetrics] = useState<BQASMetrics | null>(null)
|
|
const [ragMetrics, setRagMetrics] = useState<BQASMetrics | null>(null)
|
|
const [testRuns, setTestRuns] = useState<TestRun[]>([])
|
|
const [trendData, setTrendData] = useState<TrendData | null>(null)
|
|
|
|
// Running states
|
|
const [isRunningGolden, setIsRunningGolden] = useState(false)
|
|
const [isRunningSynthetic, setIsRunningSynthetic] = useState(false)
|
|
const [isRunningRag, setIsRunningRag] = useState(false)
|
|
|
|
// Fetch data
|
|
const fetchData = useCallback(async () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
// Fetch test runs
|
|
const runsResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/runs`)
|
|
if (runsResponse.ok) {
|
|
const runsData = await runsResponse.json()
|
|
setTestRuns(runsData.runs || [])
|
|
}
|
|
|
|
// Fetch trend data
|
|
const trendResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/trend?days=30`)
|
|
if (trendResponse.ok) {
|
|
const trend = await trendResponse.json()
|
|
setTrendData(trend)
|
|
}
|
|
|
|
// Fetch latest metrics
|
|
const metricsResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/latest-metrics`)
|
|
if (metricsResponse.ok) {
|
|
const metrics = await metricsResponse.json()
|
|
setGoldenMetrics(metrics.golden || null)
|
|
setSyntheticMetrics(metrics.synthetic || null)
|
|
setRagMetrics(metrics.rag || null)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch BQAS data:', err)
|
|
setError('Verbindung zum Voice-Service fehlgeschlagen')
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
}, [fetchData])
|
|
|
|
// Run test suites
|
|
const runGoldenTests = async () => {
|
|
setIsRunningGolden(true)
|
|
try {
|
|
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/run/golden`, {
|
|
method: 'POST',
|
|
})
|
|
if (response.ok) {
|
|
const result = await response.json()
|
|
setGoldenMetrics(result.metrics)
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to run golden tests:', err)
|
|
} finally {
|
|
setIsRunningGolden(false)
|
|
}
|
|
}
|
|
|
|
const runSyntheticTests = async () => {
|
|
setIsRunningSynthetic(true)
|
|
try {
|
|
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/run/synthetic`, {
|
|
method: 'POST',
|
|
})
|
|
if (response.ok) {
|
|
const result = await response.json()
|
|
setSyntheticMetrics(result.metrics)
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to run synthetic tests:', err)
|
|
} finally {
|
|
setIsRunningSynthetic(false)
|
|
}
|
|
}
|
|
|
|
const runRagTests = async () => {
|
|
setIsRunningRag(true)
|
|
try {
|
|
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/run/rag`, {
|
|
method: 'POST',
|
|
})
|
|
if (response.ok) {
|
|
const result = await response.json()
|
|
setRagMetrics(result.metrics)
|
|
await fetchData()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to run RAG tests:', err)
|
|
} finally {
|
|
setIsRunningRag(false)
|
|
}
|
|
}
|
|
|
|
// Tab content
|
|
const renderTabContent = () => {
|
|
switch (activeTab) {
|
|
case 'overview':
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Quick Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<MetricCard
|
|
title="Golden Score"
|
|
value={goldenMetrics?.avg_composite_score.toFixed(2) || '-'}
|
|
subtitle="Durchschnitt aller Golden Tests"
|
|
trend={trendData?.trend === 'improving' ? 'up' : trendData?.trend === 'declining' ? 'down' : 'stable'}
|
|
color="blue"
|
|
/>
|
|
<MetricCard
|
|
title="Pass Rate"
|
|
value={goldenMetrics ? `${((goldenMetrics.passed_tests / goldenMetrics.total_tests) * 100).toFixed(0)}%` : '-'}
|
|
subtitle={goldenMetrics ? `${goldenMetrics.passed_tests}/${goldenMetrics.total_tests} bestanden` : undefined}
|
|
color="green"
|
|
/>
|
|
<MetricCard
|
|
title="RAG Qualitaet"
|
|
value={ragMetrics?.avg_composite_score.toFixed(2) || '-'}
|
|
subtitle="RAG Retrieval Score"
|
|
color="purple"
|
|
/>
|
|
<MetricCard
|
|
title="Test Runs"
|
|
value={testRuns.length}
|
|
subtitle="Letzte 30 Tage"
|
|
color="yellow"
|
|
/>
|
|
</div>
|
|
|
|
{/* Trend Chart */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Score-Trend (30 Tage)</h3>
|
|
<TrendChart data={trendData || { dates: [], scores: [], trend: 'insufficient_data' }} />
|
|
</div>
|
|
|
|
{/* Test Suites Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<TestSuiteCard
|
|
title="Golden Suite"
|
|
description="97 validierte Referenz-Tests fuer Intent-Erkennung"
|
|
metrics={goldenMetrics || undefined}
|
|
onRun={runGoldenTests}
|
|
isRunning={isRunningGolden}
|
|
/>
|
|
<TestSuiteCard
|
|
title="RAG/Korrektur Tests"
|
|
description="EH-Retrieval, Operatoren-Alignment, Citation Tests"
|
|
metrics={ragMetrics || undefined}
|
|
onRun={runRagTests}
|
|
isRunning={isRunningRag}
|
|
/>
|
|
<TestSuiteCard
|
|
title="Synthetic Tests"
|
|
description="LLM-generierte Variationen fuer Robustheit"
|
|
metrics={syntheticMetrics || undefined}
|
|
onRun={runSyntheticTests}
|
|
isRunning={isRunningSynthetic}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'golden':
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-slate-900">Golden Test Suite</h3>
|
|
<p className="text-sm text-slate-500">Validierte Referenz-Tests gegen definierte Erwartungen</p>
|
|
</div>
|
|
<button
|
|
onClick={runGoldenTests}
|
|
disabled={isRunningGolden}
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
|
|
>
|
|
{isRunningGolden ? 'Laeuft...' : 'Tests starten'}
|
|
</button>
|
|
</div>
|
|
|
|
{goldenMetrics && (
|
|
<>
|
|
{/* Metrics Overview */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-slate-900">{goldenMetrics.total_tests}</p>
|
|
<p className="text-xs text-slate-500">Tests</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-emerald-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-emerald-600">{goldenMetrics.passed_tests}</p>
|
|
<p className="text-xs text-slate-500">Bestanden</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-red-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-red-600">{goldenMetrics.failed_tests}</p>
|
|
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-blue-600">{goldenMetrics.avg_intent_accuracy.toFixed(0)}%</p>
|
|
<p className="text-xs text-slate-500">Intent Accuracy</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-purple-600">{goldenMetrics.avg_composite_score.toFixed(2)}</p>
|
|
<p className="text-xs text-slate-500">Composite Score</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Intent Scores & Failed Tests */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-4">Scores nach Intent</h4>
|
|
<IntentScoresChart scores={goldenMetrics.scores_by_intent} />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
|
|
<FailedTestsList testIds={goldenMetrics.failed_test_ids} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'rag':
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-slate-900">RAG/Korrektur Test Suite</h3>
|
|
<p className="text-sm text-slate-500">Erwartungshorizont-Retrieval, Operatoren-Alignment, Citations</p>
|
|
</div>
|
|
<button
|
|
onClick={runRagTests}
|
|
disabled={isRunningRag}
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
|
|
>
|
|
{isRunningRag ? 'Laeuft...' : 'Tests starten'}
|
|
</button>
|
|
</div>
|
|
|
|
{ragMetrics ? (
|
|
<>
|
|
{/* RAG Metrics */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-slate-900">{ragMetrics.total_tests}</p>
|
|
<p className="text-xs text-slate-500">Tests</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-purple-600">{ragMetrics.avg_faithfulness.toFixed(2)}</p>
|
|
<p className="text-xs text-slate-500">Faithfulness</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-blue-600">{ragMetrics.avg_relevance.toFixed(2)}</p>
|
|
<p className="text-xs text-slate-500">Relevance</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-emerald-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-emerald-600">{(ragMetrics.safety_pass_rate * 100).toFixed(0)}%</p>
|
|
<p className="text-xs text-slate-500">Safety Pass</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* RAG Categories */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-4">RAG Kategorien</h4>
|
|
<IntentScoresChart scores={ragMetrics.scores_by_intent} />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
|
|
<FailedTestsList testIds={ragMetrics.failed_test_ids} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-12 text-slate-400">
|
|
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<p>Noch keine RAG-Test-Ergebnisse</p>
|
|
<p className="text-sm mt-2">Klicke "Tests starten" um die RAG-Suite auszufuehren</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* RAG Test Categories Explanation */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{[
|
|
{ name: 'EH Retrieval', desc: 'Korrektes Abrufen von Erwartungshorizont-Passagen', color: 'blue' },
|
|
{ name: 'Operator Alignment', desc: 'Passende Operatoren fuer Abitur-Aufgaben', color: 'purple' },
|
|
{ name: 'Hallucination Control', desc: 'Keine erfundenen Fakten oder Inhalte', color: 'red' },
|
|
{ name: 'Citation Enforcement', desc: 'Quellenangaben bei EH-Bezuegen', color: 'green' },
|
|
{ name: 'Privacy Compliance', desc: 'Keine PII-Leaks, DSGVO-Konformitaet', color: 'amber' },
|
|
{ name: 'Namespace Isolation', desc: 'Strikte Trennung zwischen Lehrern', color: 'slate' },
|
|
].map((cat) => (
|
|
<div key={cat.name} className={`p-4 rounded-lg border bg-${cat.color}-50 border-${cat.color}-200`}>
|
|
<h4 className="font-medium text-slate-900">{cat.name}</h4>
|
|
<p className="text-sm text-slate-600 mt-1">{cat.desc}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'synthetic':
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-slate-900">Synthetic Test Suite</h3>
|
|
<p className="text-sm text-slate-500">LLM-generierte Variationen fuer Robustheit-Tests</p>
|
|
</div>
|
|
<button
|
|
onClick={runSyntheticTests}
|
|
disabled={isRunningSynthetic}
|
|
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-slate-300"
|
|
>
|
|
{isRunningSynthetic ? 'Laeuft...' : 'Tests starten'}
|
|
</button>
|
|
</div>
|
|
|
|
{syntheticMetrics ? (
|
|
<>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-slate-900">{syntheticMetrics.total_tests}</p>
|
|
<p className="text-xs text-slate-500">Generierte Tests</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-emerald-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-emerald-600">{syntheticMetrics.passed_tests}</p>
|
|
<p className="text-xs text-slate-500">Bestanden</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-blue-600">{syntheticMetrics.avg_composite_score.toFixed(2)}</p>
|
|
<p className="text-xs text-slate-500">Avg Score</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-purple-600">{syntheticMetrics.avg_coherence.toFixed(2)}</p>
|
|
<p className="text-xs text-slate-500">Coherence</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-4">Intent-Variationen</h4>
|
|
<IntentScoresChart scores={syntheticMetrics.scores_by_intent} />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
|
|
<FailedTestsList testIds={syntheticMetrics.failed_test_ids} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="text-center py-12 text-slate-400">
|
|
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
|
</svg>
|
|
<p>Noch keine synthetischen Tests ausgefuehrt</p>
|
|
<p className="text-sm mt-2">Klicke "Tests starten" um Variationen zu generieren</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'history':
|
|
return (
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-6">Test Run Historie</h3>
|
|
<TestRunsTable runs={testRuns} />
|
|
</div>
|
|
)
|
|
|
|
case 'scheduler':
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Status Overview */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<SchedulerStatusCard
|
|
title="launchd Job"
|
|
status="active"
|
|
description="Taeglich um 07:00 Uhr automatisch"
|
|
icon={
|
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<SchedulerStatusCard
|
|
title="Git Hook"
|
|
status="active"
|
|
description="Quick Tests bei voice-service Aenderungen"
|
|
icon={
|
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<SchedulerStatusCard
|
|
title="Benachrichtigungen"
|
|
status="active"
|
|
description="Desktop-Alerts bei Fehlern aktiviert"
|
|
icon={
|
|
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
</svg>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Quick Actions */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quick Actions</h3>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={runGoldenTests}
|
|
disabled={isRunningGolden}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-slate-300 flex items-center gap-2"
|
|
>
|
|
{isRunningGolden ? (
|
|
<svg className="animate-spin h-4 w-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>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)}
|
|
Golden Suite starten
|
|
</button>
|
|
<button
|
|
onClick={runRagTests}
|
|
disabled={isRunningRag}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-slate-300 flex items-center gap-2"
|
|
>
|
|
{isRunningRag ? (
|
|
<svg className="animate-spin h-4 w-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>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)}
|
|
RAG Tests starten
|
|
</button>
|
|
<button
|
|
onClick={runSyntheticTests}
|
|
disabled={isRunningSynthetic}
|
|
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:bg-slate-300 flex items-center gap-2"
|
|
>
|
|
{isRunningSynthetic ? (
|
|
<svg className="animate-spin h-4 w-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>
|
|
) : (
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)}
|
|
Synthetic Tests
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* GitHub Actions vs Local - Comparison */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">GitHub Actions Alternative</h3>
|
|
<p className="text-slate-600 mb-4">
|
|
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
|
|
</p>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-slate-200 bg-slate-50">
|
|
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
|
|
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
|
|
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr className="border-b border-slate-100">
|
|
<td className="py-3 px-4 text-slate-600">Taegliche Tests (07:00)</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="text-slate-600">schedule: cron</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">macOS launchd</span>
|
|
</td>
|
|
</tr>
|
|
<tr className="border-b border-slate-100">
|
|
<td className="py-3 px-4 text-slate-600">Push-basierte Tests</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="text-slate-600">on: push</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">Git post-commit Hook</span>
|
|
</td>
|
|
</tr>
|
|
<tr className="border-b border-slate-100">
|
|
<td className="py-3 px-4 text-slate-600">PR-basierte Tests</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">on: pull_request</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs font-medium">Nicht moeglich</span>
|
|
</td>
|
|
</tr>
|
|
<tr className="border-b border-slate-100">
|
|
<td className="py-3 px-4 text-slate-600">Regression-Check</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="text-slate-600">API-Call</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">Identischer API-Call</span>
|
|
</td>
|
|
</tr>
|
|
<tr className="border-b border-slate-100">
|
|
<td className="py-3 px-4 text-slate-600">Benachrichtigungen</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="text-slate-600">GitHub Issues</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">Desktop/Slack/Email</span>
|
|
</td>
|
|
</tr>
|
|
<tr className="border-b border-slate-100">
|
|
<td className="py-3 px-4 text-slate-600">DSGVO-Konformitaet</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs font-medium">Daten bei GitHub (US)</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">100% lokal</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="py-3 px-4 text-slate-600">Offline-Faehig</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs font-medium">Nein</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-center">
|
|
<span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs font-medium">Ja</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Configuration Details */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Konfiguration</h3>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* launchd Configuration */}
|
|
<div>
|
|
<h4 className="font-medium text-slate-800 mb-3">launchd Job</h4>
|
|
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
|
|
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
|
|
Label: com.breakpilot.bqas
|
|
Schedule: 07:00 taeglich
|
|
Script: /voice-service/scripts/run_bqas.sh
|
|
Logs: /var/log/bqas/`}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Environment Variables */}
|
|
<div>
|
|
<h4 className="font-medium text-slate-800 mb-3">Umgebungsvariablen</h4>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between p-2 bg-slate-50 rounded">
|
|
<span className="font-mono text-slate-600">BQAS_SERVICE_URL</span>
|
|
<span className="text-slate-900">http://localhost:8091</span>
|
|
</div>
|
|
<div className="flex justify-between p-2 bg-slate-50 rounded">
|
|
<span className="font-mono text-slate-600">BQAS_REGRESSION_THRESHOLD</span>
|
|
<span className="text-slate-900">0.1</span>
|
|
</div>
|
|
<div className="flex justify-between p-2 bg-slate-50 rounded">
|
|
<span className="font-mono text-slate-600">BQAS_NOTIFY_DESKTOP</span>
|
|
<span className="text-emerald-600 font-medium">true</span>
|
|
</div>
|
|
<div className="flex justify-between p-2 bg-slate-50 rounded">
|
|
<span className="font-mono text-slate-600">BQAS_NOTIFY_SLACK</span>
|
|
<span className="text-slate-400">false</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detailed Explanation */}
|
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
|
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
Detaillierte Erklaerung
|
|
</h3>
|
|
|
|
<div className="prose prose-sm max-w-none text-slate-700">
|
|
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
|
|
<p className="mb-4">
|
|
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
|
|
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
|
|
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
|
|
</p>
|
|
|
|
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
|
|
<ul className="list-disc list-inside space-y-2 mb-4">
|
|
<li>
|
|
<strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
|
|
</li>
|
|
<li>
|
|
<strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
|
|
</li>
|
|
<li>
|
|
<strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
|
|
</li>
|
|
<li>
|
|
<strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet
|
|
</li>
|
|
</ul>
|
|
|
|
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
|
|
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
|
|
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
|
|
</div>
|
|
|
|
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
|
|
<ul className="list-disc list-inside space-y-1">
|
|
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
|
|
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
|
|
<li>Keine GitHub-Kosten fuer private Repositories</li>
|
|
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
|
|
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AdminLayout
|
|
title="Qualitaetssicherung"
|
|
description="BQAS - Breakpilot Quality Assurance System"
|
|
>
|
|
{/* Error Banner */}
|
|
{error && (
|
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
|
|
<svg className="w-5 h-5 flex-shrink-0" 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>
|
|
<button onClick={fetchData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content */}
|
|
{!isLoading && (
|
|
<>
|
|
{/* Tabs */}
|
|
<div className="mb-6 border-b border-slate-200">
|
|
<nav className="flex gap-6">
|
|
{[
|
|
{ id: 'overview', label: 'Uebersicht' },
|
|
{ id: 'golden', label: 'Golden Suite' },
|
|
{ id: 'rag', label: 'RAG/Korrektur' },
|
|
{ id: 'synthetic', label: 'Synthetic' },
|
|
{ id: 'history', label: 'Historie' },
|
|
{ id: 'scheduler', label: 'Lokaler Scheduler' },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
|
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-primary-600 text-primary-600'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{renderTabContent()}
|
|
</>
|
|
)}
|
|
|
|
{/* Footer Info */}
|
|
<div className="mt-8 pt-6 border-t border-slate-200">
|
|
<div className="flex items-center justify-between text-sm text-slate-500">
|
|
<div>
|
|
<span className="font-medium">Voice Service:</span> {VOICE_SERVICE_URL}
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<a href="/admin/voice" className="text-primary-600 hover:text-primary-700">
|
|
Voice Dashboard
|
|
</a>
|
|
<a href="/admin/docs" className="text-primary-600 hover:text-primary-700">
|
|
Dokumentation
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|