This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/website/app/admin/quality/page.tsx
Benjamin Admin bfdaf63ba9 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>
2026-02-09 09:51:32 +01:00

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>
)
}