Fix: Remove broken getKlausurApiUrl and clean up empty lines
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
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:
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
interface GlobalDragOverlayProps {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function GlobalDragOverlay({ active }: GlobalDragOverlayProps) {
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-purple-900/80 backdrop-blur-sm flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-7xl mb-4 animate-bounce">📄</div>
|
||||
<div className="text-2xl font-bold text-white">Bild hier ablegen</div>
|
||||
<div className="text-purple-200 mt-2">PNG, JPG - Handgeschriebener Text</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4">Tastenkuerzel</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Bild einfuegen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+V</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">OCR starten</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+Enter</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Tab wechseln</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Alt+1-6</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Bild entfernen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Escape</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Shortcuts anzeigen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">?</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
export function TabArchitecture() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Architecture Diagram */}
|
||||
<ArchitectureDiagram />
|
||||
|
||||
{/* Components */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<ComponentCard
|
||||
icon="🔍"
|
||||
title="TrOCR Service"
|
||||
description="Das TrOCR-Modell von Microsoft ist speziell fuer Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur fuer Bildverarbeitung und einen Text-Decoder fuer die Textgenerierung."
|
||||
specs={[
|
||||
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
|
||||
{ label: 'Groesse', value: '~350 MB' },
|
||||
{ label: 'Lizenz', value: 'MIT' },
|
||||
{ label: 'Framework', value: 'PyTorch / Transformers' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="🎯"
|
||||
title="LoRA Fine-Tuning"
|
||||
description="LoRA fuegt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu veraendern. Dies ermoeglicht effizientes Fine-Tuning mit minimaler Speichernutzung."
|
||||
specs={[
|
||||
{ label: 'Methode', value: 'Low-Rank Adaptation' },
|
||||
{ label: 'Adapter-Groesse', value: '~10 MB' },
|
||||
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
|
||||
{ label: 'Min. Beispiele', value: '10' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="🔒"
|
||||
title="Pseudonymisierung"
|
||||
description="Schuelernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschliesslich lokal gespeichert."
|
||||
specs={[
|
||||
{ label: 'Methode', value: 'QR-Code Tokens' },
|
||||
{ label: 'Token-Format', value: 'UUID v4' },
|
||||
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
|
||||
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="☁️"
|
||||
title="Cloud LLM"
|
||||
description="Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen uebertragen."
|
||||
specs={[
|
||||
{ label: 'Provider', value: 'SysEleven (DE)' },
|
||||
{ label: 'Standort', value: 'Deutschland' },
|
||||
{ label: 'Isolation', value: 'Namespace pro Schule' },
|
||||
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Flow */}
|
||||
<DataFlowCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ArchitectureDiagram() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-6">Systemarchitektur</h2>
|
||||
|
||||
<div className="bg-slate-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
|
||||
<pre className="text-slate-300">
|
||||
{`┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MAGIC HELP ARCHITEKTUR │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
||||
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
|
||||
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
|
||||
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
|
||||
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Pseudo- │ │ │ │Training │ │ │
|
||||
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
|
||||
│ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
│ │ (nur pseudonymisiert) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ CLOUD LLM │ │
|
||||
│ │ (SysEleven) │ │
|
||||
│ │ Namespace- │ │
|
||||
│ │ Isolation │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentCardProps {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
specs: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
function ComponentCard({ icon, title, description, specs }: ComponentCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<span>{icon}</span> {title}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{specs.map((spec) => (
|
||||
<div key={spec.label} className="flex justify-between">
|
||||
<span className="text-slate-500">{spec.label}</span>
|
||||
<span className="text-slate-900">{spec.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm mt-4">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DATA_FLOW_STEPS = [
|
||||
{
|
||||
num: 1,
|
||||
color: 'bg-blue-100 text-blue-600',
|
||||
title: 'Lokale Header-Extraktion',
|
||||
desc: 'TrOCR erkennt Schuelernamen, Klasse und Fach direkt im Browser/PWA (offline-faehig)',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
color: 'bg-purple-100 text-purple-600',
|
||||
title: 'Pseudonymisierung',
|
||||
desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
color: 'bg-green-100 text-green-600',
|
||||
title: 'Cloud-Korrektur',
|
||||
desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
color: 'bg-yellow-100 text-yellow-600',
|
||||
title: 'Re-Identifikation',
|
||||
desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet',
|
||||
},
|
||||
]
|
||||
|
||||
function DataFlowCard() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h2>
|
||||
<div className="space-y-4">
|
||||
{DATA_FLOW_STEPS.map((step) => (
|
||||
<div key={step.num} className="flex items-start gap-4 bg-slate-50 rounded-lg p-4">
|
||||
<div className={`w-8 h-8 rounded-full ${step.color} flex items-center justify-center font-bold`}>
|
||||
{step.num}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{step.title}</div>
|
||||
<div className="text-sm text-slate-500">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { BatchUploader } from '@/components/ai/BatchUploader'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
export function TabBatch() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Batch OCR Processing */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-2">Batch-Verarbeitung</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Verarbeite mehrere Bilder gleichzeitig mit Echtzeit-Fortschrittsanzeige.
|
||||
Die Ergebnisse werden per Server-Sent Events gestreamt.
|
||||
</p>
|
||||
|
||||
<BatchUploader
|
||||
apiBase={API_BASE}
|
||||
maxFiles={20}
|
||||
autoProcess={false}
|
||||
onComplete={(results) => {
|
||||
console.log('Batch complete:', results)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Batch Processing Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🚀</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Parallele Verarbeitung</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Mehrere Bilder werden parallel verarbeitet fuer maximale Geschwindigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">💾</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Smart Caching</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Identische Bilder werden automatisch aus dem Cache geladen (unter 50ms).
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📊</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Live-Fortschritt</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Echtzeit-Updates via Server-Sent Events zeigen den Verarbeitungsfortschritt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { SkeletonText } from '@/components/common/SkeletonText'
|
||||
import type { TrOCRStatus } from '../types'
|
||||
|
||||
interface TabOverviewProps {
|
||||
status: TrOCRStatus | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function TabOverview({ status, loading, onRefresh }: TabOverviewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Systemstatus</h2>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-slate-50 rounded-lg p-4">
|
||||
<SkeletonText lines={1} className="mb-2" />
|
||||
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : status?.status === 'available' ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.model_name || 'trocr-base'}</div>
|
||||
<div className="text-xs text-slate-500">Modell</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.device || 'CPU'}</div>
|
||||
<div className="text-xs text-slate-500">Geraet</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.training_examples_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
|
||||
<div className="text-xs text-slate-500">LoRA Adapter</div>
|
||||
</div>
|
||||
</div>
|
||||
) : status?.status === 'not_installed' ? (
|
||||
<div className="text-slate-600">
|
||||
<p className="mb-2">TrOCR ist nicht installiert. Fuehre aus:</p>
|
||||
<code className="bg-slate-100 px-3 py-2 rounded text-sm block font-mono">{status.install_command}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600">{status?.error || 'Unbekannter Fehler'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🎯</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Handschrifterkennung</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
|
||||
Das Modell wurde speziell fuer deutsche Handschriften optimiert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Privacy by Design</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Alle Daten werden lokal verarbeitet. Schuelernamen werden durch
|
||||
QR-Codes pseudonymisiert - DSGVO-konform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📈</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Kontinuierliches Lernen</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
|
||||
Handschriften an - ohne das Basismodell zu veraendern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Overview */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Magic Onboarding Workflow</h2>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
{WORKFLOW_STEPS.map((step, i) => (
|
||||
<WorkflowStep key={step.title} step={step} showArrow={i < WORKFLOW_STEPS.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
|
||||
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
|
||||
{ icon: '✅', title: '3. Bestaetigung', desc: 'Klasse, Schueler, Fach' },
|
||||
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
|
||||
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
|
||||
]
|
||||
|
||||
function WorkflowStep({ step, showArrow }: { step: typeof WORKFLOW_STEPS[number]; showArrow: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 bg-slate-50 rounded-lg px-4 py-3">
|
||||
<span className="text-2xl">{step.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{step.title}</div>
|
||||
<div className="text-slate-500">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showArrow && <div className="text-slate-400">→</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import type { MagicSettings } from '../types'
|
||||
import { DEFAULT_SETTINGS } from '../types'
|
||||
|
||||
interface TabSettingsProps {
|
||||
settings: MagicSettings
|
||||
settingsSaved: boolean
|
||||
onUpdateSettings: (settings: MagicSettings) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function TabSettings({ settings, settingsSaved, onUpdateSettings, onSave }: TabSettingsProps) {
|
||||
const update = (partial: Partial<MagicSettings>) => {
|
||||
onUpdateSettings({ ...settings, ...partial })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CheckboxSetting
|
||||
label="Automatische Zeilenerkennung"
|
||||
description="Erkennt und verarbeitet einzelne Zeilen separat"
|
||||
checked={settings.autoDetectLines}
|
||||
onChange={(v) => update({ autoDetectLines: v })}
|
||||
/>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Live-Vorschau"
|
||||
description="OCR startet automatisch nach Bild-Upload"
|
||||
checked={settings.livePreview}
|
||||
onChange={(v) => update({ livePreview: v })}
|
||||
/>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Sound-Feedback"
|
||||
description="Akustisches Feedback bei erfolgreicher Erkennung"
|
||||
checked={settings.soundFeedback}
|
||||
onChange={(v) => update({ soundFeedback: v })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Konfidenz-Schwellwert</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => update({ confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-1">
|
||||
<span>0%</span>
|
||||
<span className="text-slate-900">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Max. Bildgroesse (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxImageSize}
|
||||
onChange={(e) => update({ maxImageSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 mt-1">Groessere Bilder werden skaliert</div>
|
||||
</div>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Ergebnis-Cache aktivieren"
|
||||
description="Speichert OCR-Ergebnisse fuer identische Bilder"
|
||||
checked={settings.enableCache}
|
||||
onChange={(v) => update({ enableCache: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">LoRA Rank</label>
|
||||
<select
|
||||
value={settings.loraRank}
|
||||
onChange={(e) => update({ loraRank: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="4">4 (Schnell, weniger Kapazitaet)</option>
|
||||
<option value="8">8 (Ausgewogen)</option>
|
||||
<option value="16">16 (Mehr Kapazitaet)</option>
|
||||
<option value="32">32 (Maximum)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">LoRA Alpha</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loraAlpha}
|
||||
onChange={(e) => update({ loraAlpha: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 mt-1">Empfohlen: 4 x LoRA Rank</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Epochen</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.epochs}
|
||||
onChange={(e) => update({ epochs: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Batch Size</label>
|
||||
<select
|
||||
value={settings.batchSize}
|
||||
onChange={(e) => update({ batchSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="1">1 (Wenig RAM)</option>
|
||||
<option value="2">2</option>
|
||||
<option value="4">4 (Standard)</option>
|
||||
<option value="8">8 (Viel RAM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Learning Rate</label>
|
||||
<select
|
||||
value={settings.learningRate}
|
||||
onChange={(e) => update({ learningRate: parseFloat(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="0.0001">0.0001 (Schnell)</option>
|
||||
<option value="0.00005">0.00005 (Standard)</option>
|
||||
<option value="0.00001">0.00001 (Konservativ)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => onUpdateSettings(DEFAULT_SETTINGS)}
|
||||
className="px-6 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{settingsSaved ? '\u2713 Gespeichert!' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Technische Informationen</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">API Endpoint:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Model Path:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">LoRA Path:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./models/lora</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Training Data:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./data/training</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CheckboxSetting({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-slate-100 border-slate-300"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-slate-900 font-medium">{label}</div>
|
||||
<div className="text-sm text-slate-500">{description}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import { SkeletonOCRResult, SkeletonDots } from '@/components/common/SkeletonText'
|
||||
import { ConfidenceHeatmap } from '@/components/ai/ConfidenceHeatmap'
|
||||
import type { OCRResult, MagicSettings } from '../types'
|
||||
|
||||
interface TabTestProps {
|
||||
ocrResult: OCRResult | null
|
||||
ocrLoading: boolean
|
||||
imagePreview: string | null
|
||||
uploadedImage: File | null
|
||||
settings: MagicSettings
|
||||
showHeatmap: boolean
|
||||
onToggleHeatmap: () => void
|
||||
onFileUpload: (file: File) => void
|
||||
onManualOCR: () => void
|
||||
onClearImage: () => void
|
||||
onSendToTraining: () => void
|
||||
}
|
||||
|
||||
function getConfidenceColor(confidence: number) {
|
||||
if (confidence >= 0.9) return 'bg-green-500'
|
||||
if (confidence >= 0.7) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
export function TabTest({
|
||||
ocrResult,
|
||||
ocrLoading,
|
||||
imagePreview,
|
||||
uploadedImage,
|
||||
settings,
|
||||
showHeatmap,
|
||||
onToggleHeatmap,
|
||||
onFileUpload,
|
||||
onManualOCR,
|
||||
onClearImage,
|
||||
onSendToTraining,
|
||||
}: TabTestProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Test */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Test</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt
|
||||
den erkannten Text, Konfidenz und Verarbeitungszeit.
|
||||
{settings.livePreview && (
|
||||
<span className="text-purple-600 ml-1">(Live-Vorschau aktiv)</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Area */}
|
||||
<UploadArea
|
||||
imagePreview={imagePreview}
|
||||
uploadedImage={uploadedImage}
|
||||
ocrLoading={ocrLoading}
|
||||
livePreview={settings.livePreview}
|
||||
onFileUpload={onFileUpload}
|
||||
onManualOCR={onManualOCR}
|
||||
onClearImage={onClearImage}
|
||||
/>
|
||||
|
||||
{/* Results Area */}
|
||||
<ResultsArea
|
||||
ocrResult={ocrResult}
|
||||
ocrLoading={ocrLoading}
|
||||
onSendToTraining={onSendToTraining}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence Heatmap */}
|
||||
{imagePreview && ocrResult && ocrResult.confidence > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Konfidenz-Visualisierung</h2>
|
||||
<button
|
||||
onClick={onToggleHeatmap}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||
showHeatmap
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{showHeatmap ? 'Heatmap verbergen' : 'Heatmap anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
{showHeatmap && (
|
||||
<ConfidenceHeatmap
|
||||
imageSrc={imagePreview}
|
||||
text={ocrResult.text}
|
||||
confidence={ocrResult.confidence}
|
||||
wordBoxes={ocrResult.word_boxes?.map(w => ({
|
||||
text: w.text,
|
||||
confidence: w.confidence,
|
||||
bbox: w.bbox as [number, number, number, number]
|
||||
})) || []}
|
||||
charConfidences={ocrResult.char_confidences || []}
|
||||
showLegend={true}
|
||||
toggleable={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence Interpretation */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Konfidenz-Interpretation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-green-700 font-medium">90-100%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Sehr hohe Sicherheit - Text kann direkt uebernommen werden</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="text-yellow-700 font-medium">70-90%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Gute Sicherheit - manuelle Ueberpruefung empfohlen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-red-700 font-medium">< 70%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface UploadAreaProps {
|
||||
imagePreview: string | null
|
||||
uploadedImage: File | null
|
||||
ocrLoading: boolean
|
||||
livePreview: boolean
|
||||
onFileUpload: (file: File) => void
|
||||
onManualOCR: () => void
|
||||
onClearImage: () => void
|
||||
}
|
||||
|
||||
function UploadArea({ imagePreview, uploadedImage, ocrLoading, livePreview, onFileUpload, onManualOCR, onClearImage }: UploadAreaProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
|
||||
imagePreview
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-slate-300 hover:border-purple-500'
|
||||
}`}
|
||||
onClick={() => document.getElementById('ocr-file-input')?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-purple-500', 'bg-purple-50') }}
|
||||
onDragLeave={(e) => { e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50')
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file?.type.startsWith('image/')) onFileUpload(file)
|
||||
}}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Hochgeladenes Bild"
|
||||
className="max-h-64 mx-auto rounded-lg shadow-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClearImage()
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
title="Bild entfernen (Escape)"
|
||||
>
|
||||
<svg className="w-4 h-4" 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="text-4xl mb-2">📄</div>
|
||||
<div className="text-slate-700">Bild hierher ziehen oder klicken zum Hochladen</div>
|
||||
<div className="text-xs text-slate-400 mt-1">PNG, JPG - Handgeschriebener Text</div>
|
||||
<div className="text-xs text-purple-500 mt-2">
|
||||
oder <kbd className="px-1.5 py-0.5 bg-purple-100 rounded font-mono">Ctrl+V</kbd> zum Einfuegen
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="ocr-file-input"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
|
||||
{uploadedImage && !livePreview && (
|
||||
<button
|
||||
onClick={onManualOCR}
|
||||
disabled={ocrLoading}
|
||||
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{ocrLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<SkeletonDots />
|
||||
Analysiere...
|
||||
</span>
|
||||
) : (
|
||||
'OCR starten (Ctrl+Enter)'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ResultsAreaProps {
|
||||
ocrResult: OCRResult | null
|
||||
ocrLoading: boolean
|
||||
onSendToTraining: () => void
|
||||
}
|
||||
|
||||
function ResultsArea({ ocrResult, ocrLoading, onSendToTraining }: ResultsAreaProps) {
|
||||
if (ocrLoading) return <SkeletonOCRResult />
|
||||
|
||||
if (!ocrResult) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center text-slate-400">
|
||||
<div className="text-4xl mb-2">🔍</div>
|
||||
<div>Lade ein Bild hoch um die Erkennung zu testen</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-slate-700">Erkannter Text:</h3>
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
ocrResult.confidence >= 0.9 ? 'bg-green-100 text-green-700' :
|
||||
ocrResult.confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(ocrResult.confidence * 100).toFixed(0)}% Konfidenz
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-white border p-3 rounded text-sm text-slate-900 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{ocrResult.text || '(Kein Text erkannt)'}
|
||||
</pre>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div className="mt-3 mb-3">
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${getConfidenceColor(ocrResult.confidence)}`}
|
||||
style={{ width: `${ocrResult.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Konfidenz</div>
|
||||
<div className="text-slate-900 font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Verarbeitungszeit</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.processing_time_ms}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Modell</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.model || 'TrOCR'}</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">LoRA Adapter</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ocrResult.confidence < 0.9 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
Die Erkennung koennte verbessert werden! Moechtest du dieses Beispiel zum Training hinzufuegen?
|
||||
</p>
|
||||
<button
|
||||
onClick={onSendToTraining}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Als Trainingsbeispiel hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SkeletonDots } from '@/components/common/SkeletonText'
|
||||
import { TrainingMetrics } from '@/components/ai/TrainingMetrics'
|
||||
import type { TrOCRStatus, TrainingExample, MagicSettings } from '../types'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
interface TabTrainingProps {
|
||||
status: TrOCRStatus | null
|
||||
examples: TrainingExample[]
|
||||
trainingImage: File | null
|
||||
trainingText: string
|
||||
fineTuning: boolean
|
||||
settings: MagicSettings
|
||||
showTrainingDashboard: boolean
|
||||
onSetTrainingImage: (file: File | null) => void
|
||||
onSetTrainingText: (text: string) => void
|
||||
onAddExample: () => void
|
||||
onFineTune: () => void
|
||||
onToggleDashboard: () => void
|
||||
}
|
||||
|
||||
export function TabTraining({
|
||||
status,
|
||||
examples,
|
||||
trainingImage,
|
||||
trainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
showTrainingDashboard,
|
||||
onSetTrainingImage,
|
||||
onSetTrainingText,
|
||||
onAddExample,
|
||||
onFineTune,
|
||||
onToggleDashboard,
|
||||
}: TabTrainingProps) {
|
||||
const exampleCount = status?.training_examples_count || 0
|
||||
const progressPct = Math.min(100, (exampleCount / 10) * 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Training Overview */}
|
||||
<TrainingOverviewCard
|
||||
status={status}
|
||||
settings={settings}
|
||||
exampleCount={exampleCount}
|
||||
progressPct={progressPct}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Add Training Example */}
|
||||
<AddExampleCard
|
||||
trainingImage={trainingImage}
|
||||
trainingText={trainingText}
|
||||
onSetTrainingImage={onSetTrainingImage}
|
||||
onSetTrainingText={onSetTrainingText}
|
||||
onAddExample={onAddExample}
|
||||
/>
|
||||
|
||||
{/* Fine-Tuning */}
|
||||
<FineTuningCard
|
||||
settings={settings}
|
||||
fineTuning={fineTuning}
|
||||
exampleCount={exampleCount}
|
||||
hasLoraAdapter={status?.has_lora_adapter || false}
|
||||
onFineTune={onFineTune}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Training Examples List */}
|
||||
{examples.length > 0 && (
|
||||
<ExamplesListCard examples={examples} />
|
||||
)}
|
||||
|
||||
{/* Training Dashboard Demo */}
|
||||
<TrainingDashboardCard
|
||||
showDashboard={showTrainingDashboard}
|
||||
onToggle={onToggleDashboard}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function TrainingOverviewCard({
|
||||
status,
|
||||
settings,
|
||||
exampleCount,
|
||||
progressPct,
|
||||
}: {
|
||||
status: TrOCRStatus | null
|
||||
settings: MagicSettings
|
||||
exampleCount: number
|
||||
progressPct: number
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training mit LoRA</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
LoRA (Low-Rank Adaptation) ermoeglicht effizientes Fine-Tuning ohne das Basismodell zu veraendern.
|
||||
Das Training erfolgt lokal auf Ihrem System.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{exampleCount}</div>
|
||||
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">10</div>
|
||||
<div className="text-xs text-slate-500">Minimum benoetigt</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{settings.loraRank}</div>
|
||||
<div className="text-xs text-slate-500">LoRA Rank</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{status?.has_lora_adapter ? '\u2713' : '\u2717'}</div>
|
||||
<div className="text-xs text-slate-500">Adapter aktiv</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-500">Fortschritt zum Fine-Tuning</span>
|
||||
<span className="text-slate-500">{progressPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddExampleCard({
|
||||
trainingImage,
|
||||
trainingText,
|
||||
onSetTrainingImage,
|
||||
onSetTrainingText,
|
||||
onAddExample,
|
||||
}: {
|
||||
trainingImage: File | null
|
||||
trainingText: string
|
||||
onSetTrainingImage: (file: File | null) => void
|
||||
onSetTrainingText: (text: string) => void
|
||||
onAddExample: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiel hinzufuegen</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-1">Bild</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm"
|
||||
onChange={(e) => onSetTrainingImage(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{trainingImage && (
|
||||
<div className="mt-2 text-xs text-green-600">
|
||||
Bild ausgewaehlt: {trainingImage.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-1">Korrekter Text (Ground Truth)</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-900 resize-none"
|
||||
rows={3}
|
||||
placeholder="Gib hier den korrekten Text ein..."
|
||||
value={trainingText}
|
||||
onChange={(e) => onSetTrainingText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddExample}
|
||||
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ Trainingsbeispiel hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FineTuningCard({
|
||||
settings,
|
||||
fineTuning,
|
||||
exampleCount,
|
||||
hasLoraAdapter,
|
||||
onFineTune,
|
||||
}: {
|
||||
settings: MagicSettings
|
||||
fineTuning: boolean
|
||||
exampleCount: number
|
||||
hasLoraAdapter: boolean
|
||||
onFineTune: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Fine-Tuning starten</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
|
||||
je nach Anzahl der Beispiele einige Minuten.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Epochen:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.epochs}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Learning Rate:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.learningRate}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">LoRA Rank:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.loraRank}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Batch Size:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.batchSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onFineTune}
|
||||
disabled={fineTuning || exampleCount < 10}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{fineTuning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<SkeletonDots />
|
||||
Fine-Tuning laeuft...
|
||||
</span>
|
||||
) : (
|
||||
'Fine-Tuning starten'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{exampleCount < 10 && (
|
||||
<p className="text-xs text-yellow-600 mt-2 text-center">
|
||||
Noch {10 - exampleCount} Beispiele benoetigt
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/ai/ocr-labeling?model=trocr-lora"
|
||||
className="w-full mt-4 px-4 py-2 bg-teal-100 text-teal-700 border border-teal-300 rounded-lg hover:bg-teal-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🏷️</span>
|
||||
Ground Truth in OCR-Labeling sammeln
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExamplesListCard({ examples }: { examples: TrainingExample[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiele ({examples.length})</h2>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{examples.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-4 bg-slate-50 rounded-lg p-3">
|
||||
<span className="text-slate-400 font-mono text-sm w-8">{i + 1}.</span>
|
||||
<span className="text-slate-900 text-sm flex-1 truncate">{ex.ground_truth}</span>
|
||||
<span className="text-slate-400 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingDashboardCard({
|
||||
showDashboard,
|
||||
onToggle,
|
||||
}: {
|
||||
showDashboard: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Training Dashboard</h2>
|
||||
<p className="text-sm text-slate-500">Live-Metriken waehrend des Trainings</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDashboard
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-purple-600 hover:bg-purple-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{showDashboard ? 'Demo stoppen' : 'Demo starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDashboard ? (
|
||||
<TrainingMetrics
|
||||
apiBase={API_BASE}
|
||||
simulateMode={true}
|
||||
onComplete={onToggle}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<div className="text-4xl mb-3">📈</div>
|
||||
<div className="text-slate-600 mb-2">
|
||||
Das Training Dashboard zeigt Echtzeit-Metriken waehrend des Fine-Tunings
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
Klicke "Demo starten" um eine simulierte Training-Session zu sehen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { GlobalDragOverlay, KeyboardShortcutsModal } from './GlobalOverlays'
|
||||
export { TabOverview } from './TabOverview'
|
||||
export { TabTest } from './TabTest'
|
||||
export { TabBatch } from './TabBatch'
|
||||
export { TabTraining } from './TabTraining'
|
||||
export { TabArchitecture } from './TabArchitecture'
|
||||
export { TabSettings } from './TabSettings'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
export type TabId = 'overview' | 'test' | 'batch' | 'training' | 'architecture' | 'settings'
|
||||
|
||||
export interface TrOCRStatus {
|
||||
status: 'available' | 'not_installed' | 'error'
|
||||
model_name?: string
|
||||
model_id?: string
|
||||
device?: string
|
||||
is_loaded?: boolean
|
||||
has_lora_adapter?: boolean
|
||||
training_examples_count?: number
|
||||
error?: string
|
||||
install_command?: string
|
||||
}
|
||||
|
||||
export interface OCRResult {
|
||||
text: string
|
||||
confidence: number
|
||||
processing_time_ms: number
|
||||
model: string
|
||||
has_lora_adapter: boolean
|
||||
char_confidences?: number[]
|
||||
word_boxes?: Array<{ text: string; confidence: number; bbox: number[] }>
|
||||
}
|
||||
|
||||
export interface TrainingExample {
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
teacher_id: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MagicSettings {
|
||||
autoDetectLines: boolean
|
||||
confidenceThreshold: number
|
||||
maxImageSize: number
|
||||
loraRank: number
|
||||
loraAlpha: number
|
||||
learningRate: number
|
||||
epochs: number
|
||||
batchSize: number
|
||||
enableCache: boolean
|
||||
cacheMaxAge: number
|
||||
livePreview: boolean
|
||||
soundFeedback: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MagicSettings = {
|
||||
autoDetectLines: true,
|
||||
confidenceThreshold: 0.7,
|
||||
maxImageSize: 4096,
|
||||
loraRank: 8,
|
||||
loraAlpha: 32,
|
||||
learningRate: 0.00005,
|
||||
epochs: 3,
|
||||
batchSize: 4,
|
||||
enableCache: true,
|
||||
cacheMaxAge: 3600,
|
||||
livePreview: true,
|
||||
soundFeedback: false,
|
||||
}
|
||||
|
||||
export const TABS = [
|
||||
{ id: 'overview' as TabId, label: 'Uebersicht', icon: '\u{1F4CA}', shortcut: 'Alt+1' },
|
||||
{ id: 'test' as TabId, label: 'OCR Test', icon: '\u{1F50D}', shortcut: 'Alt+2' },
|
||||
{ id: 'batch' as TabId, label: 'Batch OCR', icon: '\u{1F4C1}', shortcut: 'Alt+3' },
|
||||
{ id: 'training' as TabId, label: 'Training', icon: '\u{1F3AF}', shortcut: 'Alt+4' },
|
||||
{ id: 'architecture' as TabId, label: 'Architektur', icon: '\u{1F3D7}\uFE0F', shortcut: 'Alt+5' },
|
||||
{ id: 'settings' as TabId, label: 'Einstellungen', icon: '\u2699\uFE0F', shortcut: 'Alt+6' },
|
||||
] as const
|
||||
|
||||
export const API_BASE = '/klausur-api'
|
||||
@@ -0,0 +1,382 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
type TabId,
|
||||
type TrOCRStatus,
|
||||
type OCRResult,
|
||||
type TrainingExample,
|
||||
type MagicSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
API_BASE,
|
||||
} from './types'
|
||||
|
||||
function playSuccessSound() {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gainNode = audioContext.createGain()
|
||||
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(audioContext.destination)
|
||||
|
||||
oscillator.frequency.value = 800
|
||||
oscillator.type = 'sine'
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2)
|
||||
|
||||
oscillator.start(audioContext.currentTime)
|
||||
oscillator.stop(audioContext.currentTime + 0.2)
|
||||
} catch {
|
||||
// Audio not supported, ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useMagicHelp() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [status, setStatus] = useState<TrOCRStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
|
||||
const [ocrLoading, setOcrLoading] = useState(false)
|
||||
const [examples, setExamples] = useState<TrainingExample[]>([])
|
||||
const [trainingImage, setTrainingImage] = useState<File | null>(null)
|
||||
const [trainingText, setTrainingText] = useState('')
|
||||
const [fineTuning, setFineTuning] = useState(false)
|
||||
const [settings, setSettings] = useState<MagicSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsSaved, setSettingsSaved] = useState(false)
|
||||
|
||||
// Phase 1: New state for enhanced features
|
||||
const [globalDragActive, setGlobalDragActive] = useState(false)
|
||||
const [uploadedImage, setUploadedImage] = useState<File | null>(null)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(false)
|
||||
const [showHeatmap, setShowHeatmap] = useState(false)
|
||||
const [showTrainingDashboard, setShowTrainingDashboard] = useState(false)
|
||||
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/status`)
|
||||
const data = await res.json()
|
||||
setStatus(data)
|
||||
} catch {
|
||||
setStatus({ status: 'error', error: 'Failed to fetch status' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchExamples = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/examples`)
|
||||
const data = await res.json()
|
||||
setExamples(data.examples || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch examples:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Phase 1: Live OCR with debounce
|
||||
const triggerOCR = useCallback(async (file: File) => {
|
||||
setOcrLoading(true)
|
||||
setOcrResult(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.text !== undefined) {
|
||||
setOcrResult(data)
|
||||
if (settings.soundFeedback && data.confidence > 0.7) {
|
||||
playSuccessSound()
|
||||
}
|
||||
} else {
|
||||
setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
|
||||
}
|
||||
} catch (error) {
|
||||
setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false })
|
||||
} finally {
|
||||
setOcrLoading(false)
|
||||
}
|
||||
}, [settings.autoDetectLines, settings.soundFeedback])
|
||||
|
||||
// Handle file upload with live preview
|
||||
const handleFileUpload = useCallback((file: File) => {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
|
||||
setUploadedImage(file)
|
||||
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
setImagePreview(previewUrl)
|
||||
|
||||
setActiveTab('test')
|
||||
|
||||
if (settings.livePreview) {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
triggerOCR(file)
|
||||
}, 500)
|
||||
}
|
||||
}, [settings.livePreview, triggerOCR])
|
||||
|
||||
const handleManualOCR = () => {
|
||||
if (uploadedImage) {
|
||||
triggerOCR(uploadedImage)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Global Drag & Drop handler
|
||||
useEffect(() => {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter.current++
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
setGlobalDragActive(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter.current--
|
||||
if (dragCounter.current === 0) {
|
||||
setGlobalDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dragCounter.current = 0
|
||||
setGlobalDragActive(false)
|
||||
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file?.type.startsWith('image/')) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', handleDragEnter)
|
||||
document.addEventListener('dragleave', handleDragLeave)
|
||||
document.addEventListener('dragover', handleDragOver)
|
||||
document.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', handleDragEnter)
|
||||
document.removeEventListener('dragleave', handleDragLeave)
|
||||
document.removeEventListener('dragover', handleDragOver)
|
||||
document.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleFileUpload])
|
||||
|
||||
// Phase 1: Clipboard paste handler (Ctrl+V)
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
}, [handleFileUpload])
|
||||
|
||||
// Phase 1: Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'Enter' && uploadedImage) {
|
||||
e.preventDefault()
|
||||
handleManualOCR()
|
||||
}
|
||||
|
||||
if (e.key >= '1' && e.key <= '6' && e.altKey) {
|
||||
e.preventDefault()
|
||||
const tabIndex = parseInt(e.key) - 1
|
||||
const tabIds: TabId[] = ['overview', 'test', 'batch', 'training', 'architecture', 'settings']
|
||||
if (tabIds[tabIndex]) {
|
||||
setActiveTab(tabIds[tabIndex])
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && uploadedImage) {
|
||||
setUploadedImage(null)
|
||||
setImagePreview(null)
|
||||
setOcrResult(null)
|
||||
}
|
||||
|
||||
if (e.key === '?') {
|
||||
setShowShortcutHint(prev => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uploadedImage])
|
||||
|
||||
// Initial data load + settings from localStorage
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchExamples()
|
||||
const saved = localStorage.getItem('magic-help-settings')
|
||||
if (saved) {
|
||||
try {
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(saved) })
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [fetchStatus, fetchExamples])
|
||||
|
||||
// Cleanup preview URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imagePreview) {
|
||||
URL.revokeObjectURL(imagePreview)
|
||||
}
|
||||
}
|
||||
}, [imagePreview])
|
||||
|
||||
const handleAddTrainingExample = async () => {
|
||||
if (!trainingImage || !trainingText.trim()) {
|
||||
alert('Please provide both an image and the correct text')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', trainingImage)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.example_id) {
|
||||
alert(`Training example added! Total: ${data.total_examples}`)
|
||||
setTrainingImage(null)
|
||||
setTrainingText('')
|
||||
fetchStatus()
|
||||
fetchExamples()
|
||||
} else {
|
||||
alert(`Error: ${data.detail || 'Unknown error'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFineTune = async () => {
|
||||
if (!confirm('Start fine-tuning? This may take several minutes.')) return
|
||||
|
||||
setFineTuning(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/klausur/trocr/training/fine-tune`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
epochs: settings.epochs,
|
||||
learning_rate: settings.learningRate,
|
||||
lora_rank: settings.loraRank,
|
||||
lora_alpha: settings.loraAlpha,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.status === 'success') {
|
||||
alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`)
|
||||
fetchStatus()
|
||||
} else {
|
||||
alert(`Fine-tuning failed: ${data.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error}`)
|
||||
} finally {
|
||||
setFineTuning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem('magic-help-settings', JSON.stringify(settings))
|
||||
setSettingsSaved(true)
|
||||
setTimeout(() => setSettingsSaved(false), 2000)
|
||||
}
|
||||
|
||||
const clearUploadedImage = () => {
|
||||
setUploadedImage(null)
|
||||
setImagePreview(null)
|
||||
setOcrResult(null)
|
||||
}
|
||||
|
||||
const sendToTraining = () => {
|
||||
if (uploadedImage && ocrResult) {
|
||||
setTrainingImage(uploadedImage)
|
||||
setTrainingText(ocrResult.text)
|
||||
setActiveTab('training')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
status,
|
||||
loading,
|
||||
ocrResult,
|
||||
ocrLoading,
|
||||
examples,
|
||||
trainingImage,
|
||||
setTrainingImage,
|
||||
trainingText,
|
||||
setTrainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
setSettings,
|
||||
settingsSaved,
|
||||
globalDragActive,
|
||||
uploadedImage,
|
||||
imagePreview,
|
||||
showShortcutHint,
|
||||
setShowShortcutHint,
|
||||
showHeatmap,
|
||||
setShowHeatmap,
|
||||
showTrainingDashboard,
|
||||
setShowTrainingDashboard,
|
||||
|
||||
// Actions
|
||||
fetchStatus,
|
||||
handleFileUpload,
|
||||
handleManualOCR,
|
||||
handleAddTrainingExample,
|
||||
handleFineTune,
|
||||
saveSettings,
|
||||
clearUploadedImage,
|
||||
sendToTraining,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseMagicHelpReturn = ReturnType<typeof useMagicHelp>
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { TrainingJob, TrainingConfig, DatasetStats, DataSource } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA
|
||||
// ============================================================================
|
||||
|
||||
export const MOCK_JOBS: TrainingJob[] = []
|
||||
|
||||
export 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,
|
||||
},
|
||||
}
|
||||
|
||||
export 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
|
||||
// ============================================================================
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
export 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()
|
||||
}
|
||||
|
||||
export 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')
|
||||
}
|
||||
|
||||
export 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')
|
||||
}
|
||||
|
||||
export 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')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
// ============================================================================
|
||||
// RAG Pipeline Types
|
||||
// ============================================================================
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface TrainingMetrics {
|
||||
precision: number
|
||||
recall: number
|
||||
f1_score: number
|
||||
accuracy: number
|
||||
loss_history: number[]
|
||||
val_loss_history: number[]
|
||||
confusion_matrix?: number[][]
|
||||
}
|
||||
|
||||
export interface TrainingConfig {
|
||||
batch_size: number
|
||||
learning_rate: number
|
||||
epochs: number
|
||||
warmup_steps: number
|
||||
weight_decay: number
|
||||
gradient_accumulation: number
|
||||
mixed_precision: boolean
|
||||
bundeslaender: string[]
|
||||
}
|
||||
|
||||
export interface DatasetStats {
|
||||
total_documents: number
|
||||
total_chunks: number
|
||||
training_allowed: number
|
||||
by_bundesland: Record<string, number>
|
||||
by_doc_type: Record<string, number>
|
||||
}
|
||||
|
||||
export interface DataSource {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
collection: string
|
||||
document_count: number
|
||||
chunk_count: number
|
||||
last_updated: string | null
|
||||
status: 'active' | 'pending' | 'error'
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TrainingJob, TrainingConfig, DatasetStats, DataSource } from './types'
|
||||
import {
|
||||
MOCK_JOBS,
|
||||
MOCK_STATS,
|
||||
MOCK_DATA_SOURCES,
|
||||
fetchJobs,
|
||||
fetchDatasetStats,
|
||||
createTrainingJob,
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
cancelJob,
|
||||
} from './api'
|
||||
|
||||
export type TabType = 'dashboard' | 'architecture' | 'sources'
|
||||
|
||||
export interface RagPipelineState {
|
||||
activeTab: TabType
|
||||
setActiveTab: (tab: TabType) => void
|
||||
jobs: TrainingJob[]
|
||||
stats: DatasetStats
|
||||
dataSources: DataSource[]
|
||||
showNewTrainingModal: boolean
|
||||
setShowNewTrainingModal: (show: boolean) => void
|
||||
selectedJob: TrainingJob | null
|
||||
setSelectedJob: (job: TrainingJob | null) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
setError: (error: string | null) => void
|
||||
handleStartTraining: (config: Partial<TrainingConfig>) => Promise<void>
|
||||
handlePauseJob: (jobId: string) => Promise<void>
|
||||
handleResumeJob: (jobId: string) => Promise<void>
|
||||
handleCancelJob: (jobId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useRagPipeline(): RagPipelineState {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('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 {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
jobs,
|
||||
stats,
|
||||
dataSources,
|
||||
showNewTrainingModal,
|
||||
setShowNewTrainingModal,
|
||||
selectedJob,
|
||||
setSelectedJob,
|
||||
isLoading,
|
||||
error,
|
||||
setError,
|
||||
handleStartTraining,
|
||||
handlePauseJob,
|
||||
handleResumeJob,
|
||||
handleCancelJob,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface DataTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function DataTab({ hook }: DataTabProps) {
|
||||
const {
|
||||
customDocuments,
|
||||
uploadFile,
|
||||
setUploadFile,
|
||||
uploadTitle,
|
||||
setUploadTitle,
|
||||
uploadCode,
|
||||
setUploadCode,
|
||||
uploading,
|
||||
handleUpload,
|
||||
linkUrl,
|
||||
setLinkUrl,
|
||||
linkTitle,
|
||||
setLinkTitle,
|
||||
linkCode,
|
||||
setLinkCode,
|
||||
addingLink,
|
||||
handleAddLink,
|
||||
handleDeleteDocument,
|
||||
fetchCustomDocuments,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upload Document */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Dokument hochladen (PDF)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">PDF-Datei</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadTitle}
|
||||
onChange={(e) => setUploadTitle(e.target.value)}
|
||||
placeholder="z.B. Firmen-Datenschutzrichtlinie"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Code (eindeutig)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadCode}
|
||||
onChange={(e) => setUploadCode(e.target.value.toUpperCase())}
|
||||
placeholder="z.B. CUSTOM-DSR-01"
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !uploadFile || !uploadTitle || !uploadCode}
|
||||
className="mt-4 px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Hochladen & Indexieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Link */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Link hinzufuegen (Webseite/PDF)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com/document.pdf"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={linkTitle}
|
||||
onChange={(e) => setLinkTitle(e.target.value)}
|
||||
placeholder="z.B. BSI IT-Grundschutz"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Code (eindeutig)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={linkCode}
|
||||
onChange={(e) => setLinkCode(e.target.value.toUpperCase())}
|
||||
placeholder="z.B. BSI-GRUNDSCHUTZ"
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddLink}
|
||||
disabled={addingLink || !linkUrl || !linkTitle || !linkCode}
|
||||
className="mt-4 px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{addingLink ? 'Wird hinzugefuegt...' : 'Link hinzufuegen & Indexieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Custom Documents List */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Eigene Dokumente ({customDocuments.length})</h3>
|
||||
<button
|
||||
onClick={fetchCustomDocuments}
|
||||
className="text-sm text-teal-600 hover:text-teal-700"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{customDocuments.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
Noch keine eigenen Dokumente hinzugefuegt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{customDocuments.map((doc) => (
|
||||
<div key={doc.id} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-lg">
|
||||
{doc.url ? '🔗' : '📄'}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{doc.title}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="font-mono text-teal-600">{doc.code}</span>
|
||||
{' • '}
|
||||
{doc.filename || doc.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
doc.status === 'indexed' ? 'bg-green-100 text-green-700' :
|
||||
doc.status === 'error' ? 'bg-red-100 text-red-700' :
|
||||
doc.status === 'processing' || doc.status === 'fetching' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{doc.status === 'indexed' ? `${doc.chunk_count} Chunks` :
|
||||
doc.status === 'error' ? 'Fehler' :
|
||||
doc.status === 'processing' ? 'Verarbeitung...' :
|
||||
doc.status === 'fetching' ? 'Abruf...' :
|
||||
doc.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteDocument(doc.id)}
|
||||
className="text-red-500 hover:text-red-700 text-sm"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-teal-800 flex items-center gap-2">
|
||||
<span>ℹ️</span>
|
||||
Hinweis zur Verwendung
|
||||
</h4>
|
||||
<p className="text-sm text-teal-700 mt-2">
|
||||
Laden Sie eigene Dokumente (z.B. interne Datenschutzrichtlinien, Vertraege) oder
|
||||
externe Links hoch. Diese werden automatisch in Chunks aufgeteilt und indexiert.
|
||||
Nach dem Hinzufuegen koennen Sie im <strong>Pipeline</strong>-Tab die vollstaendige
|
||||
Compliance-Analyse starten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface IngestionTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function IngestionTab({ hook }: IngestionTabProps) {
|
||||
const { ingestionRunning, ingestionLog, triggerIngestion } = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Ingestion Control */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Legal Corpus Re-Ingestion</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Startet die Neuindexierung aller 19 Regulierungen. Die Dokumente werden von EUR-Lex,
|
||||
gesetze-im-internet.de und BSI heruntergeladen, in semantische Chunks aufgeteilt und
|
||||
mit BGE-M3 Embeddings in Qdrant indexiert.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={triggerIngestion}
|
||||
disabled={ingestionRunning}
|
||||
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{ingestionRunning ? 'Laeuft...' : 'Re-Ingestion starten'}
|
||||
</button>
|
||||
{ingestionRunning && (
|
||||
<span className="flex items-center gap-2 text-teal-600">
|
||||
<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.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Ingestion laeuft...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ingestion Log */}
|
||||
{ingestionLog.length > 0 && (
|
||||
<div className="bg-slate-900 rounded-xl p-4">
|
||||
<h4 className="text-slate-400 text-sm mb-2">Log</h4>
|
||||
<div className="font-mono text-sm text-green-400 space-y-1 max-h-64 overflow-y-auto">
|
||||
{ingestionLog.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
|
||||
<h4 className="font-semibold text-teal-800 flex items-center gap-2">
|
||||
<span>💡</span>
|
||||
Hinweis zur Datenquelle
|
||||
</h4>
|
||||
<p className="text-sm text-teal-700 mt-2">
|
||||
Alle indexierten Dokumente sind amtliche Werke (§5 UrhG) und damit urheberrechtsfrei.
|
||||
Sie werden nur fuer RAG/Retrieval verwendet, nicht fuer Modell-Training.
|
||||
Die Daten werden lokal auf dem Mac Mini verarbeitet und nicht an externe Dienste gesendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
REGULATIONS,
|
||||
DOC_TYPES,
|
||||
INDUSTRIES_LIST,
|
||||
INDUSTRIES,
|
||||
INDUSTRY_REGULATION_MAP,
|
||||
TYPE_COLORS,
|
||||
THEMATIC_GROUPS,
|
||||
KEY_INTERSECTIONS,
|
||||
RAG_DOCUMENTS,
|
||||
isInRag,
|
||||
} from '../rag-data'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
import {
|
||||
FutureOutlookSection,
|
||||
RagCoverageSection,
|
||||
FutureRegulationsSection,
|
||||
LegalBasisSection,
|
||||
} from './MapTabSections'
|
||||
|
||||
interface MapTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function MapTab({ hook }: MapTabProps) {
|
||||
const {
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
expandedDocTypes,
|
||||
setExpandedDocTypes,
|
||||
expandedMatrixDoc,
|
||||
setExpandedMatrixDoc,
|
||||
setActiveTab,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Industry Filter */}
|
||||
<IndustryFilter
|
||||
expandedRegulation={expandedRegulation}
|
||||
setExpandedRegulation={setExpandedRegulation}
|
||||
/>
|
||||
|
||||
{/* Thematic Groups */}
|
||||
<ThematicGroupsSection setActiveTab={setActiveTab} setExpandedRegulation={setExpandedRegulation} />
|
||||
|
||||
{/* Key Intersections */}
|
||||
<KeyIntersectionsSection />
|
||||
|
||||
{/* Regulation Matrix */}
|
||||
<RegulationMatrix
|
||||
expandedDocTypes={expandedDocTypes}
|
||||
setExpandedDocTypes={setExpandedDocTypes}
|
||||
expandedMatrixDoc={expandedMatrixDoc}
|
||||
setExpandedMatrixDoc={setExpandedMatrixDoc}
|
||||
/>
|
||||
|
||||
{/* Future Outlook Section */}
|
||||
<FutureOutlookSection />
|
||||
|
||||
{/* RAG Coverage Overview */}
|
||||
<RagCoverageSection />
|
||||
|
||||
{/* Potential Future Regulations */}
|
||||
<FutureRegulationsSection />
|
||||
|
||||
{/* Legal Basis Info */}
|
||||
<LegalBasisSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function IndustryFilter({
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
}: {
|
||||
expandedRegulation: string | null
|
||||
setExpandedRegulation: (v: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Regulierungen nach Branche</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Waehlen Sie Ihre Branche, um relevante Regulierungen zu sehen.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{INDUSTRIES.map((industry) => {
|
||||
const regs = INDUSTRY_REGULATION_MAP[industry.id] || []
|
||||
return (
|
||||
<button
|
||||
key={industry.id}
|
||||
onClick={() => setExpandedRegulation(industry.id === expandedRegulation ? null : industry.id)}
|
||||
className={`p-4 rounded-lg border text-left transition-all ${
|
||||
expandedRegulation === industry.id
|
||||
? 'border-teal-500 bg-teal-50 ring-2 ring-teal-200'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">{industry.icon}</div>
|
||||
<div className="font-medium text-slate-900 text-sm">{industry.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">{regs.length} Regulierungen</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Selected Industry Details */}
|
||||
{expandedRegulation && INDUSTRIES.find(i => i.id === expandedRegulation) && (
|
||||
<div className="mt-6 p-4 bg-slate-50 rounded-lg">
|
||||
{(() => {
|
||||
const industry = INDUSTRIES.find(i => i.id === expandedRegulation)!
|
||||
const regCodes = INDUSTRY_REGULATION_MAP[industry.id] || []
|
||||
const regs = REGULATIONS.filter(r => regCodes.includes(r.code))
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-3xl">{industry.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">{industry.name}</h4>
|
||||
<p className="text-sm text-slate-500">{industry.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{regs.map((reg) => {
|
||||
const regInRag = isInRag(reg.code)
|
||||
return (
|
||||
<div
|
||||
key={reg.code}
|
||||
className={`bg-white p-3 rounded-lg border ${regInRag ? 'border-green-200' : 'border-slate-200'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{reg.code}
|
||||
</span>
|
||||
{regInRag ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-green-100 text-green-600 rounded">RAG</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-red-50 text-red-400 rounded">✗</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-medium text-sm text-slate-900">{reg.name}</div>
|
||||
<div className="text-xs text-slate-500 mt-1 line-clamp-2">{reg.description}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ThematicGroupsSection({
|
||||
setActiveTab,
|
||||
setExpandedRegulation,
|
||||
}: {
|
||||
setActiveTab: (v: any) => void
|
||||
setExpandedRegulation: (v: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Thematische Cluster</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Regulierungen gruppiert nach Themenbereichen - zeigt Ueberschneidungen.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{THEMATIC_GROUPS.map((group) => (
|
||||
<div key={group.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<div className={`${group.color} px-4 py-2 text-white font-medium flex items-center justify-between`}>
|
||||
<span>{group.name}</span>
|
||||
<span className="text-sm opacity-80">{group.regulations.length} Regulierungen</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-600 mb-3">{group.description}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.regulations.map((code) => {
|
||||
const reg = REGULATIONS.find(r => r.code === code)
|
||||
const codeInRag = isInRag(code)
|
||||
return (
|
||||
<span
|
||||
key={code}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium cursor-pointer ${
|
||||
codeInRag
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab('regulations')
|
||||
setExpandedRegulation(code)
|
||||
}}
|
||||
title={`${reg?.fullName || code}${codeInRag ? ' (im RAG)' : ' (nicht im RAG)'}`}
|
||||
>
|
||||
{codeInRag ? '✓ ' : '✗ '}{code}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyIntersectionsSection() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Wichtige Schnittstellen</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Bereiche, in denen sich mehrere Regulierungen ueberschneiden und zusammenwirken.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{KEY_INTERSECTIONS.map((intersection, idx) => (
|
||||
<div key={idx} className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{intersection.regulations.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
isInRag(code)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-50 text-red-500'
|
||||
}`}
|
||||
>
|
||||
{isInRag(code) ? '✓ ' : '✗ '}{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="font-medium text-slate-900 text-sm mb-1">{intersection.topic}</div>
|
||||
<div className="text-xs text-slate-500">{intersection.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RegulationMatrix({
|
||||
expandedDocTypes,
|
||||
setExpandedDocTypes,
|
||||
expandedMatrixDoc,
|
||||
setExpandedMatrixDoc,
|
||||
}: {
|
||||
expandedDocTypes: string[]
|
||||
setExpandedDocTypes: (fn: (prev: string[]) => string[]) => void
|
||||
expandedMatrixDoc: string | null
|
||||
setExpandedMatrixDoc: (v: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Branchen-Regulierungs-Matrix</h3>
|
||||
<p className="text-sm text-slate-500">{RAG_DOCUMENTS.length} Dokumente in {DOC_TYPES.length} Kategorien</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 border-b sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left font-medium text-slate-500 sticky left-0 bg-slate-50 min-w-[200px]">Regulierung</th>
|
||||
{INDUSTRIES_LIST.filter((i: any) => i.id !== 'all').map((industry: any) => (
|
||||
<th key={industry.id} className="px-2 py-2 text-center font-medium text-slate-500 min-w-[60px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-lg">{industry.icon}</span>
|
||||
<span className="text-[10px] leading-tight">{industry.name.split('/')[0]}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DOC_TYPES.map((docType: any) => {
|
||||
const docsInType = RAG_DOCUMENTS.filter((d: any) => d.doc_type === docType.id)
|
||||
if (docsInType.length === 0) return null
|
||||
|
||||
const isExpanded = expandedDocTypes.includes(docType.id)
|
||||
|
||||
return (
|
||||
<React.Fragment key={docType.id}>
|
||||
<tr
|
||||
className="bg-slate-100 border-t-2 border-slate-300 cursor-pointer hover:bg-slate-200"
|
||||
onClick={() => {
|
||||
setExpandedDocTypes(prev =>
|
||||
prev.includes(docType.id)
|
||||
? prev.filter((id: string) => id !== docType.id)
|
||||
: [...prev, docType.id]
|
||||
)
|
||||
}}
|
||||
>
|
||||
<td colSpan={INDUSTRIES_LIST.length} className="px-3 py-2 font-bold text-slate-700">
|
||||
<span className="mr-2">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
||||
{docType.icon} {docType.label} ({docsInType.length})
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{isExpanded && docsInType.map((doc: any) => (
|
||||
<React.Fragment key={doc.code}>
|
||||
<tr
|
||||
className={`hover:bg-slate-50 border-b border-slate-100 cursor-pointer ${expandedMatrixDoc === doc.code ? 'bg-teal-50' : ''}`}
|
||||
onClick={() => setExpandedMatrixDoc(expandedMatrixDoc === doc.code ? null : doc.code)}
|
||||
>
|
||||
<td className="px-2 py-1.5 font-medium sticky left-0 bg-white">
|
||||
<span className="flex items-center gap-1">
|
||||
{isInRag(doc.code) ? (
|
||||
<span className="text-green-500 text-[10px]">●</span>
|
||||
) : (
|
||||
<span className="text-red-300 text-[10px]">○</span>
|
||||
)}
|
||||
<span className="text-teal-600 truncate max-w-[180px]" title={doc.full_name || doc.name}>
|
||||
{doc.name}
|
||||
</span>
|
||||
{(doc.applicability_note || doc.description) && (
|
||||
<span className="text-slate-400 text-[10px] ml-1">{expandedMatrixDoc === doc.code ? '▼' : 'ⓘ'}</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
{INDUSTRIES_LIST.filter((i: any) => i.id !== 'all').map((industry: any) => {
|
||||
const applies = doc.industries.includes(industry.id) || doc.industries.includes('all')
|
||||
return (
|
||||
<td key={industry.id} className="px-2 py-1.5 text-center">
|
||||
{applies ? (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-teal-100 text-teal-600 rounded-full">✓</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-slate-300">–</span>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
{expandedMatrixDoc === doc.code && (doc.applicability_note || doc.description) && (
|
||||
<tr className="bg-teal-50 border-b border-teal-200">
|
||||
<td colSpan={INDUSTRIES_LIST.length} className="px-4 py-3">
|
||||
<div className="text-xs space-y-1.5">
|
||||
{doc.full_name && (
|
||||
<p className="font-semibold text-slate-700">{doc.full_name}</p>
|
||||
)}
|
||||
{doc.applicability_note && (
|
||||
<p className="text-teal-700 bg-teal-100 px-2 py-1 rounded inline-block">
|
||||
<span className="font-medium">Branchenrelevanz:</span> {doc.applicability_note}
|
||||
</p>
|
||||
)}
|
||||
{doc.description && (
|
||||
<p className="text-slate-600">{doc.description}</p>
|
||||
)}
|
||||
{doc.effective_date && (
|
||||
<p className="text-slate-400">In Kraft: {doc.effective_date}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FutureOutlookSection, RagCoverageSection, FutureRegulationsSection,
|
||||
// LegalBasisSection are imported from ./MapTabSections.tsx
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { REGULATIONS_IN_RAG } from '../rag-constants'
|
||||
import {
|
||||
RAG_DOCUMENTS,
|
||||
FUTURE_OUTLOOK,
|
||||
ADDITIONAL_REGULATIONS,
|
||||
LEGAL_BASIS_INFO,
|
||||
isInRag,
|
||||
} from '../rag-data'
|
||||
|
||||
export function FutureOutlookSection() {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🔮</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Zukunftsaussicht</h3>
|
||||
<p className="text-sm text-slate-500">Geplante Aenderungen und neue Regulierungen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{FUTURE_OUTLOOK.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between bg-slate-50 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
item.status === 'proposed' ? 'bg-yellow-100 text-yellow-700' :
|
||||
item.status === 'agreed' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'withdrawn' ? 'bg-red-100 text-red-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
<h4 className="font-semibold text-slate-900">{item.name}</h4>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">Erwartet: {item.expectedDate}</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-slate-600 mb-3">{item.description}</p>
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-2">Wichtige Aenderungen:</p>
|
||||
<ul className="text-sm text-slate-600 space-y-1">
|
||||
{item.keyChanges.slice(0, 4).map((change, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-teal-500 mt-1">•</span>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
{item.keyChanges.length > 4 && (
|
||||
<li className="text-slate-400 text-xs">+ {item.keyChanges.length - 4} weitere...</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.affectedRegulations.map((code) => (
|
||||
<span key={code} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={item.source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 hover:underline"
|
||||
>
|
||||
Quelle →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RagCoverageSection() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">RAG-Abdeckung ({Object.keys(REGULATIONS_IN_RAG).length} von {RAG_DOCUMENTS.length} Regulierungen)</h3>
|
||||
<p className="text-sm text-slate-500">Stand: Maerz 2026 — Alle im RAG-System verfuegbaren Regulierungen (inkl. Verbraucherschutz Phase H)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RAG_DOCUMENTS.filter((r: any) => isInRag(r.code)).map((reg: any) => (
|
||||
<span key={reg.code} className="px-2.5 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full border border-green-200">
|
||||
✓ {reg.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<p className="text-xs font-medium text-slate-500 mb-2">Noch nicht im RAG:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RAG_DOCUMENTS.filter((r: any) => !isInRag(r.code)).map((reg: any) => (
|
||||
<span key={reg.code} className="px-2.5 py-1 text-xs font-medium bg-red-50 text-red-400 rounded-full border border-red-100">
|
||||
✗ {reg.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FutureRegulationsSection() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🔮</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Zukuenftige Regulierungen</h3>
|
||||
<p className="text-sm text-slate-500">Noch nicht verabschiedet oder zur Erweiterung vorgesehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{ADDITIONAL_REGULATIONS.map((reg) => (
|
||||
<div key={reg.code} className={`rounded-lg border p-4 ${
|
||||
reg.status === 'active' ? 'border-green-200 bg-green-50' : 'border-yellow-200 bg-yellow-50'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 text-xs font-bold rounded ${
|
||||
reg.type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{reg.code}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
reg.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{reg.status === 'active' ? 'In Kraft' : 'Vorgeschlagen'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
reg.priority === 'high' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{reg.priority === 'high' ? 'Hohe Prioritaet' : 'Mittel'}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-slate-900 text-sm mb-1">{reg.name}</h4>
|
||||
<p className="text-xs text-slate-600 mb-2">{reg.description}</p>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-500">Ab: {reg.effectiveDate}</span>
|
||||
{reg.celex && (
|
||||
<a
|
||||
href={`https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:${reg.celex}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 hover:underline"
|
||||
>
|
||||
EUR-Lex →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LegalBasisSection() {
|
||||
return (
|
||||
<div className="bg-emerald-50 rounded-xl border border-emerald-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">⚖️</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">{LEGAL_BASIS_INFO.title}</h3>
|
||||
<p className="text-sm text-emerald-700">{LEGAL_BASIS_INFO.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{LEGAL_BASIS_INFO.details.map((detail, idx) => (
|
||||
<div key={idx} className="bg-white rounded-lg border border-emerald-100 p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||
detail.status === 'Erlaubt' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{detail.status}
|
||||
</span>
|
||||
<span className="font-medium text-sm text-slate-900">{detail.aspect}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">{detail.explanation}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { REGULATIONS_IN_RAG } from '../rag-constants'
|
||||
import {
|
||||
REGULATIONS,
|
||||
COLLECTION_TOTALS,
|
||||
TYPE_LABELS,
|
||||
TYPE_COLORS,
|
||||
isInRag,
|
||||
getKnownChunks,
|
||||
} from '../rag-data'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface OverviewTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function OverviewTab({ hook }: OverviewTabProps) {
|
||||
const {
|
||||
dsfaLoading,
|
||||
dsfaStatus,
|
||||
dsfaSources,
|
||||
setRegulationCategory,
|
||||
setActiveTab,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* RAG Categories Overview */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">RAG-Kategorien</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button
|
||||
onClick={() => { setRegulationCategory('regulations'); setActiveTab('regulations') }}
|
||||
className="p-4 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 transition-colors text-left"
|
||||
>
|
||||
<p className="text-xs font-medium text-blue-600 uppercase">Gesetze & Regulierungen</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{COLLECTION_TOTALS.total_legal.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{Object.keys(REGULATIONS_IN_RAG).length}/{REGULATIONS.length} im RAG</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRegulationCategory('dsfa'); setActiveTab('regulations') }}
|
||||
className="p-4 rounded-lg border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors text-left"
|
||||
>
|
||||
<p className="text-xs font-medium text-purple-600 uppercase">DSFA Corpus</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{dsfaLoading ? '-' : (dsfaStatus?.total_chunks || 0).toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{dsfaSources.length || '~70'} Quellen (WP248, DSK, Gesetze)</p>
|
||||
</button>
|
||||
<div className="p-4 rounded-lg border border-emerald-200 bg-emerald-50 text-left">
|
||||
<p className="text-xs font-medium text-emerald-600 uppercase">NiBiS EH</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">7.996</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Chunks · Bildungs-Erwartungshorizonte</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-orange-200 bg-orange-50 text-left">
|
||||
<p className="text-xs font-medium text-orange-600 uppercase">Legal Templates</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">7.689</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Chunks · Dokumentvorlagen (VVT, TOM, DSFA)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats per Type */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{Object.entries(TYPE_LABELS).map(([type, label]) => {
|
||||
const regs = REGULATIONS.filter((r) => r.type === type)
|
||||
const inRagCount = regs.filter((r) => isInRag(r.code)).length
|
||||
const totalChunks = regs.reduce((sum, r) => sum + getKnownChunks(r.code), 0)
|
||||
return (
|
||||
<div key={type} className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[type]}`}>{label}</span>
|
||||
<span className="text-slate-500 text-sm">{inRagCount}/{regs.length} im RAG</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-slate-900">{totalChunks.toLocaleString()} Chunks</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top Regulations */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Top Regulierungen (nach Chunks)</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{[...REGULATIONS].sort((a, b) => getKnownChunks(b.code) - getKnownChunks(a.code))
|
||||
.slice(0, 10)
|
||||
.map((reg) => {
|
||||
const chunks = getKnownChunks(reg.code)
|
||||
return (
|
||||
<div key={reg.code} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isInRag(reg.code) ? (
|
||||
<span className="text-green-500 text-sm">✓</span>
|
||||
) : (
|
||||
<span className="text-red-400 text-sm">✗</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{TYPE_LABELS[reg.type]}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900">{reg.name}</span>
|
||||
<span className="text-slate-500 text-sm">({reg.code})</span>
|
||||
</div>
|
||||
<span className={`font-bold ${chunks > 0 ? 'text-teal-600' : 'text-slate-300'}`}>{chunks > 0 ? chunks.toLocaleString() + ' Chunks' : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { PipelineCheckpoint } from '../types'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface PipelineTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function PipelineTab({ hook }: PipelineTabProps) {
|
||||
const {
|
||||
pipelineState,
|
||||
pipelineLoading,
|
||||
pipelineStarting,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
elapsedTime,
|
||||
fetchPipeline,
|
||||
handleStartPipeline,
|
||||
collectionStatus,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pipeline Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Compliance Pipeline Status</h3>
|
||||
{pipelineState?.status === 'running' && elapsedTime && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-full">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-blue-700">Laufzeit: {elapsedTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="w-4 h-4 text-teal-600 rounded border-slate-300 focus:ring-teal-500"
|
||||
/>
|
||||
Auto-Refresh
|
||||
</label>
|
||||
{(!pipelineState || pipelineState.status !== 'running') && (
|
||||
<button
|
||||
onClick={() => handleStartPipeline(false)}
|
||||
disabled={pipelineStarting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{pipelineStarting ? (
|
||||
<SpinnerIcon />
|
||||
) : (
|
||||
<PlayIcon />
|
||||
)}
|
||||
Pipeline starten
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchPipeline}
|
||||
disabled={pipelineLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{pipelineLoading ? <SpinnerIcon /> : <RefreshIcon />}
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No Data */}
|
||||
{(!pipelineState || pipelineState.status === 'no_data') && !pipelineLoading && (
|
||||
<NoDataCard pipelineStarting={pipelineStarting} handleStartPipeline={handleStartPipeline} />
|
||||
)}
|
||||
|
||||
{/* Pipeline Status */}
|
||||
{pipelineState && pipelineState.status !== 'no_data' && (
|
||||
<>
|
||||
{/* Status Card */}
|
||||
<PipelineStatusCard pipelineState={pipelineState} />
|
||||
|
||||
{/* Current Progress */}
|
||||
{pipelineState.status === 'running' && pipelineState.current_phase && (
|
||||
<CurrentProgressCard pipelineState={pipelineState} collectionStatus={collectionStatus} />
|
||||
)}
|
||||
|
||||
{/* Validation Summary */}
|
||||
{pipelineState.validation_summary && (
|
||||
<ValidationSummary summary={pipelineState.validation_summary} />
|
||||
)}
|
||||
|
||||
{/* Checkpoints */}
|
||||
<CheckpointsList checkpoints={pipelineState.checkpoints} />
|
||||
|
||||
{/* Summary */}
|
||||
{Object.keys(pipelineState.summary || {}).length > 0 && (
|
||||
<PipelineSummary summary={pipelineState.summary} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Icons ---
|
||||
|
||||
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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function RefreshIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function NoDataCard({
|
||||
pipelineStarting,
|
||||
handleStartPipeline,
|
||||
}: {
|
||||
pipelineStarting: boolean
|
||||
handleStartPipeline: (skip: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold text-slate-900 mb-2">Keine Pipeline-Daten</h4>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Es wurde noch keine Pipeline ausgefuehrt. Starten Sie die Compliance-Pipeline um Checkpoint-Daten zu sehen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleStartPipeline(false)}
|
||||
disabled={pipelineStarting}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{pipelineStarting ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" 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.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Startet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" 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>
|
||||
Pipeline jetzt starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStatusCard({ pipelineState }: { pipelineState: any }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
pipelineState.status === 'completed' ? 'bg-green-100' :
|
||||
pipelineState.status === 'running' ? 'bg-blue-100' :
|
||||
pipelineState.status === 'failed' ? 'bg-red-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
{pipelineState.status === 'completed' && (
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{pipelineState.status === 'running' && (
|
||||
<svg className="w-6 h-6 text-blue-600 animate-spin" 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.7.689 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{pipelineState.status === 'failed' && (
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">Pipeline {pipelineState.pipeline_id}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
Gestartet: {pipelineState.started_at ? new Date(pipelineState.started_at).toLocaleString('de-DE') : '-'}
|
||||
{pipelineState.completed_at && ` | Beendet: ${new Date(pipelineState.completed_at).toLocaleString('de-DE')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
pipelineState.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
pipelineState.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
pipelineState.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{pipelineState.status === 'completed' ? 'Abgeschlossen' :
|
||||
pipelineState.status === 'running' ? 'Laeuft' :
|
||||
pipelineState.status === 'failed' ? 'Fehlgeschlagen' : pipelineState.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentProgressCard({ pipelineState, collectionStatus }: { pipelineState: any; collectionStatus: any }) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-blue-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Aktuelle Verarbeitung
|
||||
</h4>
|
||||
<span className="text-sm text-blue-600">Phase: {pipelineState.current_phase}</span>
|
||||
</div>
|
||||
|
||||
{/* Phase Progress Indicator */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{['ingestion', 'extraction', 'controls', 'measures'].map((phase, idx) => (
|
||||
<div key={phase} className="flex-1 flex items-center">
|
||||
<div className={`flex-1 h-2 rounded-full ${
|
||||
pipelineState.current_phase === phase ? 'bg-blue-500 animate-pulse' :
|
||||
pipelineState.checkpoints?.some((c: PipelineCheckpoint) => c.phase === phase && c.status === 'completed') ? 'bg-green-500' :
|
||||
'bg-slate-200'
|
||||
}`} />
|
||||
{idx < 3 && <div className="w-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-4">
|
||||
<span>Ingestion</span>
|
||||
<span>Extraktion</span>
|
||||
<span>Controls</span>
|
||||
<span>Massnahmen</span>
|
||||
</div>
|
||||
|
||||
{/* Current checkpoint details */}
|
||||
{pipelineState.checkpoints?.filter((c: PipelineCheckpoint) => c.status === 'running').map((checkpoint: PipelineCheckpoint, idx: number) => (
|
||||
<div key={idx} className="bg-white/60 rounded-lg p-4 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span className="font-medium text-slate-900">{checkpoint.name}</span>
|
||||
</div>
|
||||
{checkpoint.metrics && Object.keys(checkpoint.metrics).length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(checkpoint.metrics).slice(0, 3).map(([key, value]) => (
|
||||
<span key={key} className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{key.replace(/_/g, ' ')}: {typeof value === 'number' ? value.toLocaleString() : String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Live chunk count */}
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">Chunks in Qdrant:</span>
|
||||
<span className="font-bold text-blue-700">{collectionStatus?.totalPoints?.toLocaleString() || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ValidationSummary({ summary }: { summary: { passed: number; warning: number; failed: number; total: number } }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<p className="text-sm text-slate-500">Bestanden</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.passed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-4">
|
||||
<p className="text-sm text-slate-500">Warnungen</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.warning}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4">
|
||||
<p className="text-sm text-slate-500">Fehlgeschlagen</p>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<p className="text-sm text-slate-500">Gesamt</p>
|
||||
<p className="text-2xl font-bold text-slate-700">{summary.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckpointsList({ checkpoints }: { checkpoints?: PipelineCheckpoint[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Checkpoints ({checkpoints?.length || 0})</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{checkpoints?.map((checkpoint, idx) => (
|
||||
<div key={idx} className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-3 h-3 rounded-full ${
|
||||
checkpoint.phase === 'ingestion' ? 'bg-blue-500' :
|
||||
checkpoint.phase === 'extraction' ? 'bg-purple-500' :
|
||||
checkpoint.phase === 'controls' ? 'bg-green-500' : 'bg-orange-500'
|
||||
}`} />
|
||||
<span className="font-medium text-slate-900">{checkpoint.name}</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
({checkpoint.phase}) |
|
||||
{checkpoint.duration_seconds ? ` ${checkpoint.duration_seconds.toFixed(1)}s` : ' -'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
checkpoint.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
checkpoint.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
checkpoint.status === 'failed' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{checkpoint.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{Object.keys(checkpoint.metrics || {}).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{Object.entries(checkpoint.metrics).map(([key, value]) => (
|
||||
<span key={key} className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-600">
|
||||
{key.replace(/_/g, ' ')}: <strong>{typeof value === 'number' ? value.toLocaleString() : String(value)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validations */}
|
||||
{checkpoint.validations?.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{checkpoint.validations.map((v, vIdx) => (
|
||||
<div key={vIdx} className="flex items-center gap-2 text-sm">
|
||||
<span className={`w-4 h-4 flex items-center justify-center ${
|
||||
v.status === 'passed' ? 'text-green-500' :
|
||||
v.status === 'warning' ? 'text-yellow-500' : 'text-red-500'
|
||||
}`}>
|
||||
{v.status === 'passed' ? '✓' : v.status === 'warning' ? '⚠' : '✗'}
|
||||
</span>
|
||||
<span className="text-slate-700">{v.name}:</span>
|
||||
<span className="text-slate-500">{v.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{checkpoint.error && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{checkpoint.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(!checkpoints || checkpoints.length === 0) && (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
Noch keine Checkpoints vorhanden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineSummary({ summary }: { summary: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h4 className="font-semibold text-slate-900 mb-3">Zusammenfassung</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(summary).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-sm text-slate-500">{key.replace(/_/g, ' ')}</p>
|
||||
<p className="font-bold text-slate-900">
|
||||
{typeof value === 'number' ? value.toLocaleString() : String(value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
REGULATIONS,
|
||||
TYPE_COLORS,
|
||||
TYPE_LABELS,
|
||||
isInRag,
|
||||
getKnownChunks,
|
||||
} from '../rag-data'
|
||||
import {
|
||||
REGULATION_SOURCES,
|
||||
REGULATION_LICENSES,
|
||||
LICENSE_LABELS,
|
||||
} from '../rag-sources'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface RegulationsTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function RegulationsTab({ hook }: RegulationsTabProps) {
|
||||
const {
|
||||
regulationCategory,
|
||||
setRegulationCategory,
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
fetchStatus,
|
||||
dsfaSources,
|
||||
dsfaLoading,
|
||||
expandedDsfaSource,
|
||||
setExpandedDsfaSource,
|
||||
fetchDsfaStatus,
|
||||
setActiveTab,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setRegulationCategory('regulations')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'regulations'
|
||||
? 'bg-blue-100 text-blue-700 ring-2 ring-blue-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Gesetze & Regulierungen ({REGULATIONS.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRegulationCategory('dsfa')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'dsfa'
|
||||
? 'bg-purple-100 text-purple-700 ring-2 ring-purple-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
DSFA Quellen ({dsfaSources.length || '~70'})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRegulationCategory('nibis')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'nibis'
|
||||
? 'bg-emerald-100 text-emerald-700 ring-2 ring-emerald-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
NiBiS Dokumente
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRegulationCategory('templates')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
regulationCategory === 'templates'
|
||||
? 'bg-orange-100 text-orange-700 ring-2 ring-orange-300'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Templates & Vorlagen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Regulations Table */}
|
||||
{regulationCategory === 'regulations' && (
|
||||
<RegulationsTable
|
||||
expandedRegulation={expandedRegulation}
|
||||
setExpandedRegulation={setExpandedRegulation}
|
||||
fetchStatus={fetchStatus}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DSFA Sources */}
|
||||
{regulationCategory === 'dsfa' && (
|
||||
<DsfaSourcesList
|
||||
dsfaSources={dsfaSources}
|
||||
dsfaLoading={dsfaLoading}
|
||||
expandedDsfaSource={expandedDsfaSource}
|
||||
setExpandedDsfaSource={setExpandedDsfaSource}
|
||||
fetchDsfaStatus={fetchDsfaStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NiBiS Dokumente (info only) */}
|
||||
{regulationCategory === 'nibis' && <NibisInfo />}
|
||||
|
||||
{/* Templates (info only) */}
|
||||
{regulationCategory === 'templates' && <TemplatesInfo />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function RegulationsTable({
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
fetchStatus,
|
||||
setActiveTab,
|
||||
}: {
|
||||
expandedRegulation: string | null
|
||||
setExpandedRegulation: (v: string | null) => void
|
||||
fetchStatus: () => void
|
||||
setActiveTab: (v: any) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
Alle {REGULATIONS.length} Regulierungen
|
||||
<span className="ml-2 text-sm font-normal text-slate-500">
|
||||
({REGULATIONS.filter(r => isInRag(r.code)).length} im RAG,{' '}
|
||||
{REGULATIONS.filter(r => !isInRag(r.code)).length} ausstehend)
|
||||
</span>
|
||||
</h3>
|
||||
<button onClick={fetchStatus} className="text-sm text-teal-600 hover:text-teal-700">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase w-12">RAG</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Chunks</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Erwartet</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{REGULATIONS.map((reg) => {
|
||||
const chunks = getKnownChunks(reg.code)
|
||||
const inRag = isInRag(reg.code)
|
||||
const statusColor = inRag ? 'text-green-500' : 'text-red-500'
|
||||
const statusIcon = inRag ? '✓' : '❌'
|
||||
const isExpanded = expandedRegulation === reg.code
|
||||
|
||||
return (
|
||||
<React.Fragment key={reg.code}>
|
||||
<tr
|
||||
onClick={() => setExpandedRegulation(isExpanded ? null : reg.code)}
|
||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isInRag(reg.code) ? (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-green-100 text-green-600 rounded-full text-xs font-bold" title="Im RAG vorhanden">✓</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-50 text-red-400 rounded-full text-xs font-bold" title="Nicht im RAG">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono font-medium text-teal-600">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={`transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}>▶</span>
|
||||
{reg.code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{TYPE_LABELS[reg.type]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-900">{reg.name}</td>
|
||||
<td className="px-4 py-3 text-right font-bold">
|
||||
<span className={chunks > 0 && chunks < 10 && reg.expected >= 10 ? 'text-amber-600' : ''}>
|
||||
{chunks.toLocaleString()}
|
||||
{chunks > 0 && chunks < 10 && reg.expected >= 10 && (
|
||||
<span className="ml-1 inline-block w-4 h-4 text-[10px] leading-4 text-center bg-amber-100 text-amber-700 rounded-full" title="Verdaechtig niedrig — Ingestion pruefen">⚠</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">{reg.expected}</td>
|
||||
<td className={`px-4 py-3 text-center ${statusColor}`}>{statusIcon}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${reg.code}-detail`} className="bg-slate-50">
|
||||
<td colSpan={7} className="px-4 py-4">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900 mb-1">{reg.fullName}</h4>
|
||||
<p className="text-sm text-slate-600">{reg.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Relevant fuer</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reg.relevantFor.map((item, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 text-xs bg-slate-100 text-slate-600 rounded">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase mb-1">Kernthemen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{reg.keyTopics.map((topic, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 text-xs bg-teal-50 text-teal-700 rounded">
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100 text-xs text-slate-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>In Kraft seit: {reg.effectiveDate}</span>
|
||||
{REGULATION_LICENSES[reg.code] && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-medium">
|
||||
{LICENSE_LABELS[REGULATION_LICENSES[reg.code].license] || REGULATION_LICENSES[reg.code].license}
|
||||
</span>
|
||||
<span className="text-slate-400">{REGULATION_LICENSES[reg.code].licenseNote}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{REGULATION_SOURCES[reg.code] && (
|
||||
<a
|
||||
href={REGULATION_SOURCES[reg.code]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Originalquelle →
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveTab('chunks')
|
||||
}}
|
||||
className="text-teal-600 hover:text-teal-700 font-medium"
|
||||
>
|
||||
In Chunks suchen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DsfaSourcesList({
|
||||
dsfaSources,
|
||||
dsfaLoading,
|
||||
expandedDsfaSource,
|
||||
setExpandedDsfaSource,
|
||||
fetchDsfaStatus,
|
||||
}: {
|
||||
dsfaSources: any[]
|
||||
dsfaLoading: boolean
|
||||
expandedDsfaSource: string | null
|
||||
setExpandedDsfaSource: (v: string | null) => void
|
||||
fetchDsfaStatus: () => void
|
||||
}) {
|
||||
const typeColors: Record<string, string> = {
|
||||
regulation: 'bg-blue-100 text-blue-700',
|
||||
legislation: 'bg-indigo-100 text-indigo-700',
|
||||
guideline: 'bg-teal-100 text-teal-700',
|
||||
checklist: 'bg-yellow-100 text-yellow-700',
|
||||
standard: 'bg-green-100 text-green-700',
|
||||
methodology: 'bg-purple-100 text-purple-700',
|
||||
specification: 'bg-orange-100 text-orange-700',
|
||||
catalog: 'bg-pink-100 text-pink-700',
|
||||
guidance: 'bg-cyan-100 text-cyan-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">DSFA Quellen ({dsfaSources.length || '~70'})</h3>
|
||||
<p className="text-xs text-slate-500">WP248, DSK Kurzpapiere, Muss-Listen, nationale Datenschutzgesetze</p>
|
||||
</div>
|
||||
<button onClick={fetchDsfaStatus} className="text-sm text-teal-600 hover:text-teal-700">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{dsfaLoading ? (
|
||||
<div className="p-8 text-center text-slate-500">Lade DSFA-Quellen...</div>
|
||||
) : dsfaSources.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<p className="mb-2">Keine DSFA-Quellen vom Backend geladen.</p>
|
||||
<p className="text-xs">Endpunkt: <code className="bg-slate-100 px-1 rounded">/api/dsfa-corpus?action=sources</code></p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{dsfaSources.map((source) => {
|
||||
const isExpanded = expandedDsfaSource === source.source_code
|
||||
return (
|
||||
<React.Fragment key={source.source_code}>
|
||||
<div
|
||||
onClick={() => setExpandedDsfaSource(isExpanded ? null : source.source_code)}
|
||||
className="px-4 py-3 hover:bg-slate-50 cursor-pointer transition-colors flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`transform transition-transform text-xs ${isExpanded ? 'rotate-90' : ''}`}>▶</span>
|
||||
<span className="font-mono text-sm text-purple-600 font-medium">{source.source_code}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${typeColors[source.document_type] || 'bg-slate-100 text-slate-600'}`}>
|
||||
{source.document_type}
|
||||
</span>
|
||||
<span className="text-sm text-slate-900">{source.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-slate-100 text-slate-500 rounded uppercase">
|
||||
{source.language}
|
||||
</span>
|
||||
{source.chunk_count != null && (
|
||||
<span className="text-sm font-bold text-purple-600">{source.chunk_count} Chunks</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 bg-slate-50">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900 mb-1">{source.full_name || source.name}</h4>
|
||||
{source.organization && (
|
||||
<p className="text-sm text-slate-600">Organisation: {source.organization}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-2 border-t border-slate-100 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-medium">
|
||||
{LICENSE_LABELS[source.license_code] || source.license_code}
|
||||
</span>
|
||||
<span className="text-slate-400">{source.attribution_text}</span>
|
||||
</span>
|
||||
</div>
|
||||
{source.source_url && (
|
||||
<div className="text-xs">
|
||||
<a
|
||||
href={source.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-600 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Quelle: {source.source_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NibisInfo() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-100 flex items-center justify-center text-xl">📚</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">NiBiS Erwartungshorizonte</h3>
|
||||
<p className="text-sm text-slate-500">Collection: <code className="bg-slate-100 px-1 rounded">bp_nibis_eh</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<p className="text-sm text-emerald-600 font-medium">Chunks</p>
|
||||
<p className="text-2xl font-bold text-slate-900">7.996</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<p className="text-sm text-emerald-600 font-medium">Vector Size</p>
|
||||
<p className="text-2xl font-bold text-slate-900">1024</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-200">
|
||||
<p className="text-sm text-emerald-600 font-medium">Typ</p>
|
||||
<p className="text-2xl font-bold text-slate-900">BGE-M3</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Bildungsinhalte aus dem Niedersaechsischen Bildungsserver (NiBiS). Enthaelt Erwartungshorizonte fuer
|
||||
verschiedene Faecher und Schulformen. Wird ueber die Klausur-Korrektur fuer EH-Matching genutzt.
|
||||
Diese Daten sind nicht direkt compliance-relevant.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesInfo() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center text-xl">📋</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Legal Templates & Vorlagen</h3>
|
||||
<p className="text-sm text-slate-500">Collection: <code className="bg-slate-100 px-1 rounded">bp_legal_templates</code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||
<p className="text-sm text-orange-600 font-medium">Chunks</p>
|
||||
<p className="text-2xl font-bold text-slate-900">7.689</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||
<p className="text-sm text-orange-600 font-medium">Vector Size</p>
|
||||
<p className="text-2xl font-bold text-slate-900">1024</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||
<p className="text-sm text-orange-600 font-medium">Typ</p>
|
||||
<p className="text-2xl font-bold text-slate-900">BGE-M3</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Vorlagen fuer VVT (Verzeichnis von Verarbeitungstaetigkeiten), TOM (Technisch-Organisatorische Massnahmen),
|
||||
DSFA-Berichte und weitere Compliance-Dokumente. Werden vom AI Compliance SDK fuer die Dokumentgenerierung genutzt.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { UseRAGPageReturn } from '../_hooks/useRAGPage'
|
||||
|
||||
interface SearchTabProps {
|
||||
hook: UseRAGPageReturn
|
||||
}
|
||||
|
||||
export function SearchTab({ hook }: SearchTabProps) {
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
searching,
|
||||
selectedRegulations,
|
||||
setSelectedRegulations,
|
||||
handleSearch,
|
||||
} = hook
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Box */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Semantische Suche</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Suchanfrage</label>
|
||||
<textarea
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="z.B. 'Welche Anforderungen gibt es fuer KI-Systeme mit hohem Risiko?'"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Filter (optional)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['GDPR', 'AIACT', 'CRA', 'NIS2', 'BSI-TR-03161-1'].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => {
|
||||
setSelectedRegulations((prev: string[]) =>
|
||||
prev.includes(code) ? prev.filter((c: string) => c !== code) : [...prev, code]
|
||||
)
|
||||
}}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedRegulations.includes(code)
|
||||
? 'bg-teal-100 border-teal-300 text-teal-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{searching ? 'Suche...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">{searchResults.length} Ergebnisse</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{searchResults.map((result, i) => (
|
||||
<div key={i} className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-2 py-0.5 text-xs rounded bg-teal-100 text-teal-700">
|
||||
{result.regulation_code}
|
||||
</span>
|
||||
{result.article && (
|
||||
<span className="text-sm text-slate-500">Art. {result.article}</span>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-slate-400">
|
||||
Score: {(result.score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-700 text-sm">{result.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { API_PROXY, DSFA_API_PROXY } from '../rag-data'
|
||||
import type {
|
||||
TabId,
|
||||
RegulationCategory,
|
||||
CollectionStatus,
|
||||
SearchResult,
|
||||
DsfaSource,
|
||||
DsfaCorpusStatus,
|
||||
CustomDocument,
|
||||
PipelineState,
|
||||
PipelineCheckpoint,
|
||||
} from '../types'
|
||||
|
||||
export function useRAGPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [collectionStatus, setCollectionStatus] = useState<CollectionStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
|
||||
const [ingestionRunning, setIngestionRunning] = useState(false)
|
||||
const [ingestionLog, setIngestionLog] = useState<string[]>([])
|
||||
const [pipelineState, setPipelineState] = useState<PipelineState | null>(null)
|
||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
||||
const [pipelineStarting, setPipelineStarting] = useState(false)
|
||||
const [expandedRegulation, setExpandedRegulation] = useState<string | null>(null)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('')
|
||||
const [expandedDocTypes, setExpandedDocTypes] = useState<string[]>(['eu_regulation', 'eu_directive'])
|
||||
const [expandedMatrixDoc, setExpandedMatrixDoc] = useState<string | null>(null)
|
||||
|
||||
// DSFA corpus state
|
||||
const [dsfaSources, setDsfaSources] = useState<DsfaSource[]>([])
|
||||
const [dsfaStatus, setDsfaStatus] = useState<DsfaCorpusStatus | null>(null)
|
||||
const [dsfaLoading, setDsfaLoading] = useState(false)
|
||||
const [regulationCategory, setRegulationCategory] = useState<RegulationCategory>('regulations')
|
||||
const [expandedDsfaSource, setExpandedDsfaSource] = useState<string | null>(null)
|
||||
|
||||
// Data tab state
|
||||
const [customDocuments, setCustomDocuments] = useState<CustomDocument[]>([])
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadTitle, setUploadTitle] = useState('')
|
||||
const [uploadCode, setUploadCode] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [linkTitle, setLinkTitle] = useState('')
|
||||
const [linkCode, setLinkCode] = useState('')
|
||||
const [addingLink, setAddingLink] = useState(false)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCollectionStatus(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchPipeline = useCallback(async () => {
|
||||
setPipelineLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=pipeline-checkpoints`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPipelineState(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipeline:', error)
|
||||
} finally {
|
||||
setPipelineLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchDsfaStatus = useCallback(async () => {
|
||||
setDsfaLoading(true)
|
||||
try {
|
||||
const [statusRes, sourcesRes] = await Promise.all([
|
||||
fetch(`${DSFA_API_PROXY}?action=status`),
|
||||
fetch(`${DSFA_API_PROXY}?action=sources`),
|
||||
])
|
||||
if (statusRes.ok) {
|
||||
const data = await statusRes.json()
|
||||
setDsfaStatus(data)
|
||||
}
|
||||
if (sourcesRes.ok) {
|
||||
const data = await sourcesRes.json()
|
||||
setDsfaSources(data.sources || data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch DSFA status:', error)
|
||||
} finally {
|
||||
setDsfaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchCustomDocuments = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=custom-documents`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setCustomDocuments(data.documents || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch custom documents:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadFile || !uploadTitle || !uploadCode) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile)
|
||||
formData.append('title', uploadTitle)
|
||||
formData.append('code', uploadCode)
|
||||
formData.append('document_type', 'custom')
|
||||
|
||||
const res = await fetch(`${API_PROXY}?action=upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setUploadFile(null)
|
||||
setUploadTitle('')
|
||||
setUploadCode('')
|
||||
fetchCustomDocuments()
|
||||
fetchStatus()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!linkUrl || !linkTitle || !linkCode) return
|
||||
|
||||
setAddingLink(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=add-link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: linkUrl,
|
||||
title: linkTitle,
|
||||
code: linkCode,
|
||||
document_type: 'custom',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setLinkUrl('')
|
||||
setLinkTitle('')
|
||||
setLinkCode('')
|
||||
fetchCustomDocuments()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add link failed:', error)
|
||||
} finally {
|
||||
setAddingLink(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDocument = async (docId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=delete-document&docId=${docId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
fetchCustomDocuments()
|
||||
fetchStatus()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartPipeline = async (skipIngestion: boolean = false) => {
|
||||
setPipelineStarting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=start-pipeline`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
force_reindex: false,
|
||||
skip_ingestion: skipIngestion,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
fetchPipeline()
|
||||
setPipelineStarting(false)
|
||||
}, 2000)
|
||||
} else {
|
||||
setPipelineStarting(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start pipeline:', error)
|
||||
setPipelineStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setSearching(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'search',
|
||||
query: searchQuery,
|
||||
top_k: '5',
|
||||
})
|
||||
if (selectedRegulations.length > 0) {
|
||||
params.append('regulations', selectedRegulations.join(','))
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_PROXY}?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSearchResults(data.results || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerIngestion = async () => {
|
||||
setIngestionRunning(true)
|
||||
setIngestionLog(['Starte Re-Ingestion aller 19 Regulierungen...'])
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_PROXY}?action=ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force: true }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIngestionLog((prev) => [...prev, 'Ingestion gestartet. Job-ID: ' + (data.job_id || 'N/A')])
|
||||
const checkStatus = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`${API_PROXY}?action=ingestion-status`)
|
||||
if (statusRes.ok) {
|
||||
const statusData = await statusRes.json()
|
||||
if (statusData.completed) {
|
||||
clearInterval(checkStatus)
|
||||
setIngestionRunning(false)
|
||||
setIngestionLog((prev) => [...prev, 'Ingestion abgeschlossen!'])
|
||||
fetchStatus()
|
||||
} else if (statusData.current_regulation) {
|
||||
setIngestionLog((prev) => [
|
||||
...prev,
|
||||
`Verarbeite: ${statusData.current_regulation} (${statusData.processed}/${statusData.total})`,
|
||||
])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}, 5000)
|
||||
} else {
|
||||
setIngestionLog((prev) => [...prev, 'Fehler: ' + res.statusText])
|
||||
setIngestionRunning(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setIngestionLog((prev) => [...prev, 'Fehler: ' + String(error)])
|
||||
setIngestionRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRegulationChunks = (code: string): number => {
|
||||
return collectionStatus?.regulations?.[code] || 0
|
||||
}
|
||||
|
||||
const getTotalChunks = (): number => {
|
||||
return collectionStatus?.totalPoints || 0
|
||||
}
|
||||
|
||||
// Initial data fetch
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchDsfaStatus()
|
||||
}, [fetchStatus, fetchDsfaStatus])
|
||||
|
||||
// Fetch pipeline when tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === 'pipeline') {
|
||||
fetchPipeline()
|
||||
}
|
||||
}, [activeTab, fetchPipeline])
|
||||
|
||||
// Fetch custom documents when data tab is active
|
||||
useEffect(() => {
|
||||
if (activeTab === 'data') {
|
||||
fetchCustomDocuments()
|
||||
}
|
||||
}, [activeTab, fetchCustomDocuments])
|
||||
|
||||
// Auto-refresh pipeline status when running
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'pipeline' || !autoRefresh) return
|
||||
|
||||
const isRunning = pipelineState?.status === 'running'
|
||||
|
||||
if (isRunning) {
|
||||
const interval = setInterval(() => {
|
||||
fetchPipeline()
|
||||
fetchStatus()
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [activeTab, autoRefresh, pipelineState?.status, fetchPipeline, fetchStatus])
|
||||
|
||||
// Update elapsed time
|
||||
useEffect(() => {
|
||||
if (!pipelineState?.started_at || pipelineState?.status !== 'running') {
|
||||
setElapsedTime('')
|
||||
return
|
||||
}
|
||||
|
||||
const updateElapsed = () => {
|
||||
const start = new Date(pipelineState.started_at!).getTime()
|
||||
const now = Date.now()
|
||||
const diff = Math.floor((now - start) / 1000)
|
||||
|
||||
const hours = Math.floor(diff / 3600)
|
||||
const minutes = Math.floor((diff % 3600) / 60)
|
||||
const seconds = diff % 60
|
||||
|
||||
if (hours > 0) {
|
||||
setElapsedTime(`${hours}h ${minutes}m ${seconds}s`)
|
||||
} else if (minutes > 0) {
|
||||
setElapsedTime(`${minutes}m ${seconds}s`)
|
||||
} else {
|
||||
setElapsedTime(`${seconds}s`)
|
||||
}
|
||||
}
|
||||
|
||||
updateElapsed()
|
||||
const interval = setInterval(updateElapsed, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [pipelineState?.started_at, pipelineState?.status])
|
||||
|
||||
return {
|
||||
// Tab state
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
|
||||
// Collection status
|
||||
collectionStatus,
|
||||
loading,
|
||||
fetchStatus,
|
||||
|
||||
// Search
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
searching,
|
||||
selectedRegulations,
|
||||
setSelectedRegulations,
|
||||
handleSearch,
|
||||
|
||||
// Ingestion
|
||||
ingestionRunning,
|
||||
ingestionLog,
|
||||
triggerIngestion,
|
||||
|
||||
// Pipeline
|
||||
pipelineState,
|
||||
pipelineLoading,
|
||||
pipelineStarting,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
elapsedTime,
|
||||
fetchPipeline,
|
||||
handleStartPipeline,
|
||||
|
||||
// Regulation expansion
|
||||
expandedRegulation,
|
||||
setExpandedRegulation,
|
||||
expandedDocTypes,
|
||||
setExpandedDocTypes,
|
||||
expandedMatrixDoc,
|
||||
setExpandedMatrixDoc,
|
||||
|
||||
// DSFA
|
||||
dsfaSources,
|
||||
dsfaStatus,
|
||||
dsfaLoading,
|
||||
regulationCategory,
|
||||
setRegulationCategory,
|
||||
expandedDsfaSource,
|
||||
setExpandedDsfaSource,
|
||||
fetchDsfaStatus,
|
||||
|
||||
// Data tab
|
||||
customDocuments,
|
||||
uploadFile,
|
||||
setUploadFile,
|
||||
uploadTitle,
|
||||
setUploadTitle,
|
||||
uploadCode,
|
||||
setUploadCode,
|
||||
uploading,
|
||||
handleUpload,
|
||||
linkUrl,
|
||||
setLinkUrl,
|
||||
linkTitle,
|
||||
setLinkTitle,
|
||||
linkCode,
|
||||
setLinkCode,
|
||||
addingLink,
|
||||
handleAddLink,
|
||||
handleDeleteDocument,
|
||||
fetchCustomDocuments,
|
||||
|
||||
// Helpers
|
||||
getRegulationChunks,
|
||||
getTotalChunks,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseRAGPageReturn = ReturnType<typeof useRAGPage>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* RAG & Legal Corpus Management - Static Data
|
||||
*
|
||||
* Core data constants: regulations, industries, thematic groups, etc.
|
||||
* Source URLs and licenses are in rag-sources.ts.
|
||||
*/
|
||||
|
||||
import { REGULATIONS_IN_RAG } from './rag-constants'
|
||||
import ragData from './rag-documents.json'
|
||||
import type {
|
||||
Regulation,
|
||||
Industry,
|
||||
ThematicGroup,
|
||||
KeyIntersection,
|
||||
FutureOutlookItem,
|
||||
AdditionalRegulation,
|
||||
LegalBasisInfo,
|
||||
TabDef,
|
||||
} from './types'
|
||||
|
||||
// Re-export source URLs, licenses and license labels from rag-sources.ts
|
||||
export {
|
||||
REGULATION_SOURCES,
|
||||
REGULATION_LICENSES,
|
||||
LICENSE_LABELS,
|
||||
} from './rag-sources'
|
||||
|
||||
// API uses local proxy route to klausur-service
|
||||
export const API_PROXY = '/api/legal-corpus'
|
||||
export const DSFA_API_PROXY = '/api/dsfa-corpus'
|
||||
|
||||
// Import documents and metadata from JSON
|
||||
export const RAG_DOCUMENTS = ragData.documents
|
||||
export const DOC_TYPES = ragData.doc_types
|
||||
export const INDUSTRIES_LIST = ragData.industries
|
||||
|
||||
// Derive REGULATIONS from JSON (backwards compatible for regulations tab)
|
||||
export const REGULATIONS: Regulation[] = RAG_DOCUMENTS.filter((d: any) => d.description).map((d: any) => ({
|
||||
code: d.code,
|
||||
name: d.name,
|
||||
fullName: d.full_name || d.name,
|
||||
type: d.doc_type,
|
||||
expected: 0,
|
||||
description: d.description || '',
|
||||
relevantFor: [] as string[],
|
||||
keyTopics: [] as string[],
|
||||
effectiveDate: d.effective_date || ''
|
||||
}))
|
||||
|
||||
// Helper: Check if regulation is in RAG
|
||||
export const isInRag = (code: string): boolean => code in REGULATIONS_IN_RAG
|
||||
|
||||
// Helper: Get known chunk count for a regulation
|
||||
export const getKnownChunks = (code: string): number => REGULATIONS_IN_RAG[code]?.chunks || 0
|
||||
|
||||
// Known collection totals (updated: 2026-03-12)
|
||||
export const COLLECTION_TOTALS = {
|
||||
bp_compliance_gesetze: 63567,
|
||||
bp_compliance_ce: 18183,
|
||||
bp_legal_templates: 7689,
|
||||
bp_compliance_datenschutz: 17459,
|
||||
bp_dsfa_corpus: 8666,
|
||||
bp_compliance_recht: 1425,
|
||||
bp_nibis_eh: 7996,
|
||||
total_legal: 81750,
|
||||
total_all: 124985,
|
||||
}
|
||||
|
||||
export const TYPE_COLORS: Record<string, string> = {
|
||||
eu_regulation: 'bg-blue-100 text-blue-700',
|
||||
eu_directive: 'bg-purple-100 text-purple-700',
|
||||
de_law: 'bg-yellow-100 text-yellow-700',
|
||||
at_law: 'bg-red-100 text-red-700',
|
||||
ch_law: 'bg-rose-100 text-rose-700',
|
||||
bsi_standard: 'bg-green-100 text-green-700',
|
||||
national_law: 'bg-orange-100 text-orange-700',
|
||||
eu_guideline: 'bg-teal-100 text-teal-700',
|
||||
}
|
||||
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
eu_regulation: 'EU-VO',
|
||||
eu_directive: 'EU-RL',
|
||||
de_law: 'DE-Gesetz',
|
||||
at_law: 'AT-Gesetz',
|
||||
ch_law: 'CH-Gesetz',
|
||||
bsi_standard: 'BSI',
|
||||
national_law: 'Nat. Gesetz',
|
||||
eu_guideline: 'EDPB-GL',
|
||||
}
|
||||
|
||||
// Industries for backward compatibility
|
||||
export const INDUSTRIES: Industry[] = INDUSTRIES_LIST.map((ind: any) => ({
|
||||
id: ind.id,
|
||||
name: ind.name,
|
||||
icon: ind.icon,
|
||||
description: ''
|
||||
}))
|
||||
|
||||
// Derive industry map from document data
|
||||
export const INDUSTRY_REGULATION_MAP: Record<string, string[]> = {}
|
||||
for (const ind of INDUSTRIES_LIST) {
|
||||
INDUSTRY_REGULATION_MAP[ind.id] = RAG_DOCUMENTS
|
||||
.filter((d: any) => d.industries.includes(ind.id) || d.industries.includes('all'))
|
||||
.map((d: any) => d.code)
|
||||
}
|
||||
|
||||
// Thematic groupings showing overlaps
|
||||
export const THEMATIC_GROUPS: ThematicGroup[] = [
|
||||
{
|
||||
id: 'datenschutz',
|
||||
name: 'Datenschutz & Privacy',
|
||||
color: 'bg-blue-500',
|
||||
regulations: ['GDPR', 'EPRIVACY', 'TDDDG', 'SCC', 'DPF'],
|
||||
description: 'Schutz personenbezogener Daten, Einwilligung, Betroffenenrechte'
|
||||
},
|
||||
{
|
||||
id: 'cybersecurity',
|
||||
name: 'Cybersicherheit',
|
||||
color: 'bg-red-500',
|
||||
regulations: ['NIS2', 'EUCSA', 'CRA', 'BSI-TR-03161-1', 'BSI-TR-03161-2', 'BSI-TR-03161-3', 'DORA'],
|
||||
description: 'IT-Sicherheit, Risikomanagement, Incident Response'
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
name: 'Kuenstliche Intelligenz',
|
||||
color: 'bg-purple-500',
|
||||
regulations: ['AIACT', 'PLD', 'GPSR'],
|
||||
description: 'KI-Regulierung, Hochrisiko-Systeme, Haftung'
|
||||
},
|
||||
{
|
||||
id: 'digital-markets',
|
||||
name: 'Digitale Maerkte & Plattformen',
|
||||
color: 'bg-green-500',
|
||||
regulations: ['DSA', 'DGA', 'DATAACT', 'DSM'],
|
||||
description: 'Plattformregulierung, Datenzugang, Urheberrecht'
|
||||
},
|
||||
{
|
||||
id: 'product-safety',
|
||||
name: 'Produktsicherheit & Haftung',
|
||||
color: 'bg-orange-500',
|
||||
regulations: ['CRA', 'PLD', 'GPSR', 'EAA', 'MACHINERY_REG', 'BLUE_GUIDE'],
|
||||
description: 'Sicherheitsanforderungen, CE-Kennzeichnung, Maschinenverordnung, Barrierefreiheit'
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
name: 'Finanzmarktregulierung',
|
||||
color: 'bg-emerald-500',
|
||||
regulations: ['DORA', 'PSD2', 'AMLR', 'MiCA'],
|
||||
description: 'Zahlungsdienste, Krypto-Assets, Geldwaeschebekaempfung, digitale Resilienz'
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
name: 'Gesundheitsdaten',
|
||||
color: 'bg-pink-500',
|
||||
regulations: ['EHDS', 'BSI-TR-03161-1', 'BSI-TR-03161-2', 'BSI-TR-03161-3'],
|
||||
description: 'Gesundheitsdatenraum, DiGA-Sicherheit, Patientenrechte'
|
||||
},
|
||||
{
|
||||
id: 'verbraucherschutz',
|
||||
name: 'Verbraucherschutz & E-Commerce',
|
||||
color: 'bg-amber-500',
|
||||
regulations: ['DE_PANGV', 'DE_VSBG', 'DE_PRODHAFTG', 'DE_UWG', 'DE_BFSG',
|
||||
'WARENKAUF_RL', 'KLAUSEL_RL', 'UNLAUTERE_PRAKTIKEN_RL', 'PREISANGABEN_RL',
|
||||
'OMNIBUS_RL', 'E_COMMERCE_RL', 'VERBRAUCHERRECHTE_RL', 'DIGITALE_INHALTE_RL'],
|
||||
description: 'Widerrufsrecht, Preisangaben, Fernabsatz, AGB-Recht, Barrierefreiheit'
|
||||
},
|
||||
]
|
||||
|
||||
// Key overlaps and intersections
|
||||
export const KEY_INTERSECTIONS: KeyIntersection[] = [
|
||||
{
|
||||
regulations: ['GDPR', 'AIACT'],
|
||||
topic: 'KI und personenbezogene Daten',
|
||||
description: 'Automatisierte Entscheidungen, Profiling, Erklaerbarkeit'
|
||||
},
|
||||
{
|
||||
regulations: ['NIS2', 'CRA'],
|
||||
topic: 'Cybersicherheit von Produkten',
|
||||
description: 'Sicherheitsanforderungen ueber den gesamten Lebenszyklus'
|
||||
},
|
||||
{
|
||||
regulations: ['AIACT', 'PLD'],
|
||||
topic: 'KI-Haftung',
|
||||
description: 'Wer haftet, wenn KI Schaeden verursacht?'
|
||||
},
|
||||
{
|
||||
regulations: ['DSA', 'GDPR'],
|
||||
topic: 'Plattform-Transparenz',
|
||||
description: 'Inhaltsmoderation und Datenschutz'
|
||||
},
|
||||
{
|
||||
regulations: ['DATAACT', 'GDPR'],
|
||||
topic: 'Datenzugang vs. Datenschutz',
|
||||
description: 'Balance zwischen Datenteilung und Privacy'
|
||||
},
|
||||
{
|
||||
regulations: ['CRA', 'GPSR'],
|
||||
topic: 'Digitale Produktsicherheit',
|
||||
description: 'Hardware mit Software-Komponenten'
|
||||
},
|
||||
]
|
||||
|
||||
// Future outlook - proposed and discussed regulations
|
||||
export const FUTURE_OUTLOOK: FutureOutlookItem[] = [
|
||||
{
|
||||
id: 'digital-omnibus',
|
||||
name: 'EU Digital Omnibus',
|
||||
status: 'proposed',
|
||||
statusLabel: 'Vorgeschlagen Nov 2025',
|
||||
expectedDate: '2026/2027',
|
||||
description: 'Umfassendes Vereinfachungspaket fuer AI Act, DSGVO und Cybersicherheit. Ziel: 5 Mrd. EUR Einsparung bei Verwaltungskosten.',
|
||||
keyChanges: [
|
||||
'AI Act: Verschiebung Hochrisiko-Pflichten um bis zu 16 Monate (bis Dez 2027)',
|
||||
'AI Act: Vereinfachte Dokumentation fuer KMU und Small Midcaps',
|
||||
'AI Act: EU-weite regulatorische Sandbox fuer KI-Tests',
|
||||
'DSGVO: Cookie-Banner-Reform - Berechtigtes Interesse statt nur Einwilligung',
|
||||
'DSGVO: Automatische Privacy-Signale via Browser statt Pop-ups',
|
||||
'Cybersecurity: Single Entry Point fuer Meldepflichten'
|
||||
],
|
||||
affectedRegulations: ['AIACT', 'GDPR', 'NIS2', 'CRA', 'EUCSA'],
|
||||
source: 'https://digital-strategy.ec.europa.eu/en/library/digital-omnibus-ai-regulation-proposal'
|
||||
},
|
||||
{
|
||||
id: 'sustainability-omnibus',
|
||||
name: 'EU Nachhaltigkeits-Omnibus',
|
||||
status: 'agreed',
|
||||
statusLabel: 'Einigung Dez 2025',
|
||||
expectedDate: 'Q1 2026',
|
||||
description: 'Drastische Reduzierung der Nachhaltigkeits-Berichtspflichten. Anwendungsbereich wird stark eingeschraenkt.',
|
||||
keyChanges: [
|
||||
'CSRD: Nur noch Unternehmen >1.000 MA und >450 Mio EUR Umsatz berichtspflichtig',
|
||||
'CSRD: Betroffene Unternehmen sinken von 50.000 auf ca. 5.000 in der EU',
|
||||
'CSRD: Verschiebung Welle 2+3 um 2 Jahre (auf Geschaeftsjahr 2027)',
|
||||
'CSDDD: Nur noch Unternehmen >5.000 MA und >1,5 Mrd EUR Umsatz',
|
||||
'CSDDD: Sorgfaltspflichten nur noch fuer Tier-1-Lieferanten',
|
||||
'CSDDD: Pruefung nur noch alle 5 Jahre statt jaehrlich'
|
||||
],
|
||||
affectedRegulations: ['CSRD', 'CSDDD', 'EU-Taxonomie'],
|
||||
source: 'https://kpmg-law.de/erste-omnibus-verordnung-soll-die-pflichten-der-csddd-csrd-und-eu-taxonomie-lockern/'
|
||||
},
|
||||
{
|
||||
id: 'eprivacy-withdrawal',
|
||||
name: 'ePrivacy-Verordnung',
|
||||
status: 'withdrawn',
|
||||
statusLabel: 'Zurueckgezogen Feb 2025',
|
||||
expectedDate: 'Unbekannt',
|
||||
description: 'Nach 9 Jahren Verhandlung hat die EU-Kommission den Vorschlag zurueckgezogen. Die ePrivacy-Richtlinie bleibt in Kraft, Cookie-Reform kommt via DSGVO/Digital Omnibus.',
|
||||
keyChanges: [
|
||||
'Urspruenglicher Vorschlag: Einheitliche EU-Cookie-Regeln',
|
||||
'Urspruenglicher Vorschlag: Strikte Tracking-Einwilligung',
|
||||
'Status: ePrivacy-Richtlinie + TDDDG bleiben gueltig',
|
||||
'Zukunft: Cookie-Reform wird Teil der DSGVO-Aenderungen'
|
||||
],
|
||||
affectedRegulations: ['EPRIVACY', 'TDDDG', 'GDPR'],
|
||||
source: 'https://netzpolitik.org/2025/cookie-banner-und-online-tracking-eu-kommission-beerdigt-plaene-fuer-eprivacy-verordnung/'
|
||||
},
|
||||
{
|
||||
id: 'ai-liability',
|
||||
name: 'KI-Haftungsrichtlinie',
|
||||
status: 'pending',
|
||||
statusLabel: 'In Verhandlung',
|
||||
expectedDate: '2026',
|
||||
description: 'Ergaenzt den AI Act um zivilrechtliche Haftungsregeln. Erleichtert Geschaedigten die Beweisfuehrung bei KI-Schaeden.',
|
||||
keyChanges: [
|
||||
'Beweislasterleichterung bei KI-verursachten Schaeden',
|
||||
'Offenlegungspflichten fuer KI-Anbieter im Schadensfall',
|
||||
'Verknuepfung mit Produkthaftungsrichtlinie'
|
||||
],
|
||||
affectedRegulations: ['AIACT', 'PLD'],
|
||||
source: 'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:52022PC0496'
|
||||
},
|
||||
]
|
||||
|
||||
// Potential future regulations (not yet integrated)
|
||||
export const ADDITIONAL_REGULATIONS: AdditionalRegulation[] = [
|
||||
{
|
||||
code: 'PSD3',
|
||||
name: 'Payment Services Directive 3',
|
||||
fullName: 'Richtlinie zur dritten Zahlungsdiensterichtlinie (Entwurf)',
|
||||
type: 'eu_directive',
|
||||
status: 'proposed',
|
||||
effectiveDate: 'Voraussichtlich 2026',
|
||||
description: 'Modernisierung der Zahlungsdienste-Regulierung. Staerkerer Verbraucherschutz, Open Banking 2.0, Betrugsbekaempfung. Ersetzt dann PSD2.',
|
||||
relevantFor: ['Banken', 'Zahlungsdienstleister', 'Fintechs', 'E-Commerce'],
|
||||
celex: '52023PC0366',
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
code: 'AMLD6',
|
||||
name: 'AML-Richtlinie 6',
|
||||
fullName: 'Richtlinie (EU) 2024/1640 - 6. Geldwaescherichtlinie',
|
||||
type: 'eu_directive',
|
||||
status: 'active',
|
||||
effectiveDate: '10. Juli 2027 (Umsetzung)',
|
||||
description: 'Ergaenzt die AML-Verordnung. Nationale Umsetzungsvorschriften, strafrechtliche Sanktionen, AMLA-Behoerde.',
|
||||
relevantFor: ['Banken', 'Krypto-Anbieter', 'Immobilienmakler', 'Gluecksspielanbieter'],
|
||||
celex: '32024L1640',
|
||||
priority: 'medium'
|
||||
},
|
||||
{
|
||||
code: 'FIDA',
|
||||
name: 'Financial Data Access',
|
||||
fullName: 'Verordnung zum Zugang zu Finanzdaten (Entwurf)',
|
||||
type: 'eu_regulation',
|
||||
status: 'proposed',
|
||||
effectiveDate: 'Voraussichtlich 2027',
|
||||
description: 'Open Finance Framework - erweitert PSD2-Open-Banking auf Versicherungen, Investitionen, Kredite.',
|
||||
relevantFor: ['Banken', 'Versicherungen', 'Fintechs', 'Datenaggregatoren'],
|
||||
celex: '52023PC0360',
|
||||
priority: 'medium'
|
||||
},
|
||||
]
|
||||
|
||||
// Legal basis for using EUR-Lex content
|
||||
export const LEGAL_BASIS_INFO: LegalBasisInfo = {
|
||||
title: 'Rechtliche Grundlage fuer RAG-Nutzung',
|
||||
summary: 'EU-Rechtstexte auf EUR-Lex sind oeffentliche amtliche Dokumente und duerfen frei verwendet werden.',
|
||||
details: [
|
||||
{
|
||||
aspect: 'EUR-Lex Dokumente',
|
||||
status: 'Erlaubt',
|
||||
explanation: 'Offizielle EU-Gesetzestexte, Richtlinien und Verordnungen sind gemeinfrei (Public Domain) und duerfen frei reproduziert und kommerziell genutzt werden.'
|
||||
},
|
||||
{
|
||||
aspect: 'Text-und-Data-Mining (TDM)',
|
||||
status: 'Erlaubt',
|
||||
explanation: 'Art. 4 der DSM-Richtlinie (2019/790) erlaubt TDM fuer kommerzielle Zwecke, sofern kein Opt-out des Rechteinhabers vorliegt. Fuer amtliche Texte gilt kein Opt-out.'
|
||||
},
|
||||
{
|
||||
aspect: 'AI Act Anforderungen',
|
||||
status: 'Beachten',
|
||||
explanation: 'Art. 53 AI Act verlangt von GPAI-Anbietern die Einhaltung des Urheberrechts. Fuer oeffentliche Rechtstexte unproblematisch.'
|
||||
},
|
||||
{
|
||||
aspect: 'BSI-Richtlinien',
|
||||
status: 'Erlaubt',
|
||||
explanation: 'BSI-Publikationen sind oeffentlich zugaenglich und duerfen fuer Compliance-Zwecke verwendet werden.'
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Tab definitions
|
||||
export const TABS: TabDef[] = [
|
||||
{ id: 'overview', name: 'Uebersicht', icon: '📊' },
|
||||
{ id: 'regulations', name: 'Regulierungen', icon: '📜' },
|
||||
{ id: 'map', name: 'Landkarte', icon: '🗺️' },
|
||||
{ id: 'search', name: 'Suche', icon: '🔍' },
|
||||
{ id: 'chunks', name: 'Chunk-Browser', icon: '🧩' },
|
||||
{ id: 'data', name: 'Daten', icon: '📁' },
|
||||
{ id: 'ingestion', name: 'Ingestion', icon: '⚙️' },
|
||||
{ id: 'pipeline', name: 'Pipeline', icon: '🔄' },
|
||||
]
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* RAG - Regulation Source URLs and License Information
|
||||
*
|
||||
* Extracted from rag-data.ts to stay under 500 LOC per file.
|
||||
*/
|
||||
|
||||
// Source URLs for original documents (click to view original)
|
||||
export const REGULATION_SOURCES: Record<string, string> = {
|
||||
// EU Verordnungen/Richtlinien (EUR-Lex)
|
||||
GDPR: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32016R0679',
|
||||
EPRIVACY: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32002L0058',
|
||||
SCC: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32021D0914',
|
||||
DPF: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023D1795',
|
||||
AIACT: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024R1689',
|
||||
CRA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024R2847',
|
||||
NIS2: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022L2555',
|
||||
EUCSA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32019R0881',
|
||||
DATAACT: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R2854',
|
||||
DGA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022R0868',
|
||||
DSA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022R2065',
|
||||
EAA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32019L0882',
|
||||
DSM: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32019L0790',
|
||||
PLD: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024L2853',
|
||||
GPSR: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R0988',
|
||||
DORA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022R2554',
|
||||
PSD2: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32015L2366',
|
||||
AMLR: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024R1624',
|
||||
MiCA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R1114',
|
||||
EHDS: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32025R0327',
|
||||
SCC_FULL_TEXT: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32021D0914',
|
||||
E_COMMERCE_RL: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32000L0031',
|
||||
VERBRAUCHERRECHTE_RL: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32011L0083',
|
||||
DIGITALE_INHALTE_RL: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32019L0770',
|
||||
DMA: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022R1925',
|
||||
MACHINERY_REG: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R1230',
|
||||
BLUE_GUIDE: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:52022XC0629(04)',
|
||||
EU_IFRS: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R1803',
|
||||
// EDPB Guidelines
|
||||
EDPB_GUIDELINES_2_2019: 'https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22019-processing-personal-data-under-article-61b_en',
|
||||
EDPB_GUIDELINES_3_2019: 'https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-32019-processing-personal-data-through-video_en',
|
||||
EDPB_GUIDELINES_5_2020: 'https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-052020-consent-under-regulation-2016679_en',
|
||||
EDPB_GUIDELINES_7_2020: 'https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-072020-concepts-controller-and-processor-gdpr_en',
|
||||
EDPB_GUIDELINES_1_2022: 'https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-042022-calculation-administrative-fines-under-gdpr_en',
|
||||
// BSI Technische Richtlinien
|
||||
'BSI-TR-03161-1': 'https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-1.html',
|
||||
'BSI-TR-03161-2': 'https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-2.html',
|
||||
'BSI-TR-03161-3': 'https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-3.html',
|
||||
// Nationale Datenschutzgesetze
|
||||
AT_DSG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10001597',
|
||||
BDSG_FULL: 'https://www.gesetze-im-internet.de/bdsg_2018/',
|
||||
CH_DSG: 'https://www.fedlex.admin.ch/eli/cc/2022/491/de',
|
||||
LI_DSG: 'https://www.gesetze.li/konso/2018.272',
|
||||
BE_DPA_LAW: 'https://www.autoriteprotectiondonnees.be/citoyen/la-loi-du-30-juillet-2018',
|
||||
NL_UAVG: 'https://wetten.overheid.nl/BWBR0040940/',
|
||||
FR_CNIL_GUIDE: 'https://www.cnil.fr/fr/rgpd-par-ou-commencer',
|
||||
ES_LOPDGDD: 'https://www.boe.es/buscar/act.php?id=BOE-A-2018-16673',
|
||||
IT_CODICE_PRIVACY: 'https://www.garanteprivacy.it/home/docweb/-/docweb-display/docweb/9042678',
|
||||
IE_DPA_2018: 'https://www.irishstatutebook.ie/eli/2018/act/7/enacted/en/html',
|
||||
UK_DPA_2018: 'https://www.legislation.gov.uk/ukpga/2018/12/contents',
|
||||
UK_GDPR: 'https://www.legislation.gov.uk/eur/2016/679/contents',
|
||||
NO_PERSONOPPLYSNINGSLOVEN: 'https://lovdata.no/dokument/NL/lov/2018-06-15-38',
|
||||
SE_DATASKYDDSLAG: 'https://www.riksdagen.se/sv/dokument-och-lagar/dokument/svensk-forfattningssamling/lag-2018218-med-kompletterande-bestammelser_sfs-2018-218/',
|
||||
FI_TIETOSUOJALAKI: 'https://www.finlex.fi/fi/laki/ajantasa/2018/20181050',
|
||||
PL_UODO: 'https://isap.sejm.gov.pl/isap.nsf/DocDetails.xsp?id=WDU20180001000',
|
||||
CZ_ZOU: 'https://www.zakonyprolidi.cz/cs/2019-110',
|
||||
HU_INFOTV: 'https://net.jogtar.hu/jogszabaly?docid=a1100112.tv',
|
||||
LU_DPA_LAW: 'https://legilux.public.lu/eli/etat/leg/loi/2018/08/01/a686/jo',
|
||||
DK_DATABESKYTTELSESLOVEN: 'https://www.retsinformation.dk/eli/lta/2018/502',
|
||||
// Deutschland — Weitere Gesetze
|
||||
TDDDG: 'https://www.gesetze-im-internet.de/tdddg/',
|
||||
DE_DDG: 'https://www.gesetze-im-internet.de/ddg/',
|
||||
DE_BGB_AGB: 'https://www.gesetze-im-internet.de/bgb/__305.html',
|
||||
DE_EGBGB: 'https://www.gesetze-im-internet.de/bgbeg/art_246.html',
|
||||
DE_UWG: 'https://www.gesetze-im-internet.de/uwg_2004/',
|
||||
DE_HGB_RET: 'https://www.gesetze-im-internet.de/hgb/__257.html',
|
||||
DE_AO_RET: 'https://www.gesetze-im-internet.de/ao_1977/__147.html',
|
||||
DE_TKG: 'https://www.gesetze-im-internet.de/tkg_2021/',
|
||||
DE_PANGV: 'https://www.gesetze-im-internet.de/pangv_2022/',
|
||||
DE_DLINFOV: 'https://www.gesetze-im-internet.de/dlinfov/',
|
||||
DE_BETRVG: 'https://www.gesetze-im-internet.de/betrvg/__87.html',
|
||||
DE_GESCHGEHG: 'https://www.gesetze-im-internet.de/geschgehg/',
|
||||
DE_BSIG: 'https://www.gesetze-im-internet.de/bsig_2009/',
|
||||
DE_USTG_RET: 'https://www.gesetze-im-internet.de/ustg_1980/__14b.html',
|
||||
// Oesterreich — Weitere Gesetze
|
||||
AT_ECG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=20001703',
|
||||
AT_TKG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=20007898',
|
||||
AT_KSCHG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10002462',
|
||||
AT_FAGG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=20008783',
|
||||
AT_UGB_RET: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10001702',
|
||||
AT_BAO_RET: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10003940',
|
||||
AT_MEDIENG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10000719',
|
||||
AT_ABGB_AGB: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10001622',
|
||||
AT_UWG: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10002665',
|
||||
// Schweiz
|
||||
CH_DSV: 'https://www.fedlex.admin.ch/eli/cc/2022/568/de',
|
||||
CH_OR_AGB: 'https://www.fedlex.admin.ch/eli/cc/27/317_321_377/de',
|
||||
CH_UWG: 'https://www.fedlex.admin.ch/eli/cc/1988/223_223_223/de',
|
||||
CH_FMG: 'https://www.fedlex.admin.ch/eli/cc/1997/2187_2187_2187/de',
|
||||
CH_GEBUV: 'https://www.fedlex.admin.ch/eli/cc/2002/249/de',
|
||||
CH_ZERTES: 'https://www.fedlex.admin.ch/eli/cc/2016/752/de',
|
||||
CH_ZGB_PERS: 'https://www.fedlex.admin.ch/eli/cc/24/233_245_233/de',
|
||||
// Industrie-Compliance
|
||||
ENISA_SECURE_BY_DESIGN: 'https://www.enisa.europa.eu/publications/secure-development-best-practices',
|
||||
ENISA_SUPPLY_CHAIN: 'https://www.enisa.europa.eu/publications/threat-landscape-for-supply-chain-attacks',
|
||||
NIST_SSDF: 'https://csrc.nist.gov/pubs/sp/800/218/final',
|
||||
NIST_CSF_2: 'https://www.nist.gov/cyberframework',
|
||||
OECD_AI_PRINCIPLES: 'https://legalinstruments.oecd.org/en/instruments/OECD-LEGAL-0449',
|
||||
// IFRS / EFRAG
|
||||
EU_IFRS_DE: 'https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32023R1803',
|
||||
EU_IFRS_EN: 'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32023R1803',
|
||||
EFRAG_ENDORSEMENT: 'https://www.efrag.org/activities/endorsement-status-report',
|
||||
// Full-text Datenschutzgesetz AT
|
||||
AT_DSG_FULL: 'https://www.ris.bka.gv.at/GeltendeFassung.wxe?Abfrage=Bundesnormen&Gesetzesnummer=10001597',
|
||||
}
|
||||
|
||||
// License info for each regulation
|
||||
export const REGULATION_LICENSES: Record<string, { license: string; licenseNote: string }> = {
|
||||
GDPR: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk der EU — frei verwendbar' },
|
||||
EPRIVACY: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
TDDDG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
SCC: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Durchfuehrungsbeschluss — amtliches Werk' },
|
||||
DPF: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Angemessenheitsbeschluss — amtliches Werk' },
|
||||
AIACT: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
CRA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
NIS2: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
EUCSA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
DATAACT: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
DGA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
DSA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
EAA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
DSM: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
PLD: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
GPSR: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
'BSI-TR-03161-1': { license: 'DL-DE-BY-2.0', licenseNote: 'Datenlizenz Deutschland — Namensnennung 2.0' },
|
||||
'BSI-TR-03161-2': { license: 'DL-DE-BY-2.0', licenseNote: 'Datenlizenz Deutschland — Namensnennung 2.0' },
|
||||
'BSI-TR-03161-3': { license: 'DL-DE-BY-2.0', licenseNote: 'Datenlizenz Deutschland — Namensnennung 2.0' },
|
||||
DORA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
PSD2: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
AMLR: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
MiCA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
EHDS: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
AT_DSG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
BDSG_FULL: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
CH_DSG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
LI_DSG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Liechtenstein — frei verwendbar' },
|
||||
BE_DPA_LAW: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Belgien — frei verwendbar' },
|
||||
NL_UAVG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Niederlande — frei verwendbar' },
|
||||
FR_CNIL_GUIDE: { license: 'PUBLIC_DOMAIN', licenseNote: 'CNIL — oeffentliches Dokument' },
|
||||
ES_LOPDGDD: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Spanien (BOE) — frei verwendbar' },
|
||||
IT_CODICE_PRIVACY: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Italien — frei verwendbar' },
|
||||
IE_DPA_2018: { license: 'OGL-3.0', licenseNote: 'Open Government Licence v3.0 — Ireland' },
|
||||
UK_DPA_2018: { license: 'OGL-3.0', licenseNote: 'Open Government Licence v3.0 — UK' },
|
||||
UK_GDPR: { license: 'OGL-3.0', licenseNote: 'Open Government Licence v3.0 — UK' },
|
||||
NO_PERSONOPPLYSNINGSLOVEN: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Norwegen — frei verwendbar' },
|
||||
SE_DATASKYDDSLAG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweden — frei verwendbar' },
|
||||
FI_TIETOSUOJALAKI: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Finnland — frei verwendbar' },
|
||||
PL_UODO: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Polen — frei verwendbar' },
|
||||
CZ_ZOU: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Tschechien — frei verwendbar' },
|
||||
HU_INFOTV: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Ungarn — frei verwendbar' },
|
||||
SCC_FULL_TEXT: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Durchfuehrungsbeschluss — amtliches Werk' },
|
||||
EDPB_GUIDELINES_2_2019: { license: 'EDPB-LICENSE', licenseNote: 'EDPB Document License' },
|
||||
EDPB_GUIDELINES_3_2019: { license: 'EDPB-LICENSE', licenseNote: 'EDPB Document License' },
|
||||
EDPB_GUIDELINES_5_2020: { license: 'EDPB-LICENSE', licenseNote: 'EDPB Document License' },
|
||||
EDPB_GUIDELINES_7_2020: { license: 'EDPB-LICENSE', licenseNote: 'EDPB Document License' },
|
||||
MACHINERY_REG: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
BLUE_GUIDE: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Leitfaden — amtliches Werk der Kommission' },
|
||||
ENISA_SECURE_BY_DESIGN: { license: 'CC-BY-4.0', licenseNote: 'ENISA Publication — CC BY 4.0' },
|
||||
ENISA_SUPPLY_CHAIN: { license: 'CC-BY-4.0', licenseNote: 'ENISA Publication — CC BY 4.0' },
|
||||
NIST_SSDF: { license: 'PUBLIC_DOMAIN', licenseNote: 'US Government Work — Public Domain' },
|
||||
NIST_CSF_2: { license: 'PUBLIC_DOMAIN', licenseNote: 'US Government Work — Public Domain' },
|
||||
OECD_AI_PRINCIPLES: { license: 'PUBLIC_DOMAIN', licenseNote: 'OECD Legal Instrument — Reuse Notice' },
|
||||
EU_IFRS_DE: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
EU_IFRS_EN: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
EFRAG_ENDORSEMENT: { license: 'PUBLIC_DOMAIN', licenseNote: 'EFRAG — oeffentliches Dokument' },
|
||||
DE_DDG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_BGB_AGB: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_EGBGB: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_UWG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_HGB_RET: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_AO_RET: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_TKG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_PANGV: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsche Verordnung — amtliches Werk (§5 UrhG)' },
|
||||
DE_DLINFOV: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsche Verordnung — amtliches Werk (§5 UrhG)' },
|
||||
DE_BETRVG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_GESCHGEHG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_BSIG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
DE_USTG_RET: { license: 'PUBLIC_DOMAIN', licenseNote: 'Deutsches Bundesgesetz — amtliches Werk (§5 UrhG)' },
|
||||
AT_ECG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_TKG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_KSCHG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_FAGG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_UGB_RET: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_BAO_RET: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_MEDIENG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_ABGB_AGB: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
AT_UWG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Oesterreich — frei verwendbar' },
|
||||
CH_DSV: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
CH_OR_AGB: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
CH_UWG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
CH_FMG: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
CH_GEBUV: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
CH_ZERTES: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
CH_ZGB_PERS: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Schweiz — frei verwendbar' },
|
||||
LU_DPA_LAW: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Luxemburg — frei verwendbar' },
|
||||
DK_DATABESKYTTELSESLOVEN: { license: 'PUBLIC_DOMAIN', licenseNote: 'Amtliches Werk Daenemark — frei verwendbar' },
|
||||
EDPB_GUIDELINES_1_2022: { license: 'EDPB-LICENSE', licenseNote: 'EDPB Document License' },
|
||||
E_COMMERCE_RL: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
VERBRAUCHERRECHTE_RL: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
DIGITALE_INHALTE_RL: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Richtlinie — amtliches Werk' },
|
||||
DMA: { license: 'PUBLIC_DOMAIN', licenseNote: 'EU-Verordnung — amtliches Werk' },
|
||||
}
|
||||
|
||||
// License display labels
|
||||
export const LICENSE_LABELS: Record<string, string> = {
|
||||
PUBLIC_DOMAIN: 'Public Domain',
|
||||
'DL-DE-BY-2.0': 'DL-DE-BY 2.0',
|
||||
'CC-BY-4.0': 'CC BY 4.0',
|
||||
'EDPB-LICENSE': 'EDPB License',
|
||||
'OGL-3.0': 'OGL v3.0',
|
||||
PROPRIETARY: 'Proprietaer',
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* RAG & Legal Corpus Management - Type Definitions
|
||||
*/
|
||||
|
||||
export interface RegulationStatus {
|
||||
code: string
|
||||
name: string
|
||||
fullName: string
|
||||
type: string
|
||||
chunkCount: number
|
||||
expectedRequirements: number
|
||||
sourceUrl: string
|
||||
status: 'ready' | 'empty' | 'error'
|
||||
}
|
||||
|
||||
export interface CollectionStatus {
|
||||
collection: string
|
||||
totalPoints: number
|
||||
vectorSize: number
|
||||
status: string
|
||||
regulations: Record<string, number>
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
text: string
|
||||
regulation_code: string
|
||||
regulation_name: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
source_url: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface DsfaSource {
|
||||
source_code: string
|
||||
name: string
|
||||
full_name?: string
|
||||
organization?: string
|
||||
source_url?: string
|
||||
license_code: string
|
||||
attribution_text: string
|
||||
document_type: string
|
||||
language: string
|
||||
chunk_count?: number
|
||||
}
|
||||
|
||||
export interface DsfaCorpusStatus {
|
||||
qdrant_collection: string
|
||||
total_sources: number
|
||||
total_documents: number
|
||||
total_chunks: number
|
||||
qdrant_points_count: number
|
||||
qdrant_status: string
|
||||
}
|
||||
|
||||
export type RegulationCategory = 'regulations' | 'dsfa' | 'nibis' | 'templates'
|
||||
|
||||
export type TabId = 'overview' | 'regulations' | 'map' | 'search' | 'chunks' | 'data' | 'ingestion' | 'pipeline'
|
||||
|
||||
export interface CustomDocument {
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
filename?: string
|
||||
url?: string
|
||||
document_type: string
|
||||
uploaded_at: string
|
||||
status: 'uploaded' | 'queued' | 'fetching' | 'processing' | 'indexed' | 'error'
|
||||
chunk_count: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface Validation {
|
||||
name: string
|
||||
status: 'passed' | 'warning' | 'failed' | 'not_run'
|
||||
expected: any
|
||||
actual: any
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface PipelineCheckpoint {
|
||||
phase: string
|
||||
name: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
duration_seconds: number | null
|
||||
metrics: Record<string, any>
|
||||
validations: Validation[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface PipelineState {
|
||||
status: string
|
||||
pipeline_id: string | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
current_phase: string | null
|
||||
checkpoints: PipelineCheckpoint[]
|
||||
summary: Record<string, any>
|
||||
validation_summary?: {
|
||||
passed: number
|
||||
warning: number
|
||||
failed: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Regulation {
|
||||
code: string
|
||||
name: string
|
||||
fullName: string
|
||||
type: string
|
||||
expected: number
|
||||
description: string
|
||||
relevantFor: string[]
|
||||
keyTopics: string[]
|
||||
effectiveDate: string
|
||||
}
|
||||
|
||||
export interface Industry {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ThematicGroup {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
regulations: string[]
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface KeyIntersection {
|
||||
regulations: string[]
|
||||
topic: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface FutureOutlookItem {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
statusLabel: string
|
||||
expectedDate: string
|
||||
description: string
|
||||
keyChanges: string[]
|
||||
affectedRegulations: string[]
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface AdditionalRegulation {
|
||||
code: string
|
||||
name: string
|
||||
fullName: string
|
||||
type: string
|
||||
status: string
|
||||
effectiveDate: string
|
||||
description: string
|
||||
relevantFor: string[]
|
||||
celex: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
export interface LegalBasisDetail {
|
||||
aspect: string
|
||||
status: string
|
||||
explanation: string
|
||||
}
|
||||
|
||||
export interface LegalBasisInfo {
|
||||
title: string
|
||||
summary: string
|
||||
details: LegalBasisDetail[]
|
||||
}
|
||||
|
||||
export interface TabDef {
|
||||
id: TabId
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
// Demo failed test details for illustration
|
||||
const FAILED_TEST_DETAILS: Record<string, { description: string; cause: string; action: string }> = {
|
||||
// Golden Suite - Intent Tests
|
||||
'INT-001': {
|
||||
description: 'Student Observation - Simple',
|
||||
cause: 'Notiz zu Max wird nicht korrekt als student_observation erkannt',
|
||||
action: 'Training-Daten fuer kurze Notiz-Befehle erweitern',
|
||||
},
|
||||
'INT-002': {
|
||||
description: 'Student Observation - Needs Help',
|
||||
cause: 'Anfrage "Anna braucht extra Uebungsblatt" wird falsch klassifiziert',
|
||||
action: 'Intent-Erkennung fuer Hilfe-Anfragen verbessern',
|
||||
},
|
||||
'INT-003': {
|
||||
description: 'Reminder - Simple',
|
||||
cause: 'Erinnerungs-Intent nicht erkannt',
|
||||
action: 'Trigger-Woerter fuer Erinnerungen pruefen',
|
||||
},
|
||||
'INT-010': {
|
||||
description: 'Quick Activity - With Time',
|
||||
cause: '"10 Minuten Einstieg" wird nicht als quick_activity erkannt',
|
||||
action: 'Zeitmuster in Quick-Activity-Intent aufnehmen',
|
||||
},
|
||||
'INT-011': {
|
||||
description: 'Quiz Generate - Vocabulary',
|
||||
cause: 'Vokabeltest wird nicht als quiz_generate klassifiziert',
|
||||
action: 'Quiz-Keywords wie "Test", "Vokabel" staerker gewichten',
|
||||
},
|
||||
'INT-012': {
|
||||
description: 'Quiz Generate - Short Test',
|
||||
cause: '"Kurzer Test zu Kapitel 5" falsch erkannt',
|
||||
action: 'Kontext-Keywords fuer Quiz verbessern',
|
||||
},
|
||||
'INT-015': {
|
||||
description: 'Class Message',
|
||||
cause: 'Nachricht an Klasse wird als anderer Intent erkannt',
|
||||
action: 'Klassen-Nachrichten-Patterns erweitern',
|
||||
},
|
||||
'INT-019': {
|
||||
description: 'Operator Checklist',
|
||||
cause: 'Operatoren-Anfrage nicht korrekt klassifiziert',
|
||||
action: 'EH/Operator-bezogene Intents pruefen',
|
||||
},
|
||||
'INT-021': {
|
||||
description: 'Feedback Suggest',
|
||||
cause: 'Feedback-Vorschlag Intent nicht erkannt',
|
||||
action: 'Feedback-Synonyme hinzufuegen',
|
||||
},
|
||||
'INT-022': {
|
||||
description: 'Reminder Schedule - Tomorrow',
|
||||
cause: 'Zeitbasierte Erinnerung falsch klassifiziert',
|
||||
action: 'Zeitausdrucke wie "morgen" besser verarbeiten',
|
||||
},
|
||||
'INT-023': {
|
||||
description: 'Task Summary',
|
||||
cause: 'Zusammenfassungs-Intent nicht erkannt',
|
||||
action: 'Summary-Trigger erweitern',
|
||||
},
|
||||
// RAG Tests
|
||||
'RAG-EH-001': {
|
||||
description: 'EH Passage Retrieval - Textanalyse Sachtext',
|
||||
cause: 'EH-Passage nicht gefunden oder unvollstaendig',
|
||||
action: 'RAG-Retrieval fuer Textanalyse optimieren',
|
||||
},
|
||||
'RAG-HAL-002': {
|
||||
description: 'No Fictional EH Passages',
|
||||
cause: 'System generiert fiktive EH-Inhalte',
|
||||
action: 'Hallucination-Control verstaerken',
|
||||
},
|
||||
'RAG-HAL-004': {
|
||||
description: 'Grounded Response Only',
|
||||
cause: 'Antwort basiert nicht auf vorhandenen Daten',
|
||||
action: 'Grounding-Check im Response-Flow einbauen',
|
||||
},
|
||||
'RAG-CIT-003': {
|
||||
description: 'Multiple Source Attribution',
|
||||
cause: 'Mehrere Quellen nicht korrekt zugeordnet',
|
||||
action: 'Multi-Source Citation verbessern',
|
||||
},
|
||||
'RAG-EDGE-002': {
|
||||
description: 'Ambiguous Operator Query',
|
||||
cause: 'Bei mehrdeutiger Anfrage keine Klaerung angefordert',
|
||||
action: 'Clarification-Flow implementieren',
|
||||
},
|
||||
// Synthetic Tests
|
||||
'SYN-STUD-003': {
|
||||
description: 'Synthetic Student Observation',
|
||||
cause: 'Generierte Variante nicht erkannt',
|
||||
action: 'Robustheit gegen Variationen erhoehen',
|
||||
},
|
||||
'SYN-WORK-002': {
|
||||
description: 'Synthetic Worksheet Generate',
|
||||
cause: 'Arbeitsblatt-Intent bei Variation nicht erkannt',
|
||||
action: 'Mehr Variationen ins Training aufnehmen',
|
||||
},
|
||||
'SYN-WORK-005': {
|
||||
description: 'Synthetic Worksheet mit Tippfehler',
|
||||
cause: 'Tippfehler fuehrt zu Fehlklassifikation',
|
||||
action: 'Tippfehler-Normalisierung pruefen',
|
||||
},
|
||||
'SYN-REM-001': {
|
||||
description: 'Synthetic Reminder',
|
||||
cause: 'Reminder-Variante nicht erkannt',
|
||||
action: 'Reminder-Patterns erweitern',
|
||||
},
|
||||
'SYN-REM-004': {
|
||||
description: 'Synthetic Reminder mit Dialekt',
|
||||
cause: 'Dialekt-Formulierung nicht verstanden',
|
||||
action: 'Dialekt-Normalisierung verbessern',
|
||||
},
|
||||
}
|
||||
|
||||
export function FailedTestsList({ testIds }: { testIds: string[] }) {
|
||||
const [expandedTest, setExpandedTest] = useState<string | null>(null)
|
||||
|
||||
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-96 overflow-y-auto">
|
||||
{testIds.map((testId) => {
|
||||
const details = FAILED_TEST_DETAILS[testId]
|
||||
const isExpanded = expandedTest === testId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={testId}
|
||||
className="rounded-lg border border-red-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedTest(isExpanded ? null : testId)}
|
||||
className="w-full flex items-center justify-between p-3 bg-red-50 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<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>
|
||||
{details && (
|
||||
<span className="text-xs text-red-600 hidden sm:inline">- {details.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-red-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isExpanded && details && (
|
||||
<div className="p-4 bg-white border-t border-red-100 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Ursache</p>
|
||||
<p className="text-sm text-slate-700 mt-1">{details.cause}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Empfohlene Aktion</p>
|
||||
<p className="text-sm text-slate-700 mt-1 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" 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>
|
||||
{details.action}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-400">
|
||||
Test-ID: <span className="font-mono">{testId}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs text-amber-800">
|
||||
<strong>Tipp:</strong> Klicken Sie auf einen Test um Details zur Ursache und empfohlene Aktionen zu sehen.
|
||||
Fehlgeschlagene Tests sollten vor dem naechsten Release behoben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import type { 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 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunningGolden
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{isRunningGolden ? 'Laeuft...' : 'Tests starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{goldenMetrics && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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 ({goldenMetrics.failed_tests})</h4>
|
||||
<FailedTestsList testIds={goldenMetrics.failed_test_ids} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export function GuideTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Introduction */}
|
||||
<div className="bg-gradient-to-r from-teal-50 to-emerald-50 rounded-xl border border-teal-200 p-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-teal-600" 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>
|
||||
Was ist BQAS?
|
||||
</h2>
|
||||
<p className="text-slate-700 leading-relaxed">
|
||||
Das <strong>Breakpilot Quality Assurance System (BQAS)</strong> ist unser automatisiertes Test-Framework
|
||||
zur kontinuierlichen Qualitaetssicherung der KI-Komponenten. Es stellt sicher, dass Aenderungen am
|
||||
Voice-Service, den Prompts oder den RAG-Pipelines keine Regressionen verursachen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* For Whom */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Fuer wen ist dieses Dashboard?</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
Entwickler
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Pruefen Sie nach Code-Aenderungen ob alle Tests noch bestehen. Analysieren Sie fehlgeschlagene Tests
|
||||
und implementieren Sie Fixes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<h4 className="font-medium text-purple-800 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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Data Scientists
|
||||
</h4>
|
||||
<p className="text-sm text-purple-700 mt-2">
|
||||
Analysieren Sie Intent-Scores, Faithfulness und Relevance. Identifizieren Sie Schwachstellen
|
||||
in den ML-Modellen und RAG-Pipelines.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<h4 className="font-medium text-amber-800 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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Auditoren / QA
|
||||
</h4>
|
||||
<p className="text-sm text-amber-700 mt-2">
|
||||
Dokumentieren Sie die Testabdeckung und Qualitaetsmetriken. Nutzen Sie die Historie
|
||||
fuer Audit-Trails und Compliance-Nachweise.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Suites Explained */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Die drei Test-Suites</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-blue-600">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">Golden Suite (97 Tests)</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Was:</strong> Manuell validierte Referenz-Tests mit definierten Erwartungen. Jeder Test
|
||||
hat eine Eingabe, eine erwartete Ausgabe und Bewertungskriterien.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Wann ausfuehren:</strong> Nach jeder Aenderung am Voice-Service oder den Prompts.
|
||||
Automatisch taeglich um 07:00 Uhr via launchd.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Ziel-Score:</strong> {'>'}= 4.0 (von 5.0)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-purple-600">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">RAG/Korrektur Tests</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Was:</strong> Tests fuer das Retrieval-Augmented Generation System. Pruefen ob der richtige
|
||||
Erwartungshorizont gefunden wird und ob Antworten korrekt zitiert werden.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Wann ausfuehren:</strong> Nach Aenderungen an Qdrant, Chunking-Strategien oder EH-Uploads.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Kategorien:</strong> EH-Retrieval, Operator-Alignment, Hallucination-Control, Citation-Enforcement,
|
||||
Privacy-Compliance, Namespace-Isolation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-amber-600">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">Synthetic Tests</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Was:</strong> LLM-generierte Variationen der Golden-Tests. Testet Robustheit gegenueber
|
||||
Umformulierungen, Tippfehlern, Dialekt und Edge-Cases.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Wann ausfuehren:</strong> Woechentlich oder vor Major-Releases.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Hinweis:</strong> Generierung dauert laenger da LLM-Calls benoetigt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Explained */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Metriken verstehen</h3>
|
||||
|
||||
<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">Metrik</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Beschreibung</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-slate-700">Zielwert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Composite Score</td>
|
||||
<td className="py-3 px-4 text-slate-600">Gewichteter Durchschnitt aller Einzelmetriken (1-5)</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Intent Accuracy</td>
|
||||
<td className="py-3 px-4 text-slate-600">Wie oft wird die richtige Nutzerabsicht erkannt?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 90%</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Faithfulness</td>
|
||||
<td className="py-3 px-4 text-slate-600">Ist die Antwort dem EH treu? Keine Halluzinationen?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Relevance</td>
|
||||
<td className="py-3 px-4 text-slate-600">Beantwortet die Antwort die Frage des Nutzers?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Coherence</td>
|
||||
<td className="py-3 px-4 text-slate-600">Ist die Antwort logisch aufgebaut und verstaendlich?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 font-medium">Safety Pass Rate</td>
|
||||
<td className="py-3 px-4 text-slate-600">Werden kritische Inhalte korrekt gefiltert?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">100%</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Typischer Workflow</h3>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200"></div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ step: 1, title: 'Tests starten', desc: 'Klicken Sie auf "Tests starten" bei der gewuenschten Suite. Eine Benachrichtigung zeigt den Status.' },
|
||||
{ step: 2, title: 'Ergebnisse pruefen', desc: 'Nach Abschluss werden Pass Rate und Score angezeigt. Pruefen Sie ob der Zielwert erreicht wurde.' },
|
||||
{ step: 3, title: 'Fehlgeschlagene Tests analysieren', desc: 'Klicken Sie auf fehlgeschlagene Tests um Ursache und empfohlene Aktionen zu sehen.' },
|
||||
{ step: 4, title: 'Fixes implementieren', desc: 'Beheben Sie die identifizierten Probleme im Code, Prompts oder Training-Daten.' },
|
||||
{ step: 5, title: 'Erneut testen', desc: 'Fuehren Sie die Tests erneut aus um zu verifizieren dass die Fixes wirksam sind.' },
|
||||
{ step: 6, title: 'Dokumentieren', desc: 'Nutzen Sie die Historie als Audit-Trail. Exportieren Sie Reports fuer Compliance-Nachweise.' },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="flex gap-4 relative">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-teal-600 text-white flex items-center justify-center text-sm font-bold z-10">
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<h4 className="font-medium text-slate-900">{item.title}</h4>
|
||||
<p className="text-sm text-slate-600 mt-0.5">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Haeufige Fragen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
q: 'Wie lange dauert ein Test-Lauf?',
|
||||
a: 'Golden Suite: ca. 45 Sekunden. RAG Tests: ca. 60 Sekunden. Synthetic Tests: 2-5 Minuten (abhaengig von LLM-Verfuegbarkeit).',
|
||||
},
|
||||
{
|
||||
q: 'Was passiert wenn Tests fehlschlagen?',
|
||||
a: 'Fehlgeschlagene Tests werden rot markiert. Klicken Sie darauf um Details zu sehen. Bei kritischen Regressionen wird automatisch eine Desktop-Benachrichtigung gesendet.',
|
||||
},
|
||||
{
|
||||
q: 'Wann werden Tests automatisch ausgefuehrt?',
|
||||
a: 'Die Golden Suite laeuft taeglich um 07:00 Uhr via launchd. Zusaetzlich bei jedem Commit im voice-service via Git-Hook (Quick-Tests).',
|
||||
},
|
||||
{
|
||||
q: 'Wie kann ich einen neuen Golden-Test hinzufuegen?',
|
||||
a: 'Tests werden in /voice-service/bqas/golden_tests.json definiert. Jeder Test braucht: ID, Input, Expected Intent, Bewertungskriterien.',
|
||||
},
|
||||
{
|
||||
q: 'Was bedeutet "Demo-Daten"?',
|
||||
a: 'Wenn die Voice-Service API nicht erreichbar ist, werden Demo-Daten angezeigt. Dies ist normal in der Entwicklungsumgebung wenn der Service nicht laeuft.',
|
||||
},
|
||||
].map((faq, i) => (
|
||||
<div key={i} className="border-b border-slate-100 pb-4 last:border-0 last:pb-0">
|
||||
<p className="font-medium text-slate-900">{faq.q}</p>
|
||||
<p className="text-sm text-slate-600 mt-1">{faq.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/infrastructure/ci-cd"
|
||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-slate-400" 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>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">CI/CD Scheduler</p>
|
||||
<p className="text-xs text-slate-500">Automatische Test-Planung konfigurieren</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/rag"
|
||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">RAG Management</p>
|
||||
<p className="text-xs text-slate-500">Erwartungshorizonte und Chunking verwalten</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import type { BQASMetrics, TrendData, TestRun } from '../types'
|
||||
import { MetricCard } from './MetricCard'
|
||||
import { TrendChart } from './TrendChart'
|
||||
import { TestSuiteCard } from './TestSuiteCard'
|
||||
|
||||
export function OverviewTab({
|
||||
goldenMetrics,
|
||||
syntheticMetrics,
|
||||
ragMetrics,
|
||||
trendData,
|
||||
testRuns,
|
||||
isRunningGolden,
|
||||
isRunningSynthetic,
|
||||
isRunningRag,
|
||||
runGoldenTests,
|
||||
runSyntheticTests,
|
||||
runRagTests,
|
||||
}: {
|
||||
goldenMetrics: BQASMetrics | null
|
||||
syntheticMetrics: BQASMetrics | null
|
||||
ragMetrics: BQASMetrics | null
|
||||
trendData: TrendData | null
|
||||
testRuns: TestRun[]
|
||||
isRunningGolden: boolean
|
||||
isRunningSynthetic: boolean
|
||||
isRunningRag: boolean
|
||||
runGoldenTests: () => void
|
||||
runSyntheticTests: () => void
|
||||
runRagTests: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Quick Help */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" 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>
|
||||
<div>
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Neu hier?</strong> Wechseln Sie zum Tab "Anleitung" fuer eine ausfuehrliche Erklaerung
|
||||
des BQAS-Systems und wie Sie es nutzen koennen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import type { 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 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunningRag
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{isRunningRag ? 'Laeuft...' : 'Tests starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ragMetrics ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div className="p-4 rounded-lg border bg-blue-50 border-blue-200">
|
||||
<h4 className="font-medium text-slate-900">EH Retrieval</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Korrektes Abrufen von Erwartungshorizont-Passagen</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-purple-50 border-purple-200">
|
||||
<h4 className="font-medium text-slate-900">Operator Alignment</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Passende Operatoren fuer Abitur-Aufgaben</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-red-50 border-red-200">
|
||||
<h4 className="font-medium text-slate-900">Hallucination Control</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Keine erfundenen Fakten oder Inhalte</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-green-50 border-green-200">
|
||||
<h4 className="font-medium text-slate-900">Citation Enforcement</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Quellenangaben bei EH-Bezuegen</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-amber-50 border-amber-200">
|
||||
<h4 className="font-medium text-slate-900">Privacy Compliance</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Keine PII-Leaks, DSGVO-Konformitaet</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-slate-50 border-slate-200">
|
||||
<h4 className="font-medium text-slate-900">Namespace Isolation</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Strikte Trennung zwischen Lehrern</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import type { 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 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunningSynthetic
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import type { 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-all ${
|
||||
isRunning
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { Toast } from '../useTestQuality'
|
||||
|
||||
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border animate-slide-in ${
|
||||
toast.type === 'success'
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||||
: toast.type === 'error'
|
||||
? 'bg-red-50 border-red-200 text-red-800'
|
||||
: toast.type === 'loading'
|
||||
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{toast.type === 'loading' ? (
|
||||
<svg className="animate-spin h-5 w-5" 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>
|
||||
) : toast.type === 'success' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : toast.type === 'error' ? (
|
||||
<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>
|
||||
) : (
|
||||
<svg className="w-5 h-5" 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>
|
||||
)}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
{toast.type !== 'loading' && (
|
||||
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
||||
<svg className="w-4 h-4" 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import type { 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">
|
||||
<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>
|
||||
|
||||
<div className="ml-10 h-full pr-4">
|
||||
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<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" />
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#14b8a6"
|
||||
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.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="#14b8a6" />
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { ToastContainer } from './ToastContainer'
|
||||
export { MetricCard } from './MetricCard'
|
||||
export { TestSuiteCard } from './TestSuiteCard'
|
||||
export { TrendChart } from './TrendChart'
|
||||
export { TestRunsTable } from './TestRunsTable'
|
||||
export { IntentScoresChart } from './IntentScoresChart'
|
||||
export { FailedTestsList } from './FailedTestsList'
|
||||
export { GuideTab } from './GuideTab'
|
||||
export { OverviewTab } from './OverviewTab'
|
||||
export { GoldenTab } from './GoldenTab'
|
||||
export { RagTab } from './RagTab'
|
||||
export { SyntheticTab } from './SyntheticTab'
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Constants and demo data for BQAS Dashboard
|
||||
*/
|
||||
|
||||
import type { BQASMetrics, TrendData, TestRun } from './types'
|
||||
|
||||
// API Configuration - Use internal proxy to avoid CORS issues
|
||||
export const BQAS_API_BASE = '/api/bqas'
|
||||
|
||||
// Demo data for when API is not available
|
||||
export const DEMO_GOLDEN_METRICS: BQASMetrics = {
|
||||
total_tests: 97,
|
||||
passed_tests: 89,
|
||||
failed_tests: 8,
|
||||
avg_intent_accuracy: 91.7,
|
||||
avg_faithfulness: 4.2,
|
||||
avg_relevance: 4.1,
|
||||
avg_coherence: 4.3,
|
||||
safety_pass_rate: 0.98,
|
||||
avg_composite_score: 4.15,
|
||||
scores_by_intent: {
|
||||
korrektur_anfrage: 4.5,
|
||||
erklaerung_anfrage: 4.3,
|
||||
hilfe_anfrage: 4.1,
|
||||
feedback_anfrage: 3.9,
|
||||
smalltalk: 4.2,
|
||||
},
|
||||
failed_test_ids: ['GT-023', 'GT-045', 'GT-067', 'GT-072', 'GT-081', 'GT-089', 'GT-092', 'GT-095'],
|
||||
}
|
||||
|
||||
export const DEMO_TREND: TrendData = {
|
||||
dates: ['2026-01-02', '2026-01-09', '2026-01-16', '2026-01-23', '2026-01-30'],
|
||||
scores: [3.9, 4.0, 4.1, 4.15, 4.15],
|
||||
trend: 'improving',
|
||||
}
|
||||
|
||||
export const DEMO_RUNS: TestRun[] = [
|
||||
{ id: 1, timestamp: '2026-01-30T07:00:00Z', git_commit: 'abc1234', golden_score: 4.15, synthetic_score: 3.9, total_tests: 97, passed_tests: 89, failed_tests: 8, duration_seconds: 45.2 },
|
||||
{ id: 2, timestamp: '2026-01-29T07:00:00Z', git_commit: 'def5678', golden_score: 4.12, synthetic_score: 3.85, total_tests: 97, passed_tests: 88, failed_tests: 9, duration_seconds: 44.8 },
|
||||
{ id: 3, timestamp: '2026-01-28T07:00:00Z', git_commit: '9ab0123', golden_score: 4.10, synthetic_score: 3.82, total_tests: 97, passed_tests: 87, failed_tests: 10, duration_seconds: 46.1 },
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Custom hook for BQAS Test Quality state and API logic
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { TestRun, BQASMetrics, TrendData, TabType } from './types'
|
||||
import {
|
||||
BQAS_API_BASE,
|
||||
DEMO_GOLDEN_METRICS,
|
||||
DEMO_TREND,
|
||||
DEMO_RUNS,
|
||||
} from './constants'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
type: 'success' | 'error' | 'info' | 'loading'
|
||||
message: string
|
||||
}
|
||||
|
||||
export function useTestQuality() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Toast state
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const toastIdRef = useRef(0)
|
||||
|
||||
const addToast = useCallback((type: Toast['type'], message: string) => {
|
||||
const id = ++toastIdRef.current
|
||||
console.log('Adding toast:', id, type, message)
|
||||
setToasts((prev) => [...prev, { id, type, message }])
|
||||
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const updateToast = useCallback((id: number, type: Toast['type'], message: string) => {
|
||||
console.log('Updating toast:', id, type, message)
|
||||
setToasts((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, type, message } : t))
|
||||
)
|
||||
if (type !== 'loading') {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Data states
|
||||
const [goldenMetrics, setGoldenMetrics] = useState<BQASMetrics | null>(null)
|
||||
const [syntheticMetrics, setSyntheticMetrics] = useState<BQASMetrics | null>(null)
|
||||
const [ragMetrics, setRagMetrics] = useState<BQASMetrics | null>(null)
|
||||
const [testRuns, setTestRuns] = useState<TestRun[]>([])
|
||||
const [trendData, setTrendData] = useState<TrendData | null>(null)
|
||||
|
||||
// Running states
|
||||
const [isRunningGolden, setIsRunningGolden] = useState(false)
|
||||
const [isRunningSynthetic, setIsRunningSynthetic] = useState(false)
|
||||
const [isRunningRag, setIsRunningRag] = useState(false)
|
||||
|
||||
// Fetch data
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const runsResponse = await fetch(`${BQAS_API_BASE}/runs`)
|
||||
if (runsResponse.ok) {
|
||||
const runsData = await runsResponse.json()
|
||||
if (runsData.runs && runsData.runs.length > 0) {
|
||||
setTestRuns(runsData.runs)
|
||||
} else {
|
||||
setTestRuns(DEMO_RUNS)
|
||||
}
|
||||
} else {
|
||||
setTestRuns(DEMO_RUNS)
|
||||
}
|
||||
|
||||
const trendResponse = await fetch(`${BQAS_API_BASE}/trend?days=30`)
|
||||
if (trendResponse.ok) {
|
||||
const trend = await trendResponse.json()
|
||||
if (trend.dates && trend.dates.length > 0) {
|
||||
setTrendData(trend)
|
||||
} else {
|
||||
setTrendData(DEMO_TREND)
|
||||
}
|
||||
} else {
|
||||
setTrendData(DEMO_TREND)
|
||||
}
|
||||
|
||||
const metricsResponse = await fetch(`${BQAS_API_BASE}/latest-metrics`)
|
||||
if (metricsResponse.ok) {
|
||||
const metrics = await metricsResponse.json()
|
||||
setGoldenMetrics(metrics.golden || DEMO_GOLDEN_METRICS)
|
||||
setSyntheticMetrics(metrics.synthetic || null)
|
||||
setRagMetrics(metrics.rag || null)
|
||||
} else {
|
||||
setGoldenMetrics(DEMO_GOLDEN_METRICS)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch BQAS data, using demo data:', err)
|
||||
setTestRuns(DEMO_RUNS)
|
||||
setTrendData(DEMO_TREND)
|
||||
setGoldenMetrics(DEMO_GOLDEN_METRICS)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Run test suites with toast feedback
|
||||
const runGoldenTests = async () => {
|
||||
setIsRunningGolden(true)
|
||||
const loadingToast = addToast('loading', 'Golden Suite wird ausgefuehrt...')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BQAS_API_BASE}/run/golden`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setGoldenMetrics(result.metrics)
|
||||
updateToast(loadingToast, 'success', `Golden Suite abgeschlossen: ${result.metrics?.passed_tests || 89}/${result.metrics?.total_tests || 97} bestanden`)
|
||||
await fetchData()
|
||||
} else {
|
||||
updateToast(loadingToast, 'info', 'Golden Suite: Demo-Modus (API nicht verfuegbar)')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to run golden tests:', err)
|
||||
updateToast(loadingToast, 'info', 'Golden Suite: Demo-Modus (API nicht verfuegbar)')
|
||||
} finally {
|
||||
setIsRunningGolden(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runSyntheticTests = async () => {
|
||||
setIsRunningSynthetic(true)
|
||||
const loadingToast = addToast('loading', 'Synthetic Tests werden generiert und ausgefuehrt...')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BQAS_API_BASE}/run/synthetic`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setSyntheticMetrics(result.metrics)
|
||||
updateToast(loadingToast, 'success', 'Synthetic Tests abgeschlossen')
|
||||
await fetchData()
|
||||
} else {
|
||||
updateToast(loadingToast, 'info', 'Synthetic Tests: Demo-Modus (API nicht verfuegbar)')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to run synthetic tests:', err)
|
||||
updateToast(loadingToast, 'info', 'Synthetic Tests: Demo-Modus (API nicht verfuegbar)')
|
||||
} finally {
|
||||
setIsRunningSynthetic(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runRagTests = async () => {
|
||||
setIsRunningRag(true)
|
||||
const loadingToast = addToast('loading', 'RAG/Korrektur Tests werden ausgefuehrt...')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BQAS_API_BASE}/run/rag`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setRagMetrics(result.metrics)
|
||||
updateToast(loadingToast, 'success', 'RAG Tests abgeschlossen')
|
||||
await fetchData()
|
||||
} else {
|
||||
updateToast(loadingToast, 'info', 'RAG Tests: Demo-Modus (API nicht verfuegbar)')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to run RAG tests:', err)
|
||||
updateToast(loadingToast, 'info', 'RAG Tests: Demo-Modus (API nicht verfuegbar)')
|
||||
} finally {
|
||||
setIsRunningRag(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
isLoading,
|
||||
error,
|
||||
toasts,
|
||||
removeToast,
|
||||
goldenMetrics,
|
||||
syntheticMetrics,
|
||||
ragMetrics,
|
||||
testRuns,
|
||||
trendData,
|
||||
isRunningGolden,
|
||||
isRunningSynthetic,
|
||||
isRunningRag,
|
||||
fetchData,
|
||||
runGoldenTests,
|
||||
runSyntheticTests,
|
||||
runRagTests,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user