[split-required] Split website + studio-v2 monoliths (Phase 3 continued)
Website (14 monoliths split): - compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20) - quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11) - i18n.ts (1,173 → 8 language files) - unity-bridge (1,094 → 12), backlog (1,087 → 6) - training (1,066 → 8), rag (1,063 → 8) - Deleted index_original.ts (4,899 LOC dead backup) Studio-v2 (5 monoliths split): - meet/page.tsx (1,481 → 9), messages (1,166 → 9) - AlertsB2BContext.tsx (1,165 → 5 modules) - alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6) All existing imports preserved. Zero new TypeScript errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
38
website/app/admin/quality/_components/FailedTestsList.tsx
Normal file
38
website/app/admin/quality/_components/FailedTestsList.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
73
website/app/admin/quality/_components/GoldenTab.tsx
Normal file
73
website/app/admin/quality/_components/GoldenTab.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BQASMetrics } from '../types'
|
||||
import { IntentScoresChart } from './IntentScoresChart'
|
||||
import { FailedTestsList } from './FailedTestsList'
|
||||
|
||||
export function GoldenTab({
|
||||
goldenMetrics,
|
||||
isRunningGolden,
|
||||
runGoldenTests,
|
||||
}: {
|
||||
goldenMetrics: BQASMetrics | null
|
||||
isRunningGolden: boolean
|
||||
runGoldenTests: () => void
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
38
website/app/admin/quality/_components/IntentScoresChart.tsx
Normal file
38
website/app/admin/quality/_components/IntentScoresChart.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
52
website/app/admin/quality/_components/MetricCard.tsx
Normal file
52
website/app/admin/quality/_components/MetricCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
94
website/app/admin/quality/_components/OverviewTab.tsx
Normal file
94
website/app/admin/quality/_components/OverviewTab.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { BQASMetrics, TrendData, TestRun } from '../types'
|
||||
import { MetricCard } from './MetricCard'
|
||||
import { TrendChart } from './TrendChart'
|
||||
import { TestSuiteCard } from './TestSuiteCard'
|
||||
|
||||
export function OverviewTab({
|
||||
goldenMetrics,
|
||||
ragMetrics,
|
||||
syntheticMetrics,
|
||||
trendData,
|
||||
testRuns,
|
||||
isRunningGolden,
|
||||
isRunningRag,
|
||||
isRunningSynthetic,
|
||||
runGoldenTests,
|
||||
runRagTests,
|
||||
runSyntheticTests,
|
||||
}: {
|
||||
goldenMetrics: BQASMetrics | null
|
||||
ragMetrics: BQASMetrics | null
|
||||
syntheticMetrics: BQASMetrics | null
|
||||
trendData: TrendData | null
|
||||
testRuns: TestRun[]
|
||||
isRunningGolden: boolean
|
||||
isRunningRag: boolean
|
||||
isRunningSynthetic: boolean
|
||||
runGoldenTests: () => void
|
||||
runRagTests: () => void
|
||||
runSyntheticTests: () => void
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/quality/_components/RagTab.tsx
Normal file
97
website/app/admin/quality/_components/RagTab.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { BQASMetrics } from '../types'
|
||||
import { IntentScoresChart } from './IntentScoresChart'
|
||||
import { FailedTestsList } from './FailedTestsList'
|
||||
|
||||
export function RagTab({
|
||||
ragMetrics,
|
||||
isRunningRag,
|
||||
runRagTests,
|
||||
}: {
|
||||
ragMetrics: BQASMetrics | null
|
||||
isRunningRag: boolean
|
||||
runRagTests: () => void
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
266
website/app/admin/quality/_components/SchedulerTab.tsx
Normal file
266
website/app/admin/quality/_components/SchedulerTab.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { SchedulerStatusCard } from './SchedulerStatusCard'
|
||||
import { SpinnerIcon } from './SpinnerIcon'
|
||||
|
||||
export function SchedulerTab({
|
||||
isRunningGolden,
|
||||
isRunningRag,
|
||||
isRunningSynthetic,
|
||||
runGoldenTests,
|
||||
runRagTests,
|
||||
runSyntheticTests,
|
||||
}: {
|
||||
isRunningGolden: boolean
|
||||
isRunningRag: boolean
|
||||
isRunningSynthetic: boolean
|
||||
runGoldenTests: () => void
|
||||
runRagTests: () => void
|
||||
runSyntheticTests: () => void
|
||||
}) {
|
||||
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">
|
||||
<QuickActionButton
|
||||
label="Golden Suite starten"
|
||||
isRunning={isRunningGolden}
|
||||
onClick={runGoldenTests}
|
||||
colorClass="bg-blue-600 hover:bg-blue-700"
|
||||
/>
|
||||
<QuickActionButton
|
||||
label="RAG Tests starten"
|
||||
isRunning={isRunningRag}
|
||||
onClick={runRagTests}
|
||||
colorClass="bg-purple-600 hover:bg-purple-700"
|
||||
/>
|
||||
<QuickActionButton
|
||||
label="Synthetic Tests"
|
||||
isRunning={isRunningSynthetic}
|
||||
onClick={runSyntheticTests}
|
||||
colorClass="bg-emerald-600 hover:bg-emerald-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Actions vs Local - Comparison */}
|
||||
<ComparisonTable />
|
||||
|
||||
{/* Configuration Details */}
|
||||
<ConfigurationSection />
|
||||
|
||||
{/* Detailed Explanation */}
|
||||
<ExplanationSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActionButton({
|
||||
label,
|
||||
isRunning,
|
||||
onClick,
|
||||
colorClass,
|
||||
}: {
|
||||
label: string
|
||||
isRunning: boolean
|
||||
onClick: () => void
|
||||
colorClass: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isRunning}
|
||||
className={`px-4 py-2 ${colorClass} text-white rounded-lg disabled:bg-slate-300 flex items-center gap-2`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonTable() {
|
||||
const rows = [
|
||||
{ feature: 'Taegliche Tests (07:00)', gh: 'schedule: cron', local: 'macOS launchd', localColor: 'emerald' },
|
||||
{ feature: 'Push-basierte Tests', gh: 'on: push', local: 'Git post-commit Hook', localColor: 'emerald' },
|
||||
{ feature: 'PR-basierte Tests', gh: 'on: pull_request', ghColor: 'emerald', local: 'Nicht moeglich', localColor: 'amber' },
|
||||
{ feature: 'Regression-Check', gh: 'API-Call', local: 'Identischer API-Call', localColor: 'emerald' },
|
||||
{ feature: 'Benachrichtigungen', gh: 'GitHub Issues', local: 'Desktop/Slack/Email', localColor: 'emerald' },
|
||||
{ feature: 'DSGVO-Konformitaet', gh: 'Daten bei GitHub (US)', ghColor: 'amber', local: '100% lokal', localColor: 'emerald' },
|
||||
{ feature: 'Offline-Faehig', gh: 'Nein', ghColor: 'red', local: 'Ja', localColor: 'emerald' },
|
||||
]
|
||||
|
||||
return (
|
||||
<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>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.feature} className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 text-slate-600">{row.feature}</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
{row.ghColor ? (
|
||||
<span className={`px-2 py-1 bg-${row.ghColor}-100 text-${row.ghColor}-700 rounded text-xs font-medium`}>
|
||||
{row.gh}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">{row.gh}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span className={`px-2 py-1 bg-${row.localColor}-100 text-${row.localColor}-700 rounded text-xs font-medium`}>
|
||||
{row.local}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigurationSection() {
|
||||
return (
|
||||
<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">
|
||||
{[
|
||||
{ key: 'BQAS_SERVICE_URL', value: 'http://localhost:8091', color: 'text-slate-900' },
|
||||
{ key: 'BQAS_REGRESSION_THRESHOLD', value: '0.1', color: 'text-slate-900' },
|
||||
{ key: 'BQAS_NOTIFY_DESKTOP', value: 'true', color: 'text-emerald-600 font-medium' },
|
||||
{ key: 'BQAS_NOTIFY_SLACK', value: 'false', color: 'text-slate-400' },
|
||||
].map((env) => (
|
||||
<div key={env.key} className="flex justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="font-mono text-slate-600">{env.key}</span>
|
||||
<span className={env.color}>{env.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExplanationSection() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
12
website/app/admin/quality/_components/SpinnerIcon.tsx
Normal file
12
website/app/admin/quality/_components/SpinnerIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function SpinnerIcon() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
75
website/app/admin/quality/_components/SyntheticTab.tsx
Normal file
75
website/app/admin/quality/_components/SyntheticTab.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { BQASMetrics } from '../types'
|
||||
import { IntentScoresChart } from './IntentScoresChart'
|
||||
import { FailedTestsList } from './FailedTestsList'
|
||||
|
||||
export function SyntheticTab({
|
||||
syntheticMetrics,
|
||||
isRunningSynthetic,
|
||||
runSyntheticTests,
|
||||
}: {
|
||||
syntheticMetrics: BQASMetrics | null
|
||||
isRunningSynthetic: boolean
|
||||
runSyntheticTests: () => void
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
60
website/app/admin/quality/_components/TestRunsTable.tsx
Normal file
60
website/app/admin/quality/_components/TestRunsTable.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { TestRun } from '../types'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
97
website/app/admin/quality/_components/TestSuiteCard.tsx
Normal file
97
website/app/admin/quality/_components/TestSuiteCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { BQASMetrics } from '../types'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
79
website/app/admin/quality/_components/TrendChart.tsx
Normal file
79
website/app/admin/quality/_components/TrendChart.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { TrendData } from '../types'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
56
website/app/admin/quality/types.ts
Normal file
56
website/app/admin/quality/types.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Quality Dashboard - Types & Constants
|
||||
*/
|
||||
|
||||
// Types
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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[]
|
||||
}
|
||||
|
||||
export interface TrendData {
|
||||
dates: string[]
|
||||
scores: number[]
|
||||
trend: 'improving' | 'stable' | 'declining' | 'insufficient_data'
|
||||
}
|
||||
|
||||
export type TabId = 'overview' | 'golden' | 'rag' | 'synthetic' | 'history' | 'scheduler'
|
||||
|
||||
// API Configuration
|
||||
export const VOICE_SERVICE_URL = process.env.NEXT_PUBLIC_VOICE_SERVICE_URL || 'http://localhost:8091'
|
||||
132
website/app/admin/quality/useQualityDashboard.ts
Normal file
132
website/app/admin/quality/useQualityDashboard.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { BQASMetrics, TestRun, TrendData, TabId, VOICE_SERVICE_URL } from './types'
|
||||
|
||||
export interface QualityDashboardState {
|
||||
activeTab: TabId
|
||||
setActiveTab: (tab: TabId) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
goldenMetrics: BQASMetrics | null
|
||||
syntheticMetrics: BQASMetrics | null
|
||||
ragMetrics: BQASMetrics | null
|
||||
testRuns: TestRun[]
|
||||
trendData: TrendData | null
|
||||
isRunningGolden: boolean
|
||||
isRunningSynthetic: boolean
|
||||
isRunningRag: boolean
|
||||
fetchData: () => Promise<void>
|
||||
runGoldenTests: () => Promise<void>
|
||||
runSyntheticTests: () => Promise<void>
|
||||
runRagTests: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useQualityDashboard(): QualityDashboardState {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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)
|
||||
|
||||
const [isRunningGolden, setIsRunningGolden] = useState(false)
|
||||
const [isRunningSynthetic, setIsRunningSynthetic] = useState(false)
|
||||
const [isRunningRag, setIsRunningRag] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const runsResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/runs`)
|
||||
if (runsResponse.ok) {
|
||||
const runsData = await runsResponse.json()
|
||||
setTestRuns(runsData.runs || [])
|
||||
}
|
||||
|
||||
const trendResponse = await fetch(`${VOICE_SERVICE_URL}/api/v1/bqas/trend?days=30`)
|
||||
if (trendResponse.ok) {
|
||||
const trend = await trendResponse.json()
|
||||
setTrendData(trend)
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab, setActiveTab,
|
||||
isLoading, error,
|
||||
goldenMetrics, syntheticMetrics, ragMetrics,
|
||||
testRuns, trendData,
|
||||
isRunningGolden, isRunningSynthetic, isRunningRag,
|
||||
fetchData, runGoldenTests, runSyntheticTests, runRagTests,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user