Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1469 lines
60 KiB
TypeScript
1469 lines
60 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* RAG Pipeline Page
|
||
*
|
||
* Dokument-Indexierung für die semantische Suche.
|
||
* Teil der KI-Daten-Pipeline:
|
||
* OCR-Labeling → RAG Pipeline → Daten & RAG
|
||
*/
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
|
||
|
||
// ============================================================================
|
||
// TYPES
|
||
// ============================================================================
|
||
|
||
interface TrainingJob {
|
||
id: string
|
||
name: string
|
||
model_type: 'zeugnis' | 'klausur' | 'general'
|
||
status: 'queued' | 'preparing' | 'training' | 'validating' | 'completed' | 'failed' | 'paused'
|
||
progress: number
|
||
current_epoch: number
|
||
total_epochs: number
|
||
loss: number
|
||
val_loss: number
|
||
learning_rate: number
|
||
documents_processed: number
|
||
total_documents: number
|
||
started_at: string | null
|
||
estimated_completion: string | null
|
||
error_message: string | null
|
||
metrics: TrainingMetrics
|
||
config: TrainingConfig
|
||
}
|
||
|
||
interface TrainingMetrics {
|
||
precision: number
|
||
recall: number
|
||
f1_score: number
|
||
accuracy: number
|
||
loss_history: number[]
|
||
val_loss_history: number[]
|
||
confusion_matrix?: number[][]
|
||
}
|
||
|
||
interface TrainingConfig {
|
||
batch_size: number
|
||
learning_rate: number
|
||
epochs: number
|
||
warmup_steps: number
|
||
weight_decay: number
|
||
gradient_accumulation: number
|
||
mixed_precision: boolean
|
||
bundeslaender: string[]
|
||
}
|
||
|
||
interface DatasetStats {
|
||
total_documents: number
|
||
total_chunks: number
|
||
training_allowed: number
|
||
by_bundesland: Record<string, number>
|
||
by_doc_type: Record<string, number>
|
||
}
|
||
|
||
interface DataSource {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
collection: string
|
||
document_count: number
|
||
chunk_count: number
|
||
last_updated: string | null
|
||
status: 'active' | 'pending' | 'error'
|
||
}
|
||
|
||
// ============================================================================
|
||
// MOCK DATA
|
||
// ============================================================================
|
||
|
||
const MOCK_JOBS: TrainingJob[] = []
|
||
|
||
const MOCK_STATS: DatasetStats = {
|
||
total_documents: 632,
|
||
total_chunks: 8547,
|
||
training_allowed: 489,
|
||
by_bundesland: {
|
||
ni: 87, by: 92, nw: 78, he: 65, bw: 71, rp: 43, sn: 38, sh: 34, th: 29,
|
||
},
|
||
by_doc_type: {
|
||
verordnung: 312,
|
||
schulordnung: 156,
|
||
handreichung: 98,
|
||
erlass: 66,
|
||
},
|
||
}
|
||
|
||
const MOCK_DATA_SOURCES: DataSource[] = [
|
||
{
|
||
id: 'nibis',
|
||
name: 'NiBiS Erwartungshorizonte',
|
||
description: 'Offizielle Abitur-Erwartungshorizonte vom Niedersaechsischen Bildungsserver',
|
||
collection: 'bp_nibis_eh',
|
||
document_count: 245,
|
||
chunk_count: 3200,
|
||
last_updated: '2025-01-15T10:30:00Z',
|
||
status: 'active',
|
||
},
|
||
{
|
||
id: 'user_eh',
|
||
name: 'Benutzerdefinierte EH',
|
||
description: 'Von Lehrern hochgeladene schulspezifische Erwartungshorizonte',
|
||
collection: 'bp_eh',
|
||
document_count: 87,
|
||
chunk_count: 1100,
|
||
last_updated: '2025-01-20T14:15:00Z',
|
||
status: 'active',
|
||
},
|
||
{
|
||
id: 'legal',
|
||
name: 'Rechtskorpus',
|
||
description: 'DSGVO, AI Act, BSI-Standards und weitere Compliance-Regelwerke',
|
||
collection: 'bp_legal_corpus',
|
||
document_count: 19,
|
||
chunk_count: 2400,
|
||
last_updated: '2025-01-10T08:00:00Z',
|
||
status: 'active',
|
||
},
|
||
{
|
||
id: 'dsfa',
|
||
name: 'DSFA-Guidance',
|
||
description: 'WP248, DSK Kurzpapiere, Muss-Listen aller Bundeslaender mit Quellenattribution',
|
||
collection: 'bp_dsfa_corpus',
|
||
document_count: 45,
|
||
chunk_count: 850,
|
||
last_updated: '2026-02-09T10:00:00Z',
|
||
status: 'active',
|
||
},
|
||
{
|
||
id: 'schulordnungen',
|
||
name: 'Schulordnungen',
|
||
description: 'Landesschulordnungen und Zeugnisverordnungen aller Bundeslaender',
|
||
collection: 'bp_schulordnungen',
|
||
document_count: 156,
|
||
chunk_count: 1847,
|
||
last_updated: null,
|
||
status: 'pending',
|
||
},
|
||
]
|
||
|
||
// ============================================================================
|
||
// API FUNCTIONS
|
||
// ============================================================================
|
||
|
||
async function fetchJobs(): Promise<TrainingJob[]> {
|
||
try {
|
||
const response = await fetch('/api/ai/rag-pipeline?action=jobs')
|
||
if (!response.ok) throw new Error('Failed to fetch jobs')
|
||
return await response.json()
|
||
} catch (error) {
|
||
console.error('Error fetching jobs:', error)
|
||
return MOCK_JOBS
|
||
}
|
||
}
|
||
|
||
async function fetchDatasetStats(): Promise<DatasetStats> {
|
||
try {
|
||
const response = await fetch('/api/ai/rag-pipeline?action=dataset-stats')
|
||
if (!response.ok) throw new Error('Failed to fetch stats')
|
||
return await response.json()
|
||
} catch (error) {
|
||
console.error('Error fetching stats:', error)
|
||
return MOCK_STATS
|
||
}
|
||
}
|
||
|
||
async function createTrainingJob(config: Partial<TrainingConfig>): Promise<{id: string, status: string}> {
|
||
const response = await fetch('/api/ai/rag-pipeline?action=create-job', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: `RAG-Index ${new Date().toLocaleDateString('de-DE')}`,
|
||
model_type: 'zeugnis',
|
||
bundeslaender: config.bundeslaender || [],
|
||
batch_size: config.batch_size || 16,
|
||
learning_rate: config.learning_rate || 0.00005,
|
||
epochs: config.epochs || 10,
|
||
warmup_steps: config.warmup_steps || 500,
|
||
weight_decay: config.weight_decay || 0.01,
|
||
gradient_accumulation: config.gradient_accumulation || 4,
|
||
mixed_precision: config.mixed_precision ?? true,
|
||
}),
|
||
})
|
||
if (!response.ok) {
|
||
const error = await response.json()
|
||
throw new Error(error.detail || 'Failed to create job')
|
||
}
|
||
return await response.json()
|
||
}
|
||
|
||
async function pauseJob(jobId: string): Promise<void> {
|
||
const response = await fetch(`/api/ai/rag-pipeline?action=pause&job_id=${jobId}`, {
|
||
method: 'POST',
|
||
})
|
||
if (!response.ok) throw new Error('Failed to pause job')
|
||
}
|
||
|
||
async function resumeJob(jobId: string): Promise<void> {
|
||
const response = await fetch(`/api/ai/rag-pipeline?action=resume&job_id=${jobId}`, {
|
||
method: 'POST',
|
||
})
|
||
if (!response.ok) throw new Error('Failed to resume job')
|
||
}
|
||
|
||
async function cancelJob(jobId: string): Promise<void> {
|
||
const response = await fetch(`/api/ai/rag-pipeline?action=cancel&job_id=${jobId}`, {
|
||
method: 'POST',
|
||
})
|
||
if (!response.ok) throw new Error('Failed to cancel job')
|
||
}
|
||
|
||
// ============================================================================
|
||
// COMPONENTS
|
||
// ============================================================================
|
||
|
||
// Tab Button
|
||
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
|
||
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
|
||
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
|
||
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
|
||
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>
|
||
)
|
||
}
|
||
|
||
// Training Job Card
|
||
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>
|
||
)
|
||
}
|
||
|
||
// Dataset Overview Component
|
||
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>
|
||
)
|
||
}
|
||
|
||
// Architecture Diagram Component
|
||
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">
|
||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border-2 border-blue-200 dark:border-blue-800 text-center">
|
||
<div className="text-3xl mb-2">📄</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">NiBiS PDFs</div>
|
||
<div className="text-xs text-gray-500">Erwartungshorizonte</div>
|
||
</div>
|
||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border-2 border-green-200 dark:border-green-800 text-center">
|
||
<div className="text-3xl mb-2">📤</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">Uploads</div>
|
||
<div className="text-xs text-gray-500">Eigene EH</div>
|
||
</div>
|
||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl border-2 border-purple-200 dark:border-purple-800 text-center">
|
||
<div className="text-3xl mb-2">⚖️</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">Rechtskorpus</div>
|
||
<div className="text-xs text-gray-500">DSGVO, AI Act</div>
|
||
</div>
|
||
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-xl border-2 border-orange-200 dark:border-orange-800 text-center">
|
||
<div className="text-3xl mb-2">📚</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">Schulordnungen</div>
|
||
<div className="text-xs text-gray-500">Bundeslaender</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Arrow Down */}
|
||
<div className="flex justify-center mb-4">
|
||
<div className="text-4xl text-gray-400">↓</div>
|
||
</div>
|
||
|
||
{/* 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">
|
||
<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">🔍</div>
|
||
<div className="font-medium text-sm">OCR</div>
|
||
<div className="text-xs text-gray-500">Text-Extraktion</div>
|
||
</div>
|
||
<div className="text-2xl text-gray-400">→</div>
|
||
<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">✂️</div>
|
||
<div className="font-medium text-sm">Chunking</div>
|
||
<div className="text-xs text-gray-500">1000 Zeichen</div>
|
||
</div>
|
||
<div className="text-2xl text-gray-400">→</div>
|
||
<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">🧮</div>
|
||
<div className="font-medium text-sm">Embedding</div>
|
||
<div className="text-xs text-gray-500">1536-dim Vektor</div>
|
||
</div>
|
||
<div className="text-2xl text-gray-400">→</div>
|
||
<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">💾</div>
|
||
<div className="font-medium text-sm">Speichern</div>
|
||
<div className="text-xs text-gray-500">Qdrant</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Arrow Down */}
|
||
<div className="flex justify-center mb-4">
|
||
<div className="text-4xl text-gray-400">↓</div>
|
||
</div>
|
||
|
||
{/* 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">
|
||
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
|
||
<div className="font-mono text-xs text-gray-500">bp_nibis_eh</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">Offizielle EH</div>
|
||
</div>
|
||
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
|
||
<div className="font-mono text-xs text-gray-500">bp_eh</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">Benutzer EH</div>
|
||
</div>
|
||
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
|
||
<div className="font-mono text-xs text-gray-500">bp_legal_corpus</div>
|
||
<div className="font-medium text-gray-900 dark:text-white">Rechtskorpus</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Arrow Down */}
|
||
<div className="flex justify-center mb-4">
|
||
<div className="text-4xl text-gray-400">↓</div>
|
||
</div>
|
||
|
||
{/* 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>
|
||
)
|
||
}
|
||
|
||
// Data Sources Tab Component
|
||
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) => (
|
||
<div
|
||
key={source.id}
|
||
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>
|
||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||
source.status === 'active'
|
||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||
: source.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'
|
||
}`}>
|
||
{source.status === 'active' ? 'Aktiv' : source.status === 'pending' ? 'Ausstehend' : 'Fehler'}
|
||
</span>
|
||
</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>
|
||
))}
|
||
</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">
|
||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||
<div className="text-2xl mb-2">📤</div>
|
||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||
Erwartungshorizont hochladen
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||
Laden Sie eigene EH-Dokumente in der Klausur-Korrektur hoch
|
||
</p>
|
||
<a
|
||
href="/admin/klausur-korrektur"
|
||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||
>
|
||
Zur Klausur-Korrektur →
|
||
</a>
|
||
</div>
|
||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||
<div className="text-2xl mb-2">🔄</div>
|
||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||
NiBiS neu einlesen
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||
Starten Sie die automatische Ingestion der NiBiS-PDFs
|
||
</p>
|
||
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
|
||
Ingestion starten →
|
||
</button>
|
||
</div>
|
||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||
<div className="text-2xl mb-2">⚖️</div>
|
||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||
Rechtskorpus erweitern
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||
Neue Regelwerke (DSGVO, BSI, etc.) zum Korpus hinzufuegen
|
||
</p>
|
||
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
|
||
Regelwerk hinzufuegen →
|
||
</button>
|
||
</div>
|
||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||
<div className="text-2xl mb-2">📋</div>
|
||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">
|
||
DSFA-Quellen verwalten
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||
WP248, DSK, Muss-Listen mit Lizenzattribution
|
||
</p>
|
||
<a
|
||
href="/ai/rag-pipeline/dsfa"
|
||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||
>
|
||
DSFA-Manager oeffnen →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// New Training Modal
|
||
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
|
||
|
||
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 },
|
||
]
|
||
|
||
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">
|
||
<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>
|
||
|
||
<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 <= step
|
||
? 'bg-blue-600 text-white'
|
||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||
}`}>
|
||
{s < step ? '\u2713' : s}
|
||
</div>
|
||
{s < 3 && (
|
||
<div className={`w-16 h-1 mx-2 rounded ${
|
||
s < step ? '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>
|
||
|
||
<div className="p-6 overflow-y-auto max-h-[50vh]">
|
||
{step === 1 && (
|
||
<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>
|
||
)}
|
||
|
||
{step === 2 && (
|
||
<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>
|
||
)}
|
||
|
||
{step === 3 && (
|
||
<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>
|
||
)}
|
||
</div>
|
||
|
||
<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>
|
||
)
|
||
}
|
||
|
||
// ============================================================================
|
||
// MAIN PAGE
|
||
// ============================================================================
|
||
|
||
export default function TrainingDashboardPage() {
|
||
const [activeTab, setActiveTab] = useState<'dashboard' | 'architecture' | 'sources'>('dashboard')
|
||
const [jobs, setJobs] = useState<TrainingJob[]>([])
|
||
const [stats, setStats] = useState<DatasetStats>(MOCK_STATS)
|
||
const [dataSources] = useState<DataSource[]>(MOCK_DATA_SOURCES)
|
||
const [showNewTrainingModal, setShowNewTrainingModal] = useState(false)
|
||
const [selectedJob, setSelectedJob] = useState<TrainingJob | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
async function loadData() {
|
||
setIsLoading(true)
|
||
try {
|
||
const [jobsData, statsData] = await Promise.all([
|
||
fetchJobs(),
|
||
fetchDatasetStats(),
|
||
])
|
||
setJobs(jobsData)
|
||
setStats(statsData)
|
||
setError(null)
|
||
} catch (err) {
|
||
console.error('Failed to load data:', err)
|
||
setError('Verbindung zum Backend fehlgeschlagen')
|
||
setJobs(MOCK_JOBS)
|
||
setStats(MOCK_STATS)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
loadData()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const hasActiveJob = jobs.some(j => j.status === 'training' || j.status === 'preparing')
|
||
if (!hasActiveJob) return
|
||
|
||
const interval = setInterval(async () => {
|
||
try {
|
||
const updatedJobs = await fetchJobs()
|
||
setJobs(updatedJobs)
|
||
} catch (err) {
|
||
console.error('Failed to refresh jobs:', err)
|
||
}
|
||
}, 2000)
|
||
|
||
return () => clearInterval(interval)
|
||
}, [jobs])
|
||
|
||
const handleStartTraining = async (config: Partial<TrainingConfig>) => {
|
||
try {
|
||
await createTrainingJob(config)
|
||
const updatedJobs = await fetchJobs()
|
||
setJobs(updatedJobs)
|
||
setShowNewTrainingModal(false)
|
||
} catch (err) {
|
||
console.error('Failed to start training:', err)
|
||
setError(err instanceof Error ? err.message : 'Indexierung konnte nicht gestartet werden')
|
||
}
|
||
}
|
||
|
||
const handlePauseJob = async (jobId: string) => {
|
||
try {
|
||
await pauseJob(jobId)
|
||
const updatedJobs = await fetchJobs()
|
||
setJobs(updatedJobs)
|
||
} catch (err) {
|
||
console.error('Failed to pause job:', err)
|
||
}
|
||
}
|
||
|
||
const handleResumeJob = async (jobId: string) => {
|
||
try {
|
||
await resumeJob(jobId)
|
||
const updatedJobs = await fetchJobs()
|
||
setJobs(updatedJobs)
|
||
} catch (err) {
|
||
console.error('Failed to resume job:', err)
|
||
}
|
||
}
|
||
|
||
const handleCancelJob = async (jobId: string) => {
|
||
try {
|
||
await cancelJob(jobId)
|
||
const updatedJobs = await fetchJobs()
|
||
setJobs(updatedJobs)
|
||
} catch (err) {
|
||
console.error('Failed to cancel job:', err)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<div className="flex justify-between items-start mb-4">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||
RAG-Indexierung
|
||
</h1>
|
||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||
Dokumente fuer die semantische Suche aufbereiten und indexieren
|
||
</p>
|
||
</div>
|
||
{activeTab === 'dashboard' && (
|
||
<button
|
||
onClick={() => setShowNewTrainingModal(true)}
|
||
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-medium rounded-xl shadow-lg hover:shadow-xl transition-all hover:-translate-y-0.5"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
Neue Indexierung
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Page Purpose with Related Pages */}
|
||
<PagePurpose
|
||
title="RAG Pipeline"
|
||
purpose="Indexieren Sie Dokumente fuer die semantische Suche. Das System extrahiert Text aus PDFs (OCR), teilt ihn in Chunks auf, erstellt Vektor-Embeddings und speichert diese in Qdrant. Teil der KI-Daten-Pipeline: Empfaengt Ground Truth vom OCR-Labeling und liefert Embeddings an Daten & RAG."
|
||
audience={['Entwickler', 'Data Scientists', 'Bildungs-Admins']}
|
||
architecture={{
|
||
services: ['klausur-service (Python)', 'embedding-service (Python)'],
|
||
databases: ['Qdrant (Vektor)', 'PostgreSQL', 'MinIO'],
|
||
}}
|
||
relatedPages={[
|
||
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Ground Truth erstellen' },
|
||
{ name: 'Daten & RAG', href: '/ai/rag', description: 'Indexierte Daten durchsuchen' },
|
||
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'RAG-Suche nutzen' },
|
||
]}
|
||
collapsible={true}
|
||
defaultCollapsed={true}
|
||
/>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-2 bg-white dark:bg-gray-800 rounded-xl p-1 shadow-sm border border-gray-200 dark:border-gray-700 w-fit mt-4">
|
||
<TabButton active={activeTab === 'dashboard'} onClick={() => setActiveTab('dashboard')}>
|
||
Dashboard
|
||
</TabButton>
|
||
<TabButton active={activeTab === 'architecture'} onClick={() => setActiveTab('architecture')}>
|
||
Architektur
|
||
</TabButton>
|
||
<TabButton active={activeTab === 'sources'} onClick={() => setActiveTab('sources')}>
|
||
Datenquellen
|
||
</TabButton>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
|
||
<AIModuleSidebarResponsive currentModule="rag-pipeline" />
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded-xl">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-amber-500">⚠</span>
|
||
<span className="text-amber-800 dark:text-amber-200">{error}</span>
|
||
<button
|
||
onClick={() => setError(null)}
|
||
className="ml-auto text-amber-600 hover:text-amber-800"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab Content */}
|
||
{activeTab === 'architecture' ? (
|
||
<ArchitectureTab />
|
||
) : activeTab === 'sources' ? (
|
||
<DataSourcesTab sources={dataSources} />
|
||
) : isLoading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<div className="text-center">
|
||
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" />
|
||
<p className="text-gray-500 dark:text-gray-400">Lade Daten...</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-6">
|
||
{/* Training Jobs */}
|
||
<div className="col-span-2 space-y-6">
|
||
{jobs.length === 0 ? (
|
||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-12 text-center">
|
||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||
Keine aktive Indexierung
|
||
</h3>
|
||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||
Starten Sie eine neue Indexierung, um Dokumente fuer die Suche aufzubereiten.
|
||
</p>
|
||
<button
|
||
onClick={() => setShowNewTrainingModal(true)}
|
||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700"
|
||
>
|
||
Indexierung starten
|
||
</button>
|
||
</div>
|
||
) : (
|
||
jobs.map(job => (
|
||
<TrainingJobCard
|
||
key={job.id}
|
||
job={job}
|
||
onPause={() => handlePauseJob(job.id)}
|
||
onResume={() => handleResumeJob(job.id)}
|
||
onStop={() => handleCancelJob(job.id)}
|
||
onViewDetails={() => setSelectedJob(job)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className="space-y-6">
|
||
<DatasetOverview stats={stats} />
|
||
|
||
{/* Quick Actions */}
|
||
<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">
|
||
Schnellaktionen
|
||
</h3>
|
||
<div className="space-y-3">
|
||
<button
|
||
onClick={() => setActiveTab('architecture')}
|
||
className="w-full px-4 py-3 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition"
|
||
>
|
||
<span className="flex items-center gap-3">
|
||
<span className="text-xl">📐</span>
|
||
<span>
|
||
<span className="block font-medium text-gray-900 dark:text-white">
|
||
Architektur ansehen
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
Wie funktioniert das System?
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('sources')}
|
||
className="w-full px-4 py-3 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition"
|
||
>
|
||
<span className="flex items-center gap-3">
|
||
<span className="text-xl">📚</span>
|
||
<span>
|
||
<span className="block font-medium text-gray-900 dark:text-white">
|
||
Datenquellen
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
Dokumente hinzufuegen
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<button className="w-full px-4 py-3 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition">
|
||
<span className="flex items-center gap-3">
|
||
<span className="text-xl">🔍</span>
|
||
<span>
|
||
<span className="block font-medium text-gray-900 dark:text-white">
|
||
Suche testen
|
||
</span>
|
||
<span className="text-sm text-gray-500">
|
||
RAG-Qualitaet pruefen
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* New Training Modal */}
|
||
<NewTrainingModal
|
||
isOpen={showNewTrainingModal}
|
||
onClose={() => setShowNewTrainingModal(false)}
|
||
onSubmit={handleStartTraining}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|