Fix: Remove broken getKlausurApiUrl and clean up empty lines
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 42s
CI / test-go-edu-search (push) Successful in 34s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 29s

sed replacement left orphaned hostname references in story page
and empty lines in getApiBase functions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 16:02:04 +02:00
parent b07f802c24
commit 9ba420fa91
150 changed files with 30231 additions and 32053 deletions

View File

@@ -0,0 +1,212 @@
'use client'
export function ArchitectureTab() {
return (
<div className="space-y-8">
{/* What is this module */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Was macht dieses Modul?
</h2>
<div className="prose dark:prose-invert max-w-none">
<p className="text-gray-600 dark:text-gray-400">
Das <strong>RAG-Indexierungs-Modul</strong> verarbeitet Dokumente und macht sie fuer die KI-gestuetzte Suche verfuegbar.
Es handelt sich <strong>nicht</strong> um klassisches Machine-Learning-Training, sondern um:
</p>
<ul className="mt-4 space-y-2 text-gray-600 dark:text-gray-400">
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">1.</span>
<span><strong>Dokumentenextraktion:</strong> PDFs und Bilder werden per OCR in Text umgewandelt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">2.</span>
<span><strong>Chunking:</strong> Lange Texte werden in suchbare Abschnitte (1000 Zeichen) aufgeteilt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">3.</span>
<span><strong>Embedding:</strong> Jeder Chunk wird in einen Vektor (1536 Dimensionen) umgewandelt</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-500 mt-1">4.</span>
<span><strong>Indexierung:</strong> Vektoren werden in Qdrant gespeichert fuer semantische Suche</span>
</li>
</ul>
</div>
</div>
{/* Architecture Diagram */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
Technische Architektur
</h2>
{/* Visual Pipeline */}
<div className="relative">
{/* Data Sources Row */}
<div className="grid grid-cols-4 gap-4 mb-8">
<SourceCard icon="📄" title="NiBiS PDFs" subtitle="Erwartungshorizonte" color="blue" />
<SourceCard icon="📤" title="Uploads" subtitle="Eigene EH" color="green" />
<SourceCard icon="⚖️" title="Rechtskorpus" subtitle="DSGVO, AI Act" color="purple" />
<SourceCard icon="📚" title="Schulordnungen" subtitle="Bundeslaender" color="orange" />
</div>
<ArrowDown />
{/* Processing Layer */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 mb-8">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
Verarbeitungs-Pipeline
</h3>
<div className="flex items-center justify-between gap-4">
<PipelineStep icon="🔍" title="OCR" subtitle="Text-Extraktion" />
<ArrowRight />
<PipelineStep icon="✂️" title="Chunking" subtitle="1000 Zeichen" />
<ArrowRight />
<PipelineStep icon="🧮" title="Embedding" subtitle="1536-dim Vektor" />
<ArrowRight />
<PipelineStep icon="💾" title="Speichern" subtitle="Qdrant" />
</div>
</div>
<ArrowDown />
{/* Storage Layer */}
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-xl p-6 mb-8 border-2 border-indigo-200 dark:border-indigo-800">
<h3 className="text-sm font-semibold text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-4">
Vektor-Datenbank (Qdrant)
</h3>
<div className="grid grid-cols-3 gap-4">
<CollectionCard collection="bp_nibis_eh" label="Offizielle EH" />
<CollectionCard collection="bp_eh" label="Benutzer EH" />
<CollectionCard collection="bp_legal_corpus" label="Rechtskorpus" />
</div>
</div>
<ArrowDown />
{/* Usage Layer */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border-2 border-emerald-200 dark:border-emerald-800">
<h4 className="font-medium text-emerald-700 dark:text-emerald-400 mb-2">Semantische Suche</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fragen werden in Vektoren umgewandelt und aehnliche Dokumente gefunden
</p>
</div>
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border-2 border-amber-200 dark:border-amber-800">
<h4 className="font-medium text-amber-700 dark:text-amber-400 mb-2">RAG-Antworten</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
LLM generiert Antworten basierend auf gefundenen Dokumenten
</p>
</div>
</div>
</div>
</div>
{/* Technical Details */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Technische Details
</h2>
<div className="grid grid-cols-2 gap-6">
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Embedding-Service</h3>
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Modell</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">text-embedding-3-small</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Dimensionen</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">1536</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Port</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">8087</td>
</tr>
</tbody>
</table>
</div>
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Chunk-Konfiguration</h3>
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Chunk-Groesse</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">1000 Zeichen</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Ueberlappung</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">200 Zeichen</td>
</tr>
<tr>
<td className="py-2 text-gray-500 dark:text-gray-400">Distanzmetrik</td>
<td className="py-2 font-mono text-gray-900 dark:text-white">COSINE</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
)
}
// --- Internal helper components ---
function SourceCard({ icon, title, subtitle, color }: {
icon: string
title: string
subtitle: string
color: string
}) {
const colorClasses: Record<string, string> = {
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
green: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
orange: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
}
return (
<div className={`p-4 rounded-xl border-2 text-center ${colorClasses[color]}`}>
<div className="text-3xl mb-2">{icon}</div>
<div className="font-medium text-gray-900 dark:text-white">{title}</div>
<div className="text-xs text-gray-500">{subtitle}</div>
</div>
)
}
function PipelineStep({ icon, title, subtitle }: {
icon: string
title: string
subtitle: string
}) {
return (
<div className="flex-1 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="text-2xl mb-1">{icon}</div>
<div className="font-medium text-sm">{title}</div>
<div className="text-xs text-gray-500">{subtitle}</div>
</div>
)
}
function CollectionCard({ collection, label }: { collection: string; label: string }) {
return (
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
<div className="font-mono text-xs text-gray-500">{collection}</div>
<div className="font-medium text-gray-900 dark:text-white">{label}</div>
</div>
)
}
function ArrowDown() {
return (
<div className="flex justify-center mb-4">
<div className="text-4xl text-gray-400"></div>
</div>
)
}
function ArrowRight() {
return <div className="text-2xl text-gray-400"></div>
}

View File

@@ -0,0 +1,171 @@
'use client'
import type { DataSource } from '../types'
export function DataSourcesTab({ sources }: { sources: DataSource[] }) {
return (
<div className="space-y-6">
{/* Introduction */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
<h2 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
Wie werden Daten hinzugefuegt?
</h2>
<p className="text-blue-800 dark:text-blue-200 mb-4">
Das RAG-System nutzt verschiedene Datenquellen. Jede Quelle hat einen eigenen Ingestion-Prozess:
</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="font-medium text-gray-900 dark:text-white mb-1">Automatisch</div>
<p className="text-gray-600 dark:text-gray-400">
NiBiS-PDFs werden automatisch aus dem za-download Verzeichnis eingelesen
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
<div className="font-medium text-gray-900 dark:text-white mb-1">Manuell</div>
<p className="text-gray-600 dark:text-gray-400">
Eigene EH koennen ueber die Klausur-Korrektur hochgeladen werden
</p>
</div>
</div>
</div>
{/* Data Sources List */}
<div className="grid gap-4">
{sources.map((source) => (
<DataSourceCard key={source.id} source={source} />
))}
</div>
{/* How to add data */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Daten hinzufuegen
</h2>
<div className="grid grid-cols-3 gap-6">
<AddDataCard
icon="📤"
title="Erwartungshorizont hochladen"
description="Laden Sie eigene EH-Dokumente in der Klausur-Korrektur hoch"
linkHref="/admin/klausur-korrektur"
linkText="Zur Klausur-Korrektur →"
/>
<AddDataCard
icon="🔄"
title="NiBiS neu einlesen"
description="Starten Sie die automatische Ingestion der NiBiS-PDFs"
linkText="Ingestion starten →"
/>
<AddDataCard
icon="⚖️"
title="Rechtskorpus erweitern"
description="Neue Regelwerke (DSGVO, BSI, etc.) zum Korpus hinzufuegen"
linkText="Regelwerk hinzufuegen →"
/>
<AddDataCard
icon="📋"
title="DSFA-Quellen verwalten"
description="WP248, DSK, Muss-Listen mit Lizenzattribution"
linkHref="/ai/rag-pipeline/dsfa"
linkText="DSFA-Manager oeffnen →"
/>
</div>
</div>
</div>
)
}
// --- Internal helper components ---
function DataSourceCard({ source }: { source: DataSource }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{source.name}
</h3>
<DataSourceStatusBadge status={source.status} />
</div>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{source.description}
</p>
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400">Collection: </span>
<span className="font-mono text-gray-900 dark:text-white">{source.collection}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Dokumente: </span>
<span className="font-semibold text-gray-900 dark:text-white">{source.document_count}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Chunks: </span>
<span className="font-semibold text-gray-900 dark:text-white">{source.chunk_count}</span>
</div>
{source.last_updated && (
<div>
<span className="text-gray-500 dark:text-gray-400">Aktualisiert: </span>
<span className="text-gray-900 dark:text-white">
{new Date(source.last_updated).toLocaleDateString('de-DE')}
</span>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<button className="px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg">
Aktualisieren
</button>
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Details
</button>
</div>
</div>
</div>
)
}
function DataSourceStatusBadge({ status }: { status: DataSource['status'] }) {
const className = status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: status === 'pending'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
const label = status === 'active' ? 'Aktiv' : status === 'pending' ? 'Ausstehend' : 'Fehler'
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
{label}
</span>
)
}
function AddDataCard({ icon, title, description, linkHref, linkText }: {
icon: string
title: string
description: string
linkHref?: string
linkText: string
}) {
return (
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="text-2xl mb-2">{icon}</div>
<h3 className="font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{description}</p>
{linkHref ? (
<a
href={linkHref}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{linkText}
</a>
) : (
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
{linkText}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { DatasetStats } from '../types'
export function DatasetOverview({ stats }: { stats: DatasetStats }) {
const maxBundesland = Math.max(...Object.values(stats.by_bundesland))
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Datensatz-Uebersicht
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{stats.total_documents.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
</div>
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{stats.total_chunks.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl">
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
{stats.training_allowed.toLocaleString()}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Indexiert</p>
</div>
</div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Verteilung nach Bundesland
</h4>
<div className="space-y-2">
{Object.entries(stats.by_bundesland)
.sort((a, b) => b[1] - a[1])
.map(([code, count]) => (
<div key={code} className="flex items-center gap-3">
<span className="w-8 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">
{code}
</span>
<div className="flex-1 h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
style={{ width: `${(count / maxBundesland) * 100}%` }}
/>
</div>
<span className="w-10 text-sm text-right text-gray-600 dark:text-gray-400">
{count}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,277 @@
'use client'
import { useState } from 'react'
import type { TrainingConfig } from '../types'
const BUNDESLAENDER = [
{ code: 'ni', name: 'Niedersachsen', allowed: true },
{ code: 'by', name: 'Bayern', allowed: true },
{ code: 'nw', name: 'NRW', allowed: true },
{ code: 'he', name: 'Hessen', allowed: true },
{ code: 'bw', name: 'Baden-Wuerttemberg', allowed: true },
{ code: 'rp', name: 'Rheinland-Pfalz', allowed: true },
{ code: 'sn', name: 'Sachsen', allowed: true },
{ code: 'sh', name: 'Schleswig-Holstein', allowed: true },
{ code: 'th', name: 'Thueringen', allowed: true },
{ code: 'be', name: 'Berlin', allowed: false },
{ code: 'bb', name: 'Brandenburg', allowed: false },
{ code: 'hb', name: 'Bremen', allowed: false },
{ code: 'hh', name: 'Hamburg', allowed: false },
{ code: 'mv', name: 'Mecklenburg-Vorpommern', allowed: false },
{ code: 'sl', name: 'Saarland', allowed: false },
{ code: 'st', name: 'Sachsen-Anhalt', allowed: false },
]
export function NewTrainingModal({ isOpen, onClose, onSubmit }: {
isOpen: boolean
onClose: () => void
onSubmit: (config: Partial<TrainingConfig>) => void
}) {
const [step, setStep] = useState(1)
const [config, setConfig] = useState<Partial<TrainingConfig>>({
batch_size: 16,
learning_rate: 0.00005,
epochs: 10,
warmup_steps: 500,
weight_decay: 0.01,
gradient_accumulation: 4,
mixed_precision: true,
bundeslaender: [],
})
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Neue Indexierung starten
</h2>
<p className="text-sm text-gray-500">Schritt {step} von 3</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Step Indicator */}
<StepIndicator currentStep={step} />
{/* Step Content */}
<div className="p-6 overflow-y-auto max-h-[50vh]">
{step === 1 && (
<BundeslaenderStep config={config} setConfig={setConfig} />
)}
{step === 2 && (
<ParameterStep config={config} setConfig={setConfig} />
)}
{step === 3 && (
<ConfirmStep config={config} />
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{step > 1 ? 'Zurueck' : 'Abbrechen'}
</button>
<button
onClick={() => step < 3 ? setStep(step + 1) : onSubmit(config)}
disabled={step === 1 && (!config.bundeslaender || config.bundeslaender.length === 0)}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{step < 3 ? 'Weiter' : 'Indexierung starten'}
</button>
</div>
</div>
</div>
)
}
// --- Internal step components ---
function StepIndicator({ currentStep }: { currentStep: number }) {
return (
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<div className="flex items-center justify-center gap-4">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s <= currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
}`}>
{s < currentStep ? '\u2713' : s}
</div>
{s < 3 && (
<div className={`w-16 h-1 mx-2 rounded ${
s < currentStep ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`} />
)}
</div>
))}
</div>
<div className="flex justify-center gap-20 mt-2 text-xs text-gray-500">
<span>Daten</span>
<span>Parameter</span>
<span>Bestaetigen</span>
</div>
</div>
)
}
function BundeslaenderStep({ config, setConfig }: {
config: Partial<TrainingConfig>
setConfig: (config: Partial<TrainingConfig>) => void
}) {
return (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Waehlen Sie die Bundeslaender fuer die Indexierung
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Nur Bundeslaender mit verfuegbaren Dokumenten koennen ausgewaehlt werden.
</p>
<div className="grid grid-cols-2 gap-3">
{BUNDESLAENDER.map((bl) => (
<label
key={bl.code}
className={`flex items-center p-3 rounded-lg border-2 transition cursor-pointer ${
config.bundeslaender?.includes(bl.code)
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: bl.allowed
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
: 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
}`}
>
<input
type="checkbox"
disabled={!bl.allowed}
checked={config.bundeslaender?.includes(bl.code)}
onChange={(e) => {
if (e.target.checked) {
setConfig({ ...config, bundeslaender: [...(config.bundeslaender || []), bl.code] })
} else {
setConfig({ ...config, bundeslaender: config.bundeslaender?.filter(c => c !== bl.code) })
}
}}
className="sr-only"
/>
<span className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 ${
config.bundeslaender?.includes(bl.code)
? 'bg-blue-500 border-blue-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}>
{config.bundeslaender?.includes(bl.code) && '\u2713'}
</span>
<span className="flex-1 text-gray-900 dark:text-white">{bl.name}</span>
{!bl.allowed && (
<span className="text-xs text-red-500">Keine Daten</span>
)}
</label>
))}
</div>
</div>
)
}
function ParameterStep({ config, setConfig }: {
config: Partial<TrainingConfig>
setConfig: (config: Partial<TrainingConfig>) => void
}) {
return (
<div className="space-y-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Indexierungs-Parameter
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Diese Parameter steuern die Batch-Verarbeitung der Dokumente.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Batch Size
</label>
<input
type="number"
value={config.batch_size}
onChange={(e) => setConfig({ ...config, batch_size: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
<p className="text-xs text-gray-500 mt-1">Dokumente pro Batch</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Durchlaeufe
</label>
<input
type="number"
value={config.epochs}
onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
<p className="text-xs text-gray-500 mt-1">Fuer Validierung</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<input
type="checkbox"
id="mixedPrecision"
checked={config.mixed_precision}
onChange={(e) => setConfig({ ...config, mixed_precision: e.target.checked })}
className="w-4 h-4 text-blue-600 rounded"
/>
<label htmlFor="mixedPrecision" className="text-sm text-gray-700 dark:text-gray-300">
Parallele Verarbeitung - schneller bei grossem Datensatz
</label>
</div>
</div>
)
}
function ConfirmStep({ config }: { config: Partial<TrainingConfig> }) {
return (
<div>
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
Konfiguration bestaetigen
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Bundeslaender</span>
<span className="font-medium text-gray-900 dark:text-white">
{config.bundeslaender?.length || 0} ausgewaehlt
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Batch Size</span>
<span className="font-medium text-gray-900 dark:text-white">{config.batch_size}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Parallele Verarbeitung</span>
<span className="font-medium text-gray-900 dark:text-white">
{config.mixed_precision ? 'Aktiviert' : 'Deaktiviert'}
</span>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Was passiert:</strong> Die ausgewaehlten Dokumente werden extrahiert,
in Chunks aufgeteilt, und als Vektoren in Qdrant indexiert.
Dieser Prozess kann je nach Datenmenge einige Minuten dauern.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import type { TrainingJob } from '../types'
// Tab Button
export function TabButton({ active, onClick, children }: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
active
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{children}
</button>
)
}
// Progress Ring Component
export function ProgressRing({ progress, size = 120, strokeWidth = 8, color = '#10B981' }: {
progress: number
size?: number
strokeWidth?: number
color?: string
}) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill="none"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(progress)}%
</span>
</div>
</div>
)
}
// Mini Line Chart Component
export function MiniChart({ data, color = '#10B981', height = 60 }: {
data: number[]
color?: string
height?: number
}) {
if (!data.length) return null
const max = Math.max(...data)
const min = Math.min(...data)
const range = max - min || 1
const width = 200
const padding = 4
const points = data.map((value, i) => {
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
const y = padding + (1 - (value - min) / range) * (height - 2 * padding)
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} className="overflow-visible">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{data.length > 0 && (
<circle
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - 2 * padding)}
cy={padding + (1 - (data[data.length - 1] - min) / range) * (height - 2 * padding)}
r={4}
fill={color}
/>
)}
</svg>
)
}
// Status Badge
export function StatusBadge({ status }: { status: TrainingJob['status'] }) {
const styles = {
queued: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
preparing: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
training: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
validating: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
paused: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
}
const labels = {
queued: 'In Warteschlange',
preparing: 'Vorbereitung',
training: 'Indexierung laeuft',
validating: 'Validierung',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
paused: 'Pausiert',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
{status === 'training' && (
<span className="w-2 h-2 mr-1.5 bg-blue-500 rounded-full animate-pulse" />
)}
{labels[status]}
</span>
)
}
// Metric Card
export function MetricCard({ label, value, trend, color }: {
label: string
value: number | string
trend?: 'up' | 'down' | 'neutral'
color?: string
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{label}</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold" style={{ color: color || 'inherit' }}>
{typeof value === 'number' ? value.toFixed(3) : value}
</span>
{trend && (
<span className={`ml-2 text-sm ${
trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-gray-400'
}`}>
{trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import type { TrainingJob } from '../types'
import { ProgressRing, MiniChart, StatusBadge, MetricCard } from './SharedWidgets'
export function TrainingJobCard({ job, onPause, onResume, onStop, onViewDetails }: {
job: TrainingJob
onPause: () => void
onResume: () => void
onStop: () => void
onViewDetails: () => void
}) {
const isActive = ['training', 'preparing', 'validating'].includes(job.status)
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{job.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Typ: {job.model_type.charAt(0).toUpperCase() + job.model_type.slice(1)}
</p>
</div>
<StatusBadge status={job.status} />
</div>
<div className="p-6">
<div className="flex items-center gap-8">
<ProgressRing
progress={job.progress}
color={job.status === 'failed' ? '#EF4444' : '#10B981'}
/>
<div className="flex-1 space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Durchlauf</span>
<span className="font-medium text-gray-900 dark:text-white">
{job.current_epoch} / {job.total_epochs}
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-500"
style={{ width: `${(job.current_epoch / job.total_epochs) * 100}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">Dokumente</span>
<span className="font-medium text-gray-900 dark:text-white">
{job.documents_processed.toLocaleString()} / {job.total_documents.toLocaleString()}
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full transition-all duration-500"
style={{ width: `${(job.documents_processed / job.total_documents) * 100}%` }}
/>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-3 mt-6">
<MetricCard label="Loss" value={job.loss} trend="down" color="#3B82F6" />
<MetricCard label="Val Loss" value={job.val_loss} trend="down" color="#8B5CF6" />
<MetricCard label="Precision" value={job.metrics.precision} color="#10B981" />
<MetricCard label="F1 Score" value={job.metrics.f1_score} color="#F59E0B" />
</div>
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Fortschritt
</span>
</div>
<div className="flex gap-4">
<MiniChart data={job.metrics.loss_history} color="#3B82F6" />
<MiniChart data={job.metrics.val_loss_history} color="#8B5CF6" />
</div>
</div>
<div className="mt-4 flex justify-between text-sm text-gray-500 dark:text-gray-400">
<span>
Gestartet: {job.started_at ? new Date(job.started_at).toLocaleTimeString('de-DE') : '-'}
</span>
<span>
Geschaetzt: {job.estimated_completion
? new Date(job.estimated_completion).toLocaleTimeString('de-DE')
: '-'
}
</span>
</div>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-between">
<button
onClick={onViewDetails}
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Details anzeigen
</button>
<div className="flex gap-2">
{isActive && (
<>
<button
onClick={job.status === 'paused' ? onResume : onPause}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{job.status === 'paused' ? 'Fortsetzen' : 'Pausieren'}
</button>
<button
onClick={onStop}
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40"
>
Abbrechen
</button>
</>
)}
</div>
</div>
</div>
)
}