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

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

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

View File

@@ -15,6 +15,15 @@
**/tests/test_rbac.py | owner=klausur | reason=RBAC Test-Matrix | review=2026-07-01
**/tests/test_grid_editor_api.py | owner=klausur | reason=Grid Editor Integrationstests | review=2026-07-01
# Pure Data Registries (keine Logik, nur Daten-Definitionen)
**/dsfa_sources_registry.py | owner=klausur | reason=Pure data registry (license + source definitions, no logic) | review=2027-01-01
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
# Two indivisible route handlers (~230 LOC each) that cannot be split further
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
# Legacy — TEMPORAER bis Refactoring abgeschlossen
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
# KEINE neuen Ausnahmen ohne [guardrail-change] Commit-Marker!

View File

@@ -199,7 +199,12 @@ GET /api/v1/ocr-pipeline/sessions/{id}/unified-grid
| Datei | Zeilen | Beschreibung |
|-------|--------|--------------|
| `grid_build_core.py` | 1943 | `_build_grid_core()`Haupt-Grid-Aufbau |
| `grid_build_core.py` | 213 | `_build_grid_core()`Orchestrator (ruft Phase-Module) |
| `grid_build_zones.py` | 462 | Phase 2: Bildverarbeitung, Grafik-/Box-Erkennung, Zonen |
| `grid_build_cleanup.py` | 390 | Phase 3: Junk-Zeilen, Artefakte, Pipes, Randstreifen |
| `grid_build_text_ops.py` | 489 | Phase 4+5a: Farben, Ueberschriften, IPA, Seitenreferenzen |
| `grid_build_cell_ops.py` | 305 | Phase 5b: Bullet-Entfernung, Wort-Reihenfolge, max_columns |
| `grid_build_finalize.py` | 452 | Phase 5c+6: Woerterbuch, Silben, Rechtschreibung, Ergebnis |
| `grid_editor_api.py` | 474 | REST-Endpoints (build, save, get, gutter, box, unified) |
| `grid_editor_helpers.py` | 1737 | Helper: Spalten, Rows, Cells, Colspan, Header |
| `smart_spell.py` | 587 | SmartSpellChecker |

View File

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

View File

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

View File

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

View File

@@ -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">&rarr;</div>}
</>
)
}

View File

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

View File

@@ -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">&lt; 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>
)
}

View File

@@ -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 &quot;Demo starten&quot; um eine simulierte Training-Session zu sehen
</div>
</div>
)}
</div>
)
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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'
}

View File

@@ -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,
}
}

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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 &middot; 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 &middot; 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>
)
}

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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: '🔄' },
]

View File

@@ -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',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &quot;Anleitung&quot; fuer eine ausfuehrliche Erklaerung
des BQAS-Systems und wie Sie es nutzen koennen.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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 &quot;Tests starten&quot; 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>
)
}

View File

@@ -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 &quot;Tests starten&quot; um Variationen zu generieren</p>
</div>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -0,0 +1,168 @@
'use client'
import type { DockerStats, ContainerInfo, ContainerFilter } from '../types'
import { getStateColor } from './helpers'
interface DeploymentsTabProps {
dockerStats: DockerStats | null
filteredContainers: ContainerInfo[]
containerFilter: ContainerFilter
setContainerFilter: (f: ContainerFilter) => void
actionLoading: string | null
containerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => Promise<void>
loadContainerData: () => Promise<void>
}
export function DeploymentsTab({
dockerStats,
filteredContainers,
containerFilter,
setContainerFilter,
actionLoading,
containerAction,
loadContainerData,
}: DeploymentsTabProps) {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-800">Docker Container</h3>
{dockerStats && (
<p className="text-sm text-slate-600">
{dockerStats.running_containers} laufend, {dockerStats.stopped_containers} gestoppt, {dockerStats.total_containers} gesamt
</p>
)}
</div>
<div className="flex items-center gap-2">
<select
value={containerFilter}
onChange={(e) => setContainerFilter(e.target.value as ContainerFilter)}
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg bg-white"
>
<option value="all">Alle</option>
<option value="running">Laufend</option>
<option value="stopped">Gestoppt</option>
</select>
<button
onClick={loadContainerData}
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
</div>
</div>
{/* Container List */}
{filteredContainers.length === 0 ? (
<div className="text-center py-8 text-slate-500">Keine Container gefunden</div>
) : (
<div className="space-y-3">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
actionLoading={actionLoading}
containerAction={containerAction}
/>
))}
</div>
)}
</div>
)
}
// ============================================================================
// Container Card Sub-component
// ============================================================================
function ContainerCard({
container,
actionLoading,
containerAction,
}: {
container: ContainerInfo
actionLoading: string | null
containerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => Promise<void>
}) {
return (
<div
className={`border rounded-xl p-4 transition-colors ${
container.state === 'running'
? 'border-green-200 bg-green-50/30'
: 'border-slate-200 bg-slate-50/50'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-slate-900 truncate">{container.name}</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStateColor(container.state)}`}>
{container.state}
</span>
</div>
<div className="text-sm text-slate-500 mb-2">
<span className="font-mono">{container.image}</span>
{container.ports.length > 0 && (
<span className="ml-2 text-slate-400">
| {container.ports.slice(0, 2).join(', ')}
{container.ports.length > 2 && ` +${container.ports.length - 2}`}
</span>
)}
</div>
{container.state === 'running' && (
<div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-1">
<span className="text-slate-500">CPU:</span>
<span className={`font-medium ${container.cpu_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
{container.cpu_percent.toFixed(1)}%
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-slate-500">RAM:</span>
<span className={`font-medium ${container.memory_percent > 80 ? 'text-red-600' : 'text-slate-700'}`}>
{container.memory_usage}
</span>
<span className="text-slate-400">({container.memory_percent.toFixed(1)}%)</span>
</div>
<div className="flex items-center gap-1">
<span className="text-slate-500">Net:</span>
<span className="text-slate-700">{container.network_rx} / {container.network_tx}</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{container.state === 'running' ? (
<>
<button
onClick={() => containerAction(container.id, 'restart')}
disabled={actionLoading !== null}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{actionLoading === `${container.id}-restart` ? '...' : 'Restart'}
</button>
<button
onClick={() => containerAction(container.id, 'stop')}
disabled={actionLoading !== null}
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
>
{actionLoading === `${container.id}-stop` ? '...' : 'Stop'}
</button>
</>
) : (
<button
onClick={() => containerAction(container.id, 'start')}
disabled={actionLoading !== null}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{actionLoading === `${container.id}-start` ? '...' : 'Start'}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import type { PipelineStatus, PipelineRun, SystemStats, DockerStats, WoodpeckerStatus, TabType } from '../types'
import { ProgressBar } from './helpers'
interface OverviewTabProps {
pipelineStatus: PipelineStatus | null
pipelineHistory: PipelineRun[]
systemStats: SystemStats | null
dockerStats: DockerStats | null
woodpeckerStatus: WoodpeckerStatus | null
triggeringWoodpecker: boolean
triggerWoodpeckerPipeline: () => Promise<void>
setActiveTab: (tab: TabType) => void
}
export function OverviewTab({
pipelineStatus,
pipelineHistory,
systemStats,
dockerStats,
woodpeckerStatus,
triggeringWoodpecker,
triggerWoodpeckerPipeline,
setActiveTab,
}: OverviewTabProps) {
return (
<div className="space-y-6">
{/* Woodpecker CI Status - Prominent */}
<WoodpeckerOverviewCard
woodpeckerStatus={woodpeckerStatus}
triggeringWoodpecker={triggeringWoodpecker}
triggerWoodpeckerPipeline={triggerWoodpeckerPipeline}
setActiveTab={setActiveTab}
/>
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className={`p-4 rounded-lg ${pipelineStatus?.gitea_connected ? 'bg-green-50' : 'bg-yellow-50'}`}>
<div className="flex items-center gap-2 mb-2">
<span className={`w-3 h-3 rounded-full ${pipelineStatus?.gitea_connected ? 'bg-green-500' : 'bg-yellow-500'}`}></span>
<span className="text-sm font-medium">Gitea Status</span>
</div>
<p className={`text-lg font-bold ${pipelineStatus?.gitea_connected ? 'text-green-700' : 'text-yellow-700'}`}>
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Nicht verbunden'}
</p>
<p className="text-xs text-slate-500">http://macmini:3003</p>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm font-medium">Pipeline Runs</span>
</div>
<p className="text-lg font-bold text-blue-700">{pipelineStatus?.total_runs || 0}</p>
<p className="text-xs text-slate-500">{pipelineStatus?.successful_runs || 0} erfolgreich</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
<span className="text-sm font-medium">Container</span>
</div>
<p className="text-lg font-bold text-purple-700">{dockerStats?.running_containers || 0}</p>
<p className="text-xs text-slate-500">von {dockerStats?.total_containers || 0} laufend</p>
</div>
<div className="bg-slate-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium">Letztes Update</span>
</div>
<p className="text-lg font-bold text-slate-700">
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleDateString('de-DE') : 'Nie'}
</p>
<p className="text-xs text-slate-500">
{pipelineStatus?.last_sbom_update ? new Date(pipelineStatus.last_sbom_update).toLocaleTimeString('de-DE') : '-'}
</p>
</div>
</div>
{/* System Resources */}
{systemStats && (
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="font-medium text-slate-800 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Server Ressourcen ({systemStats.hostname})
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg p-3">
<div className="flex justify-between mb-2">
<span className="text-sm text-slate-600">CPU</span>
<span className={`font-bold ${systemStats.cpu.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
{systemStats.cpu.usage_percent.toFixed(1)}%
</span>
</div>
<ProgressBar percent={systemStats.cpu.usage_percent} />
</div>
<div className="bg-white rounded-lg p-3">
<div className="flex justify-between mb-2">
<span className="text-sm text-slate-600">RAM</span>
<span className={`font-bold ${systemStats.memory.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
{systemStats.memory.usage_percent.toFixed(1)}%
</span>
</div>
<ProgressBar percent={systemStats.memory.usage_percent} color="purple" />
</div>
<div className="bg-white rounded-lg p-3">
<div className="flex justify-between mb-2">
<span className="text-sm text-slate-600">Disk</span>
<span className={`font-bold ${systemStats.disk.usage_percent > 80 ? 'text-red-600' : 'text-slate-900'}`}>
{systemStats.disk.usage_percent.toFixed(1)}%
</span>
</div>
<ProgressBar percent={systemStats.disk.usage_percent} color="green" />
</div>
</div>
</div>
)}
{/* Recent Pipeline Runs */}
{pipelineHistory.length > 0 && (
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="font-medium text-slate-800 mb-3">Letzte Pipeline Runs</h3>
<div className="space-y-2">
{pipelineHistory.slice(0, 5).map((run) => (
<div key={run.id} className="flex items-center justify-between bg-white p-3 rounded-lg">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${
run.status === 'success' ? 'bg-green-500' :
run.status === 'failed' ? 'bg-red-500' :
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
}`}></span>
<div>
<p className="text-sm font-medium text-slate-800">{run.workflow || 'SBOM Pipeline'}</p>
<p className="text-xs text-slate-500">{run.branch} - {run.commit_sha.substring(0, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
run.status === 'success' ? 'text-green-600' :
run.status === 'failed' ? 'text-red-600' :
run.status === 'running' ? 'text-yellow-600' : 'text-slate-600'
}`}>
{run.status === 'success' ? 'Erfolgreich' :
run.status === 'failed' ? 'Fehlgeschlagen' :
run.status === 'running' ? 'Laeuft...' : run.status}
</p>
<p className="text-xs text-slate-500">
{new Date(run.started_at).toLocaleString('de-DE')}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// ============================================================================
// Woodpecker Overview Card (sub-component)
// ============================================================================
function WoodpeckerOverviewCard({
woodpeckerStatus,
triggeringWoodpecker,
triggerWoodpeckerPipeline,
setActiveTab,
}: {
woodpeckerStatus: WoodpeckerStatus | null
triggeringWoodpecker: boolean
triggerWoodpeckerPipeline: () => Promise<void>
setActiveTab: (tab: TabType) => void
}) {
const latestPipeline = woodpeckerStatus?.pipelines?.[0]
const isOnline = woodpeckerStatus?.status === 'online'
const latestStatus = latestPipeline?.status
const borderClass = isOnline
? latestStatus === 'success'
? 'border-green-300 bg-green-50'
: latestStatus === 'failure' || latestStatus === 'error'
? 'border-red-300 bg-red-50'
: latestStatus === 'running'
? 'border-blue-300 bg-blue-50'
: 'border-slate-300 bg-slate-50'
: 'border-red-300 bg-red-50'
const iconBgClass = isOnline
? latestStatus === 'success'
? 'bg-green-100'
: latestStatus === 'failure' || latestStatus === 'error'
? 'bg-red-100'
: 'bg-blue-100'
: 'bg-red-100'
return (
<div className={`p-4 rounded-xl border-2 ${borderClass}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-lg ${iconBgClass}`}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-900">Woodpecker CI</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
isOnline ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{isOnline ? 'Online' : 'Offline'}
</span>
</div>
{latestPipeline && (
<p className="text-sm text-slate-600 mt-1">
Pipeline #{latestPipeline.number}: {' '}
<span className={`font-medium ${
latestStatus === 'success' ? 'text-green-600' :
latestStatus === 'failure' || latestStatus === 'error' ? 'text-red-600' :
latestStatus === 'running' ? 'text-blue-600' : 'text-slate-600'
}`}>
{latestStatus}
</span>
{' '}auf {latestPipeline.branch}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setActiveTab('woodpecker')}
className="px-3 py-1.5 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-white"
>
Details
</button>
<button
onClick={triggerWoodpeckerPipeline}
disabled={triggeringWoodpecker}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{triggeringWoodpecker ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
) : (
<svg className="w-3 h-3" 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" />
</svg>
)}
Starten
</button>
</div>
</div>
{/* Failed steps preview */}
{latestPipeline?.steps?.some(s => s.state === 'failure') && (
<div className="mt-3 pt-3 border-t border-red-200">
<p className="text-xs font-medium text-red-700 mb-2">Fehlgeschlagene Steps:</p>
<div className="flex flex-wrap gap-2">
{latestPipeline.steps.filter(s => s.state === 'failure').map((step, i) => (
<span key={i} className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
{step.name}
</span>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import type { PipelineRun } from '../types'
interface PipelinesTabProps {
pipelineHistory: PipelineRun[]
triggeringPipeline: boolean
triggerPipeline: () => Promise<void>
}
export function PipelinesTab({
pipelineHistory,
triggeringPipeline,
triggerPipeline,
}: PipelinesTabProps) {
return (
<div className="space-y-6">
{/* Pipeline Controls */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-800">Gitea Actions Pipelines</h3>
<p className="text-sm text-slate-600">Workflows werden bei Push auf main/develop automatisch ausgefuehrt</p>
</div>
<button
onClick={triggerPipeline}
disabled={triggeringPipeline}
className="px-4 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
{triggeringPipeline ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Laeuft...
</>
) : (
<>
<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>
Pipeline starten
</>
)}
</button>
</div>
{/* Available Pipelines */}
<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="flex items-center gap-2 mb-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="font-medium text-green-800">SBOM Pipeline</span>
</div>
<p className="text-sm text-green-700 mb-2">Generiert Software Bill of Materials</p>
<p className="text-xs text-green-600">5 Jobs: generate, scan, license, upload, summary</p>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
<div className="flex items-center gap-2 mb-2">
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
<span className="font-medium text-slate-600">Test Pipeline</span>
</div>
<p className="text-sm text-slate-500 mb-2">Unit & Integration Tests</p>
<p className="text-xs text-slate-400">Geplant</p>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 opacity-60">
<div className="flex items-center gap-2 mb-2">
<span className="w-2 h-2 rounded-full bg-slate-400"></span>
<span className="font-medium text-slate-600">Security Pipeline</span>
</div>
<p className="text-sm text-slate-500 mb-2">SAST, SCA, Secrets Scan</p>
<p className="text-xs text-slate-400">Geplant</p>
</div>
</div>
{/* Pipeline History */}
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
{pipelineHistory.length === 0 ? (
<div className="text-center py-8 text-slate-500">
Keine Pipeline-Runs vorhanden. Starten Sie die erste Pipeline!
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Status</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Workflow</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Branch</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Commit</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Gestartet</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-slate-500 uppercase">Dauer</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{pipelineHistory.map((run) => (
<tr key={run.id} className="hover:bg-white">
<td className="py-2 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
run.status === 'success' ? 'bg-green-100 text-green-800' :
run.status === 'failed' ? 'bg-red-100 text-red-800' :
run.status === 'running' ? 'bg-yellow-100 text-yellow-800' : 'bg-slate-100 text-slate-600'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
run.status === 'success' ? 'bg-green-500' :
run.status === 'failed' ? 'bg-red-500' :
run.status === 'running' ? 'bg-yellow-500 animate-pulse' : 'bg-slate-400'
}`}></span>
{run.status}
</span>
</td>
<td className="py-2 px-3 text-sm text-slate-900">{run.workflow || 'SBOM Pipeline'}</td>
<td className="py-2 px-3 text-sm text-slate-600">{run.branch}</td>
<td className="py-2 px-3 text-sm font-mono text-slate-500">{run.commit_sha.substring(0, 8)}</td>
<td className="py-2 px-3 text-sm text-slate-500">{new Date(run.started_at).toLocaleString('de-DE')}</td>
<td className="py-2 px-3 text-sm text-slate-500">
{run.duration_seconds ? `${run.duration_seconds}s` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Pipeline Architecture */}
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-medium text-slate-800 mb-3">SBOM Pipeline Architektur</h4>
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
{`Gitea Actions Pipeline (.gitea/workflows/sbom.yaml)
|
+-- 1. generate-sbom -> Syft generiert CycloneDX SBOM
|
+-- 2. vulnerability-scan -> Grype scannt auf CVEs
|
+-- 3. license-check -> Prueft GPL/AGPL Lizenzen
|
+-- 4. upload-dashboard -> POST /api/v1/security/sbom/upload
|
+-- 5. summary -> Job Summary generieren`}
</pre>
</div>
</div>
)
}

View File

@@ -0,0 +1,286 @@
'use client'
export function SchedulerTab() {
return (
<div className="space-y-6">
{/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatusCard
icon={<ClockIcon />}
title="launchd Job"
description="Taeglich um 07:00 Uhr automatisch"
/>
<StatusCard
icon={<TerminalIcon />}
title="Git Hook"
description="Quick Tests bei voice-service Aenderungen"
/>
<StatusCard
icon={<BellIcon />}
title="Benachrichtigungen"
description="Desktop-Alerts bei Fehlern aktiviert"
/>
</div>
{/* Quick Actions */}
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="font-medium text-slate-800 mb-4">Quick Actions (BQAS)</h3>
<div className="flex flex-wrap gap-3">
<a
href="/ai/test-quality"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<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>
Test Dashboard oeffnen
</a>
<span className="text-sm text-slate-500 self-center">
Starte Tests direkt im BQAS Dashboard
</span>
</div>
</div>
{/* GitHub Actions vs Local - Comparison */}
<ComparisonTable />
{/* Configuration Details */}
<ConfigurationDetails />
{/* Detailed Explanation */}
<DetailedExplanation />
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
function StatusCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
return (
<div className="rounded-xl border p-5 bg-emerald-100 border-emerald-200 text-emerald-700">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
{icon}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold">{title}</h4>
<span className="w-2 h-2 rounded-full bg-emerald-500" />
</div>
<p className="text-sm mt-1 opacity-80">{description}</p>
</div>
</div>
</div>
)
}
function ComparisonTable() {
return (
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="font-medium text-slate-800 mb-4">GitHub Actions Alternative</h3>
<p className="text-slate-600 mb-4">
Der lokale BQAS Scheduler ersetzt GitHub Actions und bietet DSGVO-konforme, vollstaendig lokale Test-Ausfuehrung.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 bg-white">
<th className="text-left py-3 px-4 font-medium text-slate-700">Feature</th>
<th className="text-center py-3 px-4 font-medium text-slate-700">GitHub Actions</th>
<th className="text-center py-3 px-4 font-medium text-slate-700">Lokaler Scheduler</th>
</tr>
</thead>
<tbody>
<ComparisonRow
feature="Taegliche Tests (07:00)"
github={<span className="text-slate-600">schedule: cron</span>}
local={<Badge color="emerald">macOS launchd</Badge>}
/>
<ComparisonRow
feature="Push-basierte Tests"
github={<span className="text-slate-600">on: push</span>}
local={<Badge color="emerald">Git post-commit Hook</Badge>}
/>
<ComparisonRow
feature="PR-basierte Tests"
github={<Badge color="emerald">on: pull_request</Badge>}
local={<Badge color="amber">Nicht moeglich</Badge>}
/>
<ComparisonRow
feature="DSGVO-Konformitaet"
github={<Badge color="amber">Daten bei GitHub (US)</Badge>}
local={<Badge color="emerald">100% lokal</Badge>}
/>
<ComparisonRow
feature="Offline-Faehig"
github={<Badge color="red">Nein</Badge>}
local={<Badge color="emerald">Ja</Badge>}
isLast
/>
</tbody>
</table>
</div>
</div>
)
}
function ComparisonRow({
feature,
github,
local,
isLast = false,
}: {
feature: string
github: React.ReactNode
local: React.ReactNode
isLast?: boolean
}) {
return (
<tr className={isLast ? '' : 'border-b border-slate-100'}>
<td className="py-3 px-4 text-slate-600">{feature}</td>
<td className="py-3 px-4 text-center">{github}</td>
<td className="py-3 px-4 text-center">{local}</td>
</tr>
)
}
function Badge({ color, children }: { color: 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
const colorClasses = {
emerald: 'bg-emerald-100 text-emerald-700',
amber: 'bg-amber-100 text-amber-700',
red: 'bg-red-100 text-red-700',
}
return (
<span className={`px-2 py-1 rounded text-xs font-medium ${colorClasses[color]}`}>
{children}
</span>
)
}
function ConfigurationDetails() {
return (
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="font-medium text-slate-800 mb-4">Konfiguration</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* launchd Configuration */}
<div>
<h4 className="font-medium text-slate-700 mb-3">launchd Job</h4>
<div className="bg-slate-900 rounded-lg p-4 font-mono text-sm text-slate-100 overflow-x-auto">
<pre>{`# ~/Library/LaunchAgents/com.breakpilot.bqas.plist
Label: com.breakpilot.bqas
Schedule: 07:00 taeglich
Script: /voice-service/scripts/run_bqas.sh
Logs: /var/log/bqas/`}</pre>
</div>
</div>
{/* Environment Variables */}
<div>
<h4 className="font-medium text-slate-700 mb-3">Umgebungsvariablen</h4>
<div className="space-y-2 text-sm">
<EnvVar name="BQAS_SERVICE_URL" value="http://localhost:8091" />
<EnvVar name="BQAS_REGRESSION_THRESHOLD" value="0.1" />
<EnvVar name="BQAS_NOTIFY_DESKTOP" value="true" isActive />
<EnvVar name="BQAS_NOTIFY_SLACK" value="false" />
</div>
</div>
</div>
</div>
)
}
function EnvVar({ name, value, isActive }: { name: string; value: string; isActive?: boolean }) {
return (
<div className="flex justify-between p-2 bg-white rounded">
<span className="font-mono text-slate-600">{name}</span>
<span className={isActive ? 'text-emerald-600 font-medium' : value === 'false' ? 'text-slate-400' : 'text-slate-900'}>
{value}
</span>
</div>
)
}
function DetailedExplanation() {
return (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Detaillierte Erklaerung
</h3>
<div className="prose prose-sm max-w-none text-slate-700">
<h4 className="text-base font-semibold mt-4 mb-2">Warum ein lokaler Scheduler?</h4>
<p className="mb-4">
Der lokale BQAS Scheduler wurde entwickelt, um die gleiche Funktionalitaet wie GitHub Actions zu bieten,
aber mit dem entscheidenden Vorteil, dass <strong>alle Daten zu 100% auf dem lokalen Mac Mini verbleiben</strong>.
Dies ist besonders wichtig fuer DSGVO-Konformitaet, da keine Schuelerdaten oder Testergebnisse an externe Server uebertragen werden.
</p>
<h4 className="text-base font-semibold mt-4 mb-2">Komponenten</h4>
<ul className="list-disc list-inside space-y-2 mb-4">
<li>
<strong>run_bqas.sh</strong> - Hauptscript das pytest ausfuehrt, Regression-Checks macht und Benachrichtigungen versendet
</li>
<li>
<strong>launchd Job</strong> - macOS-nativer Scheduler der das Script taeglich um 07:00 Uhr startet
</li>
<li>
<strong>Git Hook</strong> - post-commit Hook der bei Aenderungen im voice-service automatisch Quick-Tests startet
</li>
<li>
<strong>Notifier</strong> - Python-Modul das Desktop-, Slack- und E-Mail-Benachrichtigungen versendet
</li>
</ul>
<h4 className="text-base font-semibold mt-4 mb-2">Installation</h4>
<div className="bg-slate-900 rounded-lg p-3 font-mono text-sm text-slate-100 mb-4">
<code>./voice-service/scripts/install_bqas_scheduler.sh install</code>
</div>
<h4 className="text-base font-semibold mt-4 mb-2">Vorteile gegenueber GitHub Actions</h4>
<ul className="list-disc list-inside space-y-1">
<li>100% DSGVO-konform - alle Daten bleiben lokal</li>
<li>Keine Internet-Abhaengigkeit - funktioniert auch offline</li>
<li>Keine GitHub-Kosten fuer private Repositories</li>
<li>Schnellere Ausfuehrung ohne Cloud-Overhead</li>
<li>Volle Kontrolle ueber Scheduling und Benachrichtigungen</li>
</ul>
</div>
</div>
)
}
// ============================================================================
// SVG Icons
// ============================================================================
function ClockIcon() {
return (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
function TerminalIcon() {
return (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)
}
function BellIcon() {
return (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import type { PipelineStatus } from '../types'
interface SetupTabProps {
pipelineStatus: PipelineStatus | null
}
export function SetupTab({ pipelineStatus }: SetupTabProps) {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Erstkonfiguration - Gitea CI/CD</h3>
<p className="text-slate-600">
Anleitung zur Einrichtung der CI/CD Pipeline mit Gitea Actions auf dem Mac Mini Server.
</p>
</div>
{/* Gitea Server Info */}
<div className="bg-blue-50 p-4 rounded-lg">
<h4 className="font-medium text-blue-800 mb-3 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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
Gitea Server
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-3 rounded-lg">
<p className="text-sm text-slate-500">Web-URL</p>
<p className="font-mono text-blue-700">http://macmini:3003</p>
</div>
<div className="bg-white p-3 rounded-lg">
<p className="text-sm text-slate-500">SSH</p>
<p className="font-mono text-blue-700">macmini:2222</p>
</div>
<div className="bg-white p-3 rounded-lg">
<p className="text-sm text-slate-500">Status</p>
<p className={`font-medium ${pipelineStatus?.gitea_connected ? 'text-green-600' : 'text-yellow-600'}`}>
{pipelineStatus?.gitea_connected ? 'Verbunden' : 'Konfiguration erforderlich'}
</p>
</div>
</div>
</div>
{/* Implementierte Komponenten */}
<div className="bg-slate-50 p-4 rounded-lg">
<h4 className="font-medium text-slate-800 mb-3">Implementierte Komponenten</h4>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 font-medium text-slate-600">Komponente</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Pfad</th>
<th className="text-left py-2 px-3 font-medium text-slate-600">Beschreibung</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tr>
<td className="py-2 px-3 font-medium">Gitea Service</td>
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
<td className="py-2 px-3 text-slate-600">Gitea 1.22 mit Actions enabled</td>
</tr>
<tr>
<td className="py-2 px-3 font-medium">Gitea Runner</td>
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">docker-compose.yml</code></td>
<td className="py-2 px-3 text-slate-600">act_runner fuer Job-Ausfuehrung</td>
</tr>
<tr>
<td className="py-2 px-3 font-medium">SBOM Workflow</td>
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">.gitea/workflows/sbom.yaml</code></td>
<td className="py-2 px-3 text-slate-600">5 Jobs: generate, scan, license, upload, summary</td>
</tr>
<tr>
<td className="py-2 px-3 font-medium">Backend API</td>
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">backend/security_api.py</code></td>
<td className="py-2 px-3 text-slate-600">SBOM Upload, Pipeline Status, History</td>
</tr>
<tr>
<td className="py-2 px-3 font-medium">Runner Config</td>
<td className="py-2 px-3"><code className="bg-slate-200 px-1 rounded text-xs">gitea/runner-config.yaml</code></td>
<td className="py-2 px-3 text-slate-600">Labels: ubuntu-latest, self-hosted</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Setup Steps */}
<div className="bg-orange-50 p-4 rounded-lg">
<h4 className="font-medium text-orange-800 mb-3 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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
Setup-Schritte
</h4>
<div className="space-y-3">
<div className="bg-white p-3 rounded-lg">
<h5 className="font-medium text-slate-800 mb-1">1. Gitea oeffnen</h5>
<code className="text-sm bg-slate-100 px-2 py-1 rounded">http://macmini:3003</code>
</div>
<div className="bg-white p-3 rounded-lg">
<h5 className="font-medium text-slate-800 mb-1">2. Admin-Account erstellen</h5>
<p className="text-sm text-slate-600">Username: admin, Email: admin@breakpilot.de</p>
</div>
<div className="bg-white p-3 rounded-lg">
<h5 className="font-medium text-slate-800 mb-1">3. Repository erstellen</h5>
<p className="text-sm text-slate-600">Name: breakpilot-pwa, Visibility: Private</p>
</div>
<div className="bg-white p-3 rounded-lg">
<h5 className="font-medium text-slate-800 mb-1">4. Actions aktivieren</h5>
<p className="text-sm text-slate-600">Repository Settings &rarr; Actions &rarr; Enable Repository Actions</p>
</div>
<div className="bg-white p-3 rounded-lg">
<h5 className="font-medium text-slate-800 mb-1">5. Runner Token erstellen & starten</h5>
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
{`export GITEA_RUNNER_TOKEN=<token>
docker compose up -d gitea-runner`}
</pre>
</div>
<div className="bg-white p-3 rounded-lg">
<h5 className="font-medium text-slate-800 mb-1">6. Repository pushen</h5>
<pre className="text-xs bg-slate-100 p-2 rounded mt-1 overflow-x-auto">
{`git remote add gitea http://macmini:3003/admin/breakpilot-pwa.git
git push gitea main`}
</pre>
</div>
</div>
</div>
{/* Quick Links */}
<div className="bg-purple-50 p-4 rounded-lg">
<h4 className="font-medium text-purple-800 mb-3">Quick Links</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<a
href="http://macmini:3003"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors"
>
<div>
<p className="font-medium text-purple-800">Gitea</p>
<p className="text-xs text-slate-500">Git Server & CI/CD</p>
</div>
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
href="http://macmini:3003/admin/breakpilot-pwa/actions"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between bg-white p-3 rounded-lg hover:bg-purple-100 transition-colors"
>
<div>
<p className="font-medium text-purple-800">Pipeline Actions</p>
<p className="text-xs text-slate-500">Workflow Runs</p>
</div>
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,325 @@
'use client'
import type { WoodpeckerStatus, WoodpeckerPipeline } from '../types'
interface WoodpeckerTabProps {
woodpeckerStatus: WoodpeckerStatus | null
triggeringWoodpecker: boolean
triggerWoodpeckerPipeline: () => Promise<void>
}
export function WoodpeckerTab({
woodpeckerStatus,
triggeringWoodpecker,
triggerWoodpeckerPipeline,
}: WoodpeckerTabProps) {
return (
<div className="space-y-6">
{/* Woodpecker Status Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-slate-800">Woodpecker CI Pipeline</h3>
<span className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
woodpeckerStatus?.status === 'online'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
<span className={`w-2 h-2 rounded-full ${
woodpeckerStatus?.status === 'online' ? 'bg-green-500' : 'bg-red-500'
}`} />
{woodpeckerStatus?.status === 'online' ? 'Online' : 'Offline'}
</span>
</div>
<div className="flex items-center gap-2">
<a
href="http://macmini:8090"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 text-sm border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Woodpecker UI
</a>
<button
onClick={triggerWoodpeckerPipeline}
disabled={triggeringWoodpecker}
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center gap-2"
>
{triggeringWoodpecker ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Startet...
</>
) : (
<>
<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>
Pipeline starten
</>
)}
</button>
</div>
</div>
{/* Pipeline Stats */}
<WoodpeckerStats pipelines={woodpeckerStatus?.pipelines || []} />
{/* Pipeline List */}
{woodpeckerStatus?.pipelines && woodpeckerStatus.pipelines.length > 0 ? (
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-medium text-slate-800 mb-4">Pipeline Historie</h4>
<div className="space-y-3">
{woodpeckerStatus.pipelines.map((pipeline) => (
<PipelineCard key={pipeline.id} pipeline={pipeline} />
))}
</div>
</div>
) : (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p className="text-slate-500">Keine Pipelines gefunden</p>
<p className="text-sm text-slate-400 mt-1">Starte eine neue Pipeline oder pruefe die Woodpecker-Konfiguration</p>
</div>
)}
{/* Pipeline Configuration Info */}
<div className="bg-slate-50 rounded-lg p-4">
<h4 className="font-medium text-slate-800 mb-3">Pipeline Konfiguration</h4>
<pre className="bg-slate-800 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm">
{`Woodpecker CI Pipeline (.woodpecker/main.yml)
|
+-- 1. go-lint -> Go Linting (PR only)
+-- 2. python-lint -> Python Linting (PR only)
+-- 3. secrets-scan -> GitLeaks Secrets Scan
|
+-- 4. test-go-consent -> Go Unit Tests
+-- 5. test-go-billing -> Billing Service Tests
+-- 6. test-go-school -> School Service Tests
+-- 7. test-python -> Python Backend Tests
|
+-- 8. build-images -> Docker Image Build
+-- 9. generate-sbom -> SBOM Generation (Syft)
+-- 10. vuln-scan -> Vulnerability Scan (Grype)
+-- 11. container-scan -> Container Scan (Trivy)
|
+-- 12. sign-images -> Cosign Image Signing
+-- 13. attest-sbom -> SBOM Attestation
+-- 14. provenance -> SLSA Provenance
|
+-- 15. deploy-prod -> Production Deployment`}
</pre>
</div>
{/* Workflow Anleitung */}
<WorkflowGuide />
</div>
)
}
// ============================================================================
// Sub-components
// ============================================================================
function WoodpeckerStats({ pipelines }: { pipelines: WoodpeckerPipeline[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="text-sm font-medium">Gesamt</span>
</div>
<p className="text-2xl font-bold text-blue-700">{pipelines.length}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium">Erfolgreich</span>
</div>
<p className="text-2xl font-bold text-green-700">
{pipelines.filter(p => p.status === 'success').length}
</p>
</div>
<div className="bg-red-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-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="text-sm font-medium">Fehlgeschlagen</span>
</div>
<p className="text-2xl font-bold text-red-700">
{pipelines.filter(p => p.status === 'failure' || p.status === 'error').length}
</p>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium">Laufend</span>
</div>
<p className="text-2xl font-bold text-yellow-700">
{pipelines.filter(p => p.status === 'running' || p.status === 'pending').length}
</p>
</div>
</div>
)
}
function PipelineCard({ pipeline }: { pipeline: WoodpeckerPipeline }) {
const borderClass =
pipeline.status === 'success'
? 'border-green-200 bg-green-50/30'
: pipeline.status === 'failure' || pipeline.status === 'error'
? 'border-red-200 bg-red-50/30'
: pipeline.status === 'running'
? 'border-blue-200 bg-blue-50/30'
: 'border-slate-200 bg-white'
return (
<div className={`border rounded-xl p-4 transition-colors ${borderClass}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`w-3 h-3 rounded-full ${
pipeline.status === 'success' ? 'bg-green-500' :
pipeline.status === 'failure' || pipeline.status === 'error' ? 'bg-red-500' :
pipeline.status === 'running' ? 'bg-blue-500 animate-pulse' : 'bg-slate-400'
}`} />
<span className="font-semibold text-slate-900">Pipeline #{pipeline.number}</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
pipeline.status === 'success' ? 'bg-green-100 text-green-800' :
pipeline.status === 'failure' || pipeline.status === 'error' ? 'bg-red-100 text-red-800' :
pipeline.status === 'running' ? 'bg-blue-100 text-blue-800' :
'bg-slate-100 text-slate-600'
}`}>
{pipeline.status}
</span>
</div>
<div className="text-sm text-slate-600 mb-2">
<span className="font-mono">{pipeline.branch}</span>
<span className="mx-2 text-slate-400"></span>
<span className="font-mono text-slate-500">{pipeline.commit}</span>
<span className="mx-2 text-slate-400"></span>
<span>{pipeline.event}</span>
</div>
{pipeline.message && (
<p className="text-sm text-slate-500 mb-2 truncate max-w-xl">{pipeline.message}</p>
)}
{/* Steps Progress */}
{pipeline.steps && pipeline.steps.length > 0 && (
<div className="mt-3">
<div className="flex gap-1 mb-2">
{pipeline.steps.map((step, i) => (
<div
key={i}
className={`h-2 flex-1 rounded-full ${
step.state === 'success' ? 'bg-green-500' :
step.state === 'failure' ? 'bg-red-500' :
step.state === 'running' ? 'bg-blue-500 animate-pulse' :
step.state === 'skipped' ? 'bg-slate-200' : 'bg-slate-300'
}`}
title={`${step.name}: ${step.state}`}
/>
))}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{pipeline.steps.map((step, i) => (
<span
key={i}
className={`px-2 py-1 rounded ${
step.state === 'success' ? 'bg-green-100 text-green-700' :
step.state === 'failure' ? 'bg-red-100 text-red-700' :
step.state === 'running' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-600'
}`}
>
{step.name}
</span>
))}
</div>
</div>
)}
{/* Errors */}
{pipeline.errors && pipeline.errors.length > 0 && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<h5 className="text-sm font-medium text-red-800 mb-1">Fehler:</h5>
<ul className="text-xs text-red-700 space-y-1">
{pipeline.errors.map((err, i) => (
<li key={i} className="font-mono">{err}</li>
))}
</ul>
</div>
)}
</div>
<div className="text-right text-sm text-slate-500">
<p>{new Date(pipeline.created * 1000).toLocaleDateString('de-DE')}</p>
<p className="text-xs">{new Date(pipeline.created * 1000).toLocaleTimeString('de-DE')}</p>
{pipeline.started && pipeline.finished && (
<p className="text-xs mt-1">
Dauer: {Math.round((pipeline.finished - pipeline.started) / 60)}m
</p>
)}
</div>
</div>
</div>
)
}
function WorkflowGuide() {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-800 mb-3 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Workflow-Anleitung
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<h5 className="font-medium text-blue-700 mb-2">Automatisch (bei jedem Push/PR):</h5>
<ul className="space-y-1 text-blue-600">
<li>- <strong>Linting</strong> - Code-Qualitaet pruefen (nur PRs)</li>
<li>- <strong>Unit Tests</strong> - Go & Python Tests</li>
<li>- <strong>Test-Dashboard</strong> - Ergebnisse werden gesendet</li>
<li>- <strong>Backlog</strong> - Fehlgeschlagene Tests werden erfasst</li>
</ul>
</div>
<div>
<h5 className="font-medium text-blue-700 mb-2">Manuell (Button oder Tag):</h5>
<ul className="space-y-1 text-blue-600">
<li>- <strong>Docker Builds</strong> - Container erstellen</li>
<li>- <strong>SBOM/Scans</strong> - Sicherheitsanalyse</li>
<li>- <strong>Deployment</strong> - In Produktion deployen</li>
<li>- <strong>Pipeline starten</strong> - Diesen Button verwenden</li>
</ul>
</div>
</div>
<div className="mt-4 pt-3 border-t border-blue-200">
<h5 className="font-medium text-blue-700 mb-2">Setup: API Token konfigurieren</h5>
<p className="text-blue-600 text-sm">
Um Pipelines ueber das Dashboard zu starten, muss ein <strong>WOODPECKER_TOKEN</strong> konfiguriert werden:
</p>
<ol className="mt-2 space-y-1 text-blue-600 text-sm list-decimal list-inside">
<li>Woodpecker UI oeffnen: <a href="http://macmini:8090" target="_blank" rel="noopener noreferrer" className="underline hover:text-blue-800">http://macmini:8090</a></li>
<li>Mit Gitea-Account einloggen</li>
<li>Klick auf Profil &rarr; <strong>User Settings</strong> &rarr; <strong>Personal Access Tokens</strong></li>
<li>Neues Token erstellen und in <code className="bg-blue-100 px-1 rounded">.env</code> eintragen: <code className="bg-blue-100 px-1 rounded">WOODPECKER_TOKEN=...</code></li>
<li>Container neu starten: <code className="bg-blue-100 px-1 rounded">docker compose up -d admin-v2</code></li>
</ol>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
// ============================================================================
// CI/CD Dashboard - Shared Helper Components & Utilities
// ============================================================================
export function ProgressBar({ percent, color = 'blue' }: { percent: number; color?: string }) {
const getColor = () => {
if (percent > 90) return 'bg-red-500'
if (percent > 70) return 'bg-yellow-500'
if (color === 'green') return 'bg-green-500'
if (color === 'purple') return 'bg-purple-500'
return 'bg-blue-500'
}
return (
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${getColor()}`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
)
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
export function getStateColor(state: string): string {
switch (state) {
case 'running': return 'bg-green-100 text-green-800'
case 'exited':
case 'dead': return 'bg-red-100 text-red-800'
case 'paused': return 'bg-yellow-100 text-yellow-800'
case 'restarting': return 'bg-blue-100 text-blue-800'
default: return 'bg-slate-100 text-slate-600'
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
// ============================================================================
// CI/CD Dashboard Types
// ============================================================================
export interface PipelineStatus {
gitea_connected: boolean
gitea_url: string
last_sbom_update: string | null
total_runs: number
successful_runs: number
failed_runs: number
}
export interface PipelineRun {
id: string
workflow: string
branch: string
commit_sha: string
status: 'success' | 'failed' | 'running' | 'pending'
started_at: string
finished_at: string | null
duration_seconds: number | null
}
export interface ContainerInfo {
id: string
name: string
image: string
status: string
state: string
created: string
ports: string[]
cpu_percent: number
memory_usage: string
memory_limit: string
memory_percent: number
network_rx: string
network_tx: string
}
export interface SystemStats {
hostname: string
platform: string
arch: string
uptime: number
cpu: {
model: string
cores: number
usage_percent: number
}
memory: {
total: string
used: string
free: string
usage_percent: number
}
disk: {
total: string
used: string
free: string
usage_percent: number
}
}
export interface DockerStats {
containers: ContainerInfo[]
total_containers: number
running_containers: number
stopped_containers: number
}
export type TabType = 'overview' | 'woodpecker' | 'pipelines' | 'deployments' | 'setup' | 'scheduler'
// Woodpecker Types
export interface WoodpeckerStep {
name: string
state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'
exit_code: number
error?: string
}
export interface WoodpeckerPipeline {
id: number
number: number
status: 'pending' | 'running' | 'success' | 'failure' | 'error'
event: string
branch: string
commit: string
message: string
author: string
created: number
started: number
finished: number
steps: WoodpeckerStep[]
errors?: string[]
}
export interface WoodpeckerStatus {
status: 'online' | 'offline'
pipelines: WoodpeckerPipeline[]
lastUpdate: string
error?: string
}
export type ContainerFilter = 'all' | 'running' | 'stopped'

View File

@@ -0,0 +1,244 @@
import { useState, useEffect, useCallback } from 'react'
import type {
PipelineStatus,
PipelineRun,
SystemStats,
DockerStats,
WoodpeckerStatus,
TabType,
ContainerFilter,
ContainerInfo,
} from './types'
export interface CiCdData {
// Tab
activeTab: TabType
setActiveTab: (tab: TabType) => void
// Pipeline
pipelineStatus: PipelineStatus | null
pipelineHistory: PipelineRun[]
triggeringPipeline: boolean
triggerPipeline: () => Promise<void>
// Container
systemStats: SystemStats | null
dockerStats: DockerStats | null
containerFilter: ContainerFilter
setContainerFilter: (f: ContainerFilter) => void
filteredContainers: ContainerInfo[]
actionLoading: string | null
containerAction: (containerId: string, action: 'start' | 'stop' | 'restart') => Promise<void>
loadContainerData: () => Promise<void>
// Woodpecker
woodpeckerStatus: WoodpeckerStatus | null
triggeringWoodpecker: boolean
triggerWoodpeckerPipeline: () => Promise<void>
// General
loading: boolean
error: string | null
message: string | null
}
export function useCiCdData(): CiCdData {
const [activeTab, setActiveTab] = useState<TabType>('overview')
// Pipeline State
const [pipelineStatus, setPipelineStatus] = useState<PipelineStatus | null>(null)
const [pipelineHistory, setPipelineHistory] = useState<PipelineRun[]>([])
const [triggeringPipeline, setTriggeringPipeline] = useState(false)
// Container State
const [systemStats, setSystemStats] = useState<SystemStats | null>(null)
const [dockerStats, setDockerStats] = useState<DockerStats | null>(null)
const [containerFilter, setContainerFilter] = useState<ContainerFilter>('all')
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Woodpecker State
const [woodpeckerStatus, setWoodpeckerStatus] = useState<WoodpeckerStatus | null>(null)
const [triggeringWoodpecker, setTriggeringWoodpecker] = useState(false)
// General State
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''
// ============================================================================
// Data Loading
// ============================================================================
const loadPipelineData = useCallback(async () => {
try {
const [statusRes, historyRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/status`),
fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/history`),
])
if (statusRes.ok) {
setPipelineStatus(await statusRes.json())
}
if (historyRes.ok) {
setPipelineHistory(await historyRes.json())
}
} catch (err) {
console.error('Failed to load pipeline data:', err)
}
}, [BACKEND_URL])
const loadContainerData = useCallback(async () => {
try {
const response = await fetch('/api/admin/infrastructure/mac-mini')
if (response.ok) {
const data = await response.json()
setSystemStats(data.system)
setDockerStats(data.docker)
}
} catch (err) {
console.error('Failed to load container data:', err)
}
}, [])
const loadWoodpeckerData = useCallback(async () => {
try {
const response = await fetch('/api/admin/infrastructure/woodpecker?limit=10')
if (response.ok) {
const data = await response.json()
setWoodpeckerStatus(data)
}
} catch (err) {
console.error('Failed to load Woodpecker data:', err)
setWoodpeckerStatus({
status: 'offline',
pipelines: [],
lastUpdate: new Date().toISOString(),
error: 'Verbindung fehlgeschlagen'
})
}
}, [])
const loadAllData = useCallback(async () => {
setLoading(true)
setError(null)
await Promise.all([loadPipelineData(), loadContainerData(), loadWoodpeckerData()])
setLoading(false)
}, [loadPipelineData, loadContainerData, loadWoodpeckerData])
useEffect(() => {
loadAllData()
}, [loadAllData])
// Auto-refresh every 30 seconds
useEffect(() => {
const interval = setInterval(loadAllData, 30000)
return () => clearInterval(interval)
}, [loadAllData])
// ============================================================================
// Actions
// ============================================================================
const triggerPipeline = async () => {
setTriggeringPipeline(true)
try {
const response = await fetch(`${BACKEND_URL}/api/v1/security/sbom/pipeline/trigger`, {
method: 'POST',
})
if (response.ok) {
setMessage('Pipeline gestartet!')
setTimeout(loadPipelineData, 2000)
setTimeout(loadPipelineData, 5000)
}
} catch (err) {
setError('Pipeline-Trigger fehlgeschlagen')
} finally {
setTriggeringPipeline(false)
}
}
const triggerWoodpeckerPipeline = async () => {
setTriggeringWoodpecker(true)
setMessage(null)
try {
const response = await fetch('/api/admin/infrastructure/woodpecker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: 'main' })
})
if (response.ok) {
const result = await response.json()
setMessage(`Woodpecker Pipeline #${result.pipeline?.number || '?'} gestartet!`)
setTimeout(loadWoodpeckerData, 2000)
setTimeout(loadWoodpeckerData, 5000)
} else {
setError('Pipeline-Start fehlgeschlagen')
}
} catch (err) {
setError('Pipeline konnte nicht gestartet werden')
} finally {
setTriggeringWoodpecker(false)
}
}
const containerAction = async (containerId: string, action: 'start' | 'stop' | 'restart') => {
setActionLoading(`${containerId}-${action}`)
setMessage(null)
try {
const response = await fetch('/api/admin/infrastructure/mac-mini', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ container_id: containerId, action }),
})
if (!response.ok) {
throw new Error('Aktion fehlgeschlagen')
}
setMessage(`Container ${action} erfolgreich`)
setTimeout(loadContainerData, 1000)
setTimeout(loadContainerData, 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler')
} finally {
setActionLoading(null)
}
}
// ============================================================================
// Derived
// ============================================================================
const filteredContainers = dockerStats?.containers.filter(c => {
if (containerFilter === 'all') return true
if (containerFilter === 'running') return c.state === 'running'
if (containerFilter === 'stopped') return c.state !== 'running'
return true
}) || []
return {
activeTab,
setActiveTab,
pipelineStatus,
pipelineHistory,
triggeringPipeline,
triggerPipeline,
systemStats,
dockerStats,
containerFilter,
setContainerFilter,
filteredContainers,
actionLoading,
containerAction,
loadContainerData,
woodpeckerStatus,
triggeringWoodpecker,
triggerWoodpeckerPipeline,
loading,
error,
message,
}
}

View File

@@ -0,0 +1,490 @@
'use client'
import { useState } from 'react'
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
import type { FailedTest, BacklogItem, BacklogPriority } from '../types'
// ==============================================================================
// FailedTestCard
// ==============================================================================
function FailedTestCard({
test,
onStatusChange,
onPriorityChange,
priority = 'medium',
failureCount = 1,
}: {
test: FailedTest
onStatusChange: (testId: string, status: string) => void
onPriorityChange?: (testId: string, priority: string) => void
priority?: BacklogPriority
failureCount?: number
}) {
const errorTypeColors: Record<string, string> = {
assertion: 'bg-amber-100 text-amber-700',
nil_pointer: 'bg-red-100 text-red-700',
type_error: 'bg-purple-100 text-purple-700',
network: 'bg-blue-100 text-blue-700',
timeout: 'bg-orange-100 text-orange-700',
logic_error: 'bg-slate-100 text-slate-700',
unknown: 'bg-slate-100 text-slate-700',
}
const statusColors: Record<string, string> = {
open: 'bg-red-100 text-red-700',
in_progress: 'bg-blue-100 text-blue-700',
fixed: 'bg-emerald-100 text-emerald-700',
wont_fix: 'bg-slate-100 text-slate-700',
flaky: 'bg-purple-100 text-purple-700',
}
const priorityColors: Record<string, string> = {
critical: 'bg-red-500 text-white',
high: 'bg-orange-500 text-white',
medium: 'bg-yellow-500 text-white',
low: 'bg-slate-400 text-white',
}
const priorityLabels: Record<string, string> = {
critical: '!!! Kritisch',
high: '!! Hoch',
medium: '! Mittel',
low: 'Niedrig',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-4 hover:border-red-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priorityColors[priority]}`}>
{priorityLabels[priority]}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${errorTypeColors[test.error_type] || errorTypeColors.unknown}`}>
{test.error_type.replace('_', ' ')}
</span>
<span className="text-xs text-slate-400">{test.service}</span>
{failureCount > 1 && (
<span className="px-1.5 py-0.5 rounded bg-red-100 text-red-600 text-xs font-medium">
{failureCount}x fehlgeschlagen
</span>
)}
</div>
<h4 className="font-mono text-sm font-medium text-slate-900 truncate" title={test.name}>
{test.name}
</h4>
<p className="text-xs text-slate-500 truncate" title={test.file_path}>
{test.file_path}
</p>
</div>
<div className="flex flex-col gap-1 ml-2">
<select
value={test.status}
onChange={(e) => onStatusChange(test.id, e.target.value)}
className={`px-2 py-1 rounded text-xs font-medium cursor-pointer border-0 ${statusColors[test.status]}`}
>
<option value="open">Offen</option>
<option value="in_progress">In Arbeit</option>
<option value="fixed">Behoben</option>
<option value="wont_fix">Ignoriert</option>
<option value="flaky">Flaky</option>
</select>
{onPriorityChange && (
<select
value={priority}
onChange={(e) => onPriorityChange(test.id, e.target.value)}
className="px-2 py-1 rounded text-xs font-medium cursor-pointer border border-slate-200"
>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
)}
</div>
</div>
<div className="bg-red-50 rounded-lg p-3 mb-3">
<p className="text-sm text-red-800 font-medium mb-1">Fehlermeldung:</p>
<p className="text-xs text-red-700 font-mono break-words">
{test.error_message || 'Keine Details verfuegbar'}
</p>
</div>
{test.suggestion && (
<div className="bg-emerald-50 rounded-lg p-3">
<p className="text-sm text-emerald-800 font-medium mb-1">Loesungsvorschlag:</p>
<p className="text-xs text-emerald-700">
{test.suggestion}
</p>
</div>
)}
<div className="mt-3 pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-400">
<span>Zuletzt fehlgeschlagen: {test.last_failed ? new Date(test.last_failed).toLocaleString('de-DE') : 'Unbekannt'}</span>
<button
className="text-orange-600 hover:text-orange-700 font-medium"
onClick={() => {
navigator.clipboard.writeText(test.id)
}}
>
ID kopieren
</button>
</div>
</div>
)
}
// ==============================================================================
// BacklogTab
// ==============================================================================
export function BacklogTab({
failedTests,
onStatusChange,
onPriorityChange,
isLoading,
backlogItems,
usePostgres = false,
}: {
failedTests: FailedTest[]
onStatusChange: (testId: string, status: string) => void
onPriorityChange?: (testId: string, priority: string) => void
isLoading: boolean
backlogItems?: BacklogItem[]
usePostgres?: boolean
}) {
const [filterStatus, setFilterStatus] = useState<string>('open')
const [filterService, setFilterService] = useState<string>('all')
const [filterPriority, setFilterPriority] = useState<string>('all')
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
const items = usePostgres && backlogItems ? backlogItems : failedTests
// Gruppiere nach Service
const services = [...new Set(items.map(t => 'service' in t ? t.service : (t as BacklogItem).service))]
// Filtere Items
const filteredItems = items.filter(item => {
const status = 'status' in item ? item.status : 'open'
const service = 'service' in item ? item.service : ''
const priority = 'priority' in item ? (item as BacklogItem).priority : 'medium'
if (filterStatus !== 'all' && status !== filterStatus) return false
if (filterService !== 'all' && service !== filterService) return false
if (filterPriority !== 'all' && priority !== filterPriority) return false
return true
})
// Zaehle nach Status
const openCount = items.filter(t => t.status === 'open').length
const inProgressCount = items.filter(t => t.status === 'in_progress').length
const fixedCount = items.filter(t => t.status === 'fixed').length
const flakyCount = items.filter(t => t.status === 'flaky').length
// Zaehle nach Prioritaet (nur bei PostgreSQL)
const criticalCount = backlogItems?.filter(t => t.priority === 'critical').length || 0
const highCount = backlogItems?.filter(t => t.priority === 'high').length || 0
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-600"></div>
</div>
)
}
// Konvertiere BacklogItem zu FailedTest fuer die Anzeige
const convertToFailedTest = (item: BacklogItem): FailedTest => ({
id: String(item.id),
name: item.test_name,
service: item.service,
file_path: item.test_file || '',
error_message: item.error_message || '',
error_type: item.error_type || 'unknown',
suggestion: item.fix_suggestion || '',
run_id: '',
last_failed: item.last_failed_at,
status: item.status,
})
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="text-2xl font-bold text-red-600">{openCount}</p>
<p className="text-sm text-red-700">Offene Fehler</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<p className="text-2xl font-bold text-blue-600">{inProgressCount}</p>
<p className="text-sm text-blue-700">In Arbeit</p>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-4">
<p className="text-2xl font-bold text-emerald-600">{fixedCount}</p>
<p className="text-sm text-emerald-700">Behoben</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<p className="text-2xl font-bold text-purple-600">{flakyCount}</p>
<p className="text-sm text-purple-700">Flaky</p>
</div>
{usePostgres && criticalCount + highCount > 0 && (
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4">
<p className="text-2xl font-bold text-orange-600">{criticalCount + highCount}</p>
<p className="text-sm text-orange-700">Kritisch/Hoch</p>
</div>
)}
</div>
{/* PostgreSQL Badge */}
{usePostgres && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-50 border border-emerald-200 rounded-lg w-fit">
<svg className="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs text-emerald-700 font-medium">Persistente Speicherung aktiv (PostgreSQL)</span>
</div>
)}
{/* LLM Analysis Toggle */}
<LLMAnalysisPanel
llmAutoAnalysis={llmAutoAnalysis}
setLlmAutoAnalysis={setLlmAutoAnalysis}
llmRouting={llmRouting}
setLlmRouting={setLlmRouting}
/>
{/* Filter */}
<div className="flex flex-wrap gap-4 items-center">
<div>
<label className="text-sm text-slate-600 mr-2">Status:</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
>
<option value="all">Alle</option>
<option value="open">Offen ({openCount})</option>
<option value="in_progress">In Arbeit ({inProgressCount})</option>
<option value="fixed">Behoben ({fixedCount})</option>
<option value="flaky">Flaky ({flakyCount})</option>
<option value="wont_fix">Ignoriert</option>
</select>
</div>
<div>
<label className="text-sm text-slate-600 mr-2">Service:</label>
<select
value={filterService}
onChange={(e) => setFilterService(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
>
<option value="all">Alle Services</option>
{services.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{usePostgres && (
<div>
<label className="text-sm text-slate-600 mr-2">Prioritaet:</label>
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-sm"
>
<option value="all">Alle</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
</div>
)}
<div className="ml-auto text-sm text-slate-500">
{filteredItems.length} von {items.length} Tests angezeigt
</div>
</div>
{/* Test-Liste */}
{filteredItems.length === 0 ? (
<div className="text-center py-12 bg-emerald-50 rounded-xl border border-emerald-200">
<svg className="w-12 h-12 mx-auto text-emerald-400 mb-4" 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>
<p className="text-emerald-700 font-medium">
{filterStatus === 'open' ? 'Keine offenen Fehler!' : 'Keine Tests mit diesem Filter gefunden.'}
</p>
{filterStatus === 'open' && (
<p className="text-sm text-emerald-600 mt-2">
Alle Tests bestanden. Bereit fuer Go-Live!
</p>
)}
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredItems.map((item) => {
const test = usePostgres && 'test_name' in item
? convertToFailedTest(item as BacklogItem)
: item as FailedTest
const priority = usePostgres && 'priority' in item
? (item as BacklogItem).priority
: 'medium'
const failureCount = usePostgres && 'failure_count' in item
? (item as BacklogItem).failure_count
: 1
return (
<FailedTestCard
key={test.id}
test={test}
onStatusChange={onStatusChange}
onPriorityChange={onPriorityChange}
priority={priority}
failureCount={failureCount}
/>
)
})}
</div>
)}
{/* Info */}
<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 font-medium">Workflow fuer fehlgeschlagene Tests:</p>
<ol className="text-xs text-blue-700 mt-2 space-y-1 list-decimal list-inside">
<li>Markiere den Test als &quot;In Arbeit&quot; wenn du daran arbeitest</li>
<li>Analysiere die Fehlermeldung und den Loesungsvorschlag</li>
<li>Behebe den Fehler im Code</li>
<li>Fuehre den Test erneut aus (Button im Service-Tab)</li>
<li>Markiere als &quot;Behoben&quot; wenn der Test besteht</li>
{usePostgres && <li>Setze &quot;Flaky&quot; fuer sporadisch fehlschlagende Tests</li>}
</ol>
</div>
</div>
</div>
</div>
)
}
// ==============================================================================
// LLM Analysis Panel (internal)
// ==============================================================================
function LLMAnalysisPanel({
llmAutoAnalysis,
setLlmAutoAnalysis,
llmRouting,
setLlmRouting,
}: {
llmAutoAnalysis: boolean
setLlmAutoAnalysis: (v: boolean) => void
llmRouting: LLMRoutingOption
setLlmRouting: (v: LLMRoutingOption) => void
}) {
return (
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={llmAutoAnalysis}
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
</label>
</div>
{llmAutoAnalysis && (
<div className="mt-4 pt-4 border-t border-violet-200">
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
<div className="flex flex-wrap gap-2">
<RoutingOption
value="local_only"
current={llmRouting}
onChange={setLlmRouting}
label="Nur lokales 32B LLM"
badge="DSGVO"
badgeColor="bg-emerald-100 text-emerald-700"
/>
<RoutingOption
value="claude_preferred"
current={llmRouting}
onChange={setLlmRouting}
label="Claude bevorzugt"
badge="Qualitaet"
badgeColor="bg-blue-100 text-blue-700"
/>
<RoutingOption
value="smart_routing"
current={llmRouting}
onChange={setLlmRouting}
label="Smart Routing"
badge="Empfohlen"
badgeColor="bg-amber-100 text-amber-700"
/>
</div>
<p className="text-xs text-slate-500 mt-2">
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
</p>
</div>
)}
</div>
)
}
function RoutingOption({
value,
current,
onChange,
label,
badge,
badgeColor,
}: {
value: LLMRoutingOption
current: LLMRoutingOption
onChange: (v: LLMRoutingOption) => void
label: string
badge: string
badgeColor: string
}) {
const isActive = current === value
return (
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
isActive
? 'bg-violet-100 border-violet-300 text-violet-800'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
<input
type="radio"
name="llm-routing"
value={value}
checked={isActive}
onChange={() => onChange(value)}
className="sr-only"
/>
<span className="text-sm font-medium">{label}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeColor}`}>{badge}</span>
</label>
)
}

View File

@@ -0,0 +1,82 @@
import type { CoverageData } from '../types'
export function CoverageChart({ data }: { data: CoverageData[] }) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-slate-400">
Keine Coverage-Daten verfuegbar
</div>
)
}
const sortedData = [...data].sort((a, b) => b.coverage_percent - a.coverage_percent)
return (
<div className="space-y-3">
{sortedData.map((item) => (
<div key={item.service}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-600 truncate max-w-[200px]">{item.display_name}</span>
<span
className={`font-medium ${
item.coverage_percent >= 80 ? 'text-emerald-600' : item.coverage_percent >= 60 ? 'text-amber-600' : 'text-red-600'
}`}
>
{item.coverage_percent.toFixed(1)}%
</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
item.coverage_percent >= 80 ? 'bg-emerald-500' : item.coverage_percent >= 60 ? 'bg-amber-500' : 'bg-red-500'
}`}
style={{ width: `${item.coverage_percent}%` }}
/>
</div>
</div>
))}
</div>
)
}
export function FrameworkDistribution({ data }: { data: Record<string, number> }) {
const total = Object.values(data).reduce((a, b) => a + b, 0)
if (total === 0) return null
const frameworkLabels: Record<string, string> = {
go_test: 'Go Tests',
pytest: 'Python (pytest)',
jest: 'Jest (TS)',
vitest: 'Vitest (SDK)',
playwright: 'Playwright (E2E)',
bqas_golden: 'BQAS Golden',
bqas_rag: 'BQAS RAG',
bqas_synthetic: 'BQAS Synthetic',
}
const frameworkColors: Record<string, string> = {
go_test: 'bg-cyan-500',
pytest: 'bg-yellow-500',
jest: 'bg-blue-500',
vitest: 'bg-orange-500',
playwright: 'bg-purple-500',
bqas_golden: 'bg-emerald-500',
bqas_rag: 'bg-teal-500',
bqas_synthetic: 'bg-amber-500',
}
return (
<div className="space-y-3">
{Object.entries(data)
.sort((a, b) => b[1] - a[1])
.map(([framework, count]) => (
<div key={framework} className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${frameworkColors[framework] || 'bg-slate-400'}`} />
<span className="text-sm text-slate-600 flex-1">{frameworkLabels[framework] || framework}</span>
<span className="text-sm font-medium text-slate-900">{count}</span>
<span className="text-xs text-slate-400">({((count / total) * 100).toFixed(0)}%)</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,224 @@
import Link from 'next/link'
export function GuideTab() {
return (
<div className="space-y-8">
<div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-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-orange-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 das Test Dashboard?
</h2>
<p className="text-slate-700 leading-relaxed">
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
</p>
</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-4 gap-4">
<TestCategoryCard
icon="🐹" title="Go Unit Tests (~57)" color="cyan"
description="consent-service, billing-service, school-service, edu-search-service, ai-compliance-sdk"
/>
<TestCategoryCard
icon="🐍" title="Python Tests (~50)" color="yellow"
description="backend, voice-service, klausur-service, geo-service"
/>
<TestCategoryCard
icon="🎯" title="BQAS Golden (97)" color="emerald"
description="Validierte Referenz-Tests mit LLM-Judge fuer Intent-Erkennung"
/>
<TestCategoryCard
icon="📚" title="BQAS RAG (~20)" color="teal"
description="RAG-Judge Tests fuer Retrieval, Citations, Hallucination-Control"
/>
<TestCategoryCard
icon="📘" title="TypeScript Jest (~8)" color="blue"
description="Website Unit Tests fuer React-Komponenten"
/>
<TestCategoryCard
icon="⚡" title="SDK Vitest (~43)" color="orange"
description="AI Compliance SDK Unit Tests: Types, Export, Components, Reducer"
/>
<TestCategoryCard
icon="🎭" title="SDK Playwright (~25)" color="purple"
description="SDK E2E Tests: Navigation, Workflow, Command Bar, Export"
/>
<TestCategoryCard
icon="🌐" title="Website E2E (~5)" color="slate"
description="End-to-End Tests fuer kritische User Flows"
/>
<TestCategoryCard
icon="🔗" title="Integration Tests (~15)" color="indigo"
description="Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB"
/>
</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">Architektur</h3>
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
{`┌────────────────────────────────────────────────────────────────────┐
│ Admin-v2 Test Dashboard │
│ /infrastructure/tests │
├────────────────────────────────────────────────────────────────────┤
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
│ │ Unit Tests │ │ SDK Tests │ │ BQAS │ │ E2E Tests │ │
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│ │
│ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Test Registry API │ │
│ │ /backend/api/tests/registry.py │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
Tests bleiben wo sie sind:
- /consent-service/internal/**/*_test.go
- /backend/tests/test_*.py
- /voice-service/tests/bqas/
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
</pre>
</div>
{/* CI/CD Workflow Anleitung */}
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
<h3 className="text-lg font-semibold text-blue-900 mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
CI/CD Integration
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-blue-800 mb-2">Automatisch (bei jedem Push/PR)</h4>
<ul className="space-y-2 text-sm text-blue-700">
<CIItem icon="✓" color="green" label="Unit Tests" detail="Go & Python Tests laufen automatisch" />
<CIItem icon="✓" color="green" label="Test-Ergebnisse" detail="Werden ans Dashboard gesendet" />
<CIItem icon="✓" color="green" label="Backlog" detail="Fehlgeschlagene Tests erscheinen hier" />
<CIItem icon="✓" color="green" label="Linting" detail="Code-Qualitaet bei PRs pruefen" />
</ul>
</div>
<div>
<h4 className="font-medium text-blue-800 mb-2">Manuell (Button oder Tag)</h4>
<ul className="space-y-2 text-sm text-blue-700">
<CIItem icon="▶" color="orange" label="Docker Builds" detail="Container erstellen" />
<CIItem icon="▶" color="orange" label="SBOM/Scans" detail="Sicherheitsanalyse ausfuehren" />
<CIItem icon="▶" color="orange" label="Deployment" detail="In Produktion deployen" />
<CIItem icon="▶" color="orange" label="Pipeline starten" detail="Im CI/CD Dashboard" />
</ul>
</div>
</div>
<div className="mt-4 pt-4 border-t border-blue-200">
<p className="text-sm text-blue-600">
<strong>Daten-Fluss:</strong> Woodpecker CI POST /api/tests/ci-result PostgreSQL Test Dashboard
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
href="/ai/test-quality"
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="font-medium text-slate-900">BQAS Dashboard</p>
<p className="text-xs text-slate-500">Detaillierte BQAS-Metriken und Trend-Analyse</p>
</div>
</div>
</Link>
<Link
href="/infrastructure/ci-cd"
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-orange-300 hover:bg-orange-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 Pipelines</p>
<p className="text-xs text-slate-500">Gitea Actions und automatische Test-Planung</p>
</div>
</div>
</Link>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Internal helper components
// ---------------------------------------------------------------------------
function TestCategoryCard({
icon,
title,
color,
description,
}: {
icon: string
title: string
color: string
description: string
}) {
const colorMap: Record<string, string> = {
cyan: 'bg-cyan-50 border-cyan-200 text-cyan-800 text-cyan-700',
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-800 text-yellow-700',
emerald: 'bg-emerald-50 border-emerald-200 text-emerald-800 text-emerald-700',
teal: 'bg-teal-50 border-teal-200 text-teal-800 text-teal-700',
blue: 'bg-blue-50 border-blue-200 text-blue-800 text-blue-700',
orange: 'bg-orange-50 border-orange-200 text-orange-800 text-orange-700',
purple: 'bg-purple-50 border-purple-200 text-purple-800 text-purple-700',
slate: 'bg-slate-50 border-slate-200 text-slate-800 text-slate-700',
indigo: 'bg-indigo-50 border-indigo-200 text-indigo-800 text-indigo-700',
}
// Build explicit class strings for Tailwind to detect
const bgBorder = `bg-${color}-50 border-${color}-200`
const titleColor = `text-${color}-800`
const descColor = `text-${color}-700`
return (
<div className={`p-4 rounded-lg border ${bgBorder}`}>
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{icon}</span>
<h4 className={`font-medium ${titleColor}`}>{title}</h4>
</div>
<p className={`text-sm ${descColor}`}>{description}</p>
</div>
)
}
function CIItem({
icon,
color,
label,
detail,
}: {
icon: string
color: 'green' | 'orange'
label: string
detail: string
}) {
const iconColor = color === 'green' ? 'text-green-500' : 'text-orange-500'
return (
<li className="flex items-start gap-2">
<span className={`${iconColor} mt-1`}>{icon}</span>
<span><strong>{label}</strong> - {detail}</span>
</li>
)
}

View File

@@ -0,0 +1,53 @@
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' | 'orange' | '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',
orange: 'bg-orange-50 border-orange-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>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import type { ServiceTestInfo } from '../types'
export interface ServiceProgress {
current_file: string
files_done: number
files_total: number
passed: number
failed: number
status: string
}
export function ServiceTestCard({
service,
onRun,
isRunning,
progress,
}: {
service: ServiceTestInfo
onRun: (service: string) => void
isRunning: boolean
progress?: ServiceProgress
}) {
const passRate = service.total_tests > 0 ? (service.passed_tests / service.total_tests) * 100 : 0
const getLanguageIcon = (lang: string) => {
switch (lang) {
case 'go':
return '🐹'
case 'python':
return '🐍'
case 'typescript':
return '📘'
case 'mixed':
return '🔀'
default:
return '📦'
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'passed':
return 'bg-emerald-100 text-emerald-700'
case 'failed':
return 'bg-red-100 text-red-700'
case 'running':
return 'bg-blue-100 text-blue-700'
default:
return 'bg-slate-100 text-slate-700'
}
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:border-orange-300 transition-colors">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{getLanguageIcon(service.language)}</span>
<div>
<h3 className="font-semibold text-slate-900">{service.display_name}</h3>
<p className="text-xs text-slate-500">
{service.port ? `Port ${service.port}` : 'Library'} {service.language}
</p>
</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(service.status)}`}>
{service.status === 'passed' ? 'Bestanden' : service.status === 'failed' ? 'Fehler' : 'Ausstehend'}
</span>
</div>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-600">Pass Rate</span>
<span className="font-medium text-slate-900">{passRate.toFixed(0)}%</span>
</div>
<div className="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>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-2 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-slate-900">{service.total_tests}</p>
<p className="text-xs text-slate-500">Tests</p>
</div>
<div className="p-2 bg-emerald-50 rounded-lg">
<p className="text-lg font-bold text-emerald-600">{service.passed_tests}</p>
<p className="text-xs text-slate-500">Bestanden</p>
</div>
<div className="p-2 bg-red-50 rounded-lg">
<p className="text-lg font-bold text-red-600">{service.failed_tests}</p>
<p className="text-xs text-slate-500">Fehler</p>
</div>
</div>
{service.coverage_percent && (
<div className="flex items-center justify-between text-sm pt-2 border-t border-slate-100">
<span className="text-slate-600">Coverage</span>
<span className={`font-medium ${service.coverage_percent >= 70 ? 'text-emerald-600' : 'text-amber-600'}`}>
{service.coverage_percent.toFixed(1)}%
</span>
</div>
)}
{/* Progress-Anzeige wenn Tests laufen */}
{isRunning && progress && progress.status === 'running' && (
<div className="mb-3 p-3 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-center justify-between text-xs text-orange-700 mb-2">
<span className="font-mono truncate max-w-[180px]">{progress.current_file || 'Starte...'}</span>
<span>{progress.files_done}/{progress.files_total} Dateien</span>
</div>
<div className="h-1.5 bg-orange-100 rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 rounded-full transition-all"
style={{ width: `${progress.files_total > 0 ? (progress.files_done / progress.files_total) * 100 : 0}%` }}
/>
</div>
<div className="flex items-center justify-between mt-2 text-xs">
<span className="text-emerald-600 font-medium">{progress.passed} bestanden</span>
<span className="text-red-600 font-medium">{progress.failed} fehler</span>
</div>
</div>
)}
<button
onClick={() => onRun(service.service)}
disabled={isRunning}
className={`w-full py-2 rounded-lg text-sm font-medium transition-all ${
isRunning
? 'bg-orange-100 text-orange-600 cursor-wait'
: 'bg-orange-600 text-white hover:bg-orange-700 active:scale-98'
}`}
>
{isRunning ? (
<span className="flex items-center justify-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 12h4z" />
</svg>
{progress && progress.status === 'running' ? `${progress.passed + progress.failed} Tests...` : 'Laeuft...'}
</span>
) : (
'Tests starten'
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
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">Service</th>
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</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>
<th className="text-center py-3 px-4 font-medium text-slate-600">Status</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-xs text-slate-500">{run.id.slice(-8)}</td>
<td className="py-3 px-4 text-slate-900">{run.service}</td>
<td className="py-3 px-4 text-slate-600">
{new Date(run.started_at).toLocaleString('de-DE')}
</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>
<td className="py-3 px-4 text-center">
<span
className={`px-2 py-1 rounded text-xs font-medium ${
run.status === 'completed'
? 'bg-emerald-100 text-emerald-700'
: run.status === 'failed'
? 'bg-red-100 text-red-700'
: run.status === 'running'
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700'
}`}
>
{run.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import type { Toast } from '../types'
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>
)
}

View File

@@ -0,0 +1,310 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import type {
ServiceTestInfo,
TestRegistryStats,
TestRun,
CoverageData,
TabType,
Toast,
FailedTest,
BacklogItem,
} from '../types'
import { API_BASE, DEMO_SERVICES, DEMO_STATS } from '../_lib/constants'
import type { ServiceProgress } from '../_components/ServiceTestCard'
export function useTestDashboard() {
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
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) => {
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 [services, setServices] = useState<ServiceTestInfo[]>([])
const [stats, setStats] = useState<TestRegistryStats | null>(null)
const [coverage, setCoverage] = useState<CoverageData[]>([])
const [testRuns, setTestRuns] = useState<TestRun[]>([])
const [failedTests, setFailedTests] = useState<FailedTest[]>([])
const [backlogItems, setBacklogItems] = useState<BacklogItem[]>([])
const [usePostgres, setUsePostgres] = useState(false)
// Running states
const [runningServices, setRunningServices] = useState<Set<string>>(new Set())
// Progress states fuer laufende Tests
const [serviceProgress, setServiceProgress] = useState<Record<string, ServiceProgress>>({})
// Fetch data
const fetchData = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const registryResponse = await fetch(`${API_BASE}/registry`)
if (registryResponse.ok) {
const data = await registryResponse.json()
setServices(data.services || DEMO_SERVICES)
setStats(data.stats || DEMO_STATS)
} else {
setServices(DEMO_SERVICES)
setStats(DEMO_STATS)
}
const coverageResponse = await fetch(`${API_BASE}/coverage`)
if (coverageResponse.ok) {
const data = await coverageResponse.json()
setCoverage(data.services || [])
} else {
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
service: s.service,
display_name: s.display_name,
coverage_percent: s.coverage_percent!,
language: s.language,
})))
}
const runsResponse = await fetch(`${API_BASE}/runs`)
if (runsResponse.ok) {
const data = await runsResponse.json()
setTestRuns(data.runs || [])
}
// Lade fehlgeschlagene Tests fuer Backlog
const failedResponse = await fetch(`${API_BASE}/failed`)
if (failedResponse.ok) {
const data = await failedResponse.json()
setFailedTests(data.tests || [])
}
// Versuche PostgreSQL-Backlog zu laden (neue API)
try {
const backlogResponse = await fetch(`${API_BASE}/backlog`)
if (backlogResponse.ok) {
const data = await backlogResponse.json()
if (data.items && data.items.length > 0) {
setBacklogItems(data.items)
setUsePostgres(true)
}
}
} catch {
// PostgreSQL nicht verfuegbar, nutze Legacy
setUsePostgres(false)
}
} catch (err) {
console.error('Failed to fetch test registry data:', err)
setServices(DEMO_SERVICES)
setStats(DEMO_STATS)
setCoverage(DEMO_SERVICES.filter(s => s.coverage_percent).map(s => ({
service: s.service,
display_name: s.display_name,
coverage_percent: s.coverage_percent!,
language: s.language,
})))
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Update failed test status
const updateTestStatus = async (testId: string, status: string) => {
try {
// Nutze PostgreSQL-Endpoint wenn verfuegbar
const endpoint = usePostgres
? `${API_BASE}/backlog/${testId}/status`
: `${API_BASE}/failed/${encodeURIComponent(testId)}/status?status=${status}`
const response = await fetch(endpoint, {
method: 'POST',
headers: usePostgres ? { 'Content-Type': 'application/json' } : undefined,
body: usePostgres ? JSON.stringify({ status }) : undefined,
})
if (response.ok) {
// Aktualisiere lokalen State
if (usePostgres) {
setBacklogItems(prev =>
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
)
}
setFailedTests(prev =>
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
)
addToast('success', `Test-Status auf "${status}" gesetzt`)
}
} catch (err) {
console.error('Failed to update test status:', err)
// Trotzdem lokal aktualisieren fuer bessere UX
setFailedTests(prev =>
prev.map(t => t.id === testId ? { ...t, status: status as any } : t)
)
if (usePostgres) {
setBacklogItems(prev =>
prev.map(t => String(t.id) === testId ? { ...t, status: status as any } : t)
)
}
}
}
// Update failed test priority (nur PostgreSQL)
const updateTestPriority = async (testId: string, priority: string) => {
if (!usePostgres) return
try {
const response = await fetch(`${API_BASE}/backlog/${testId}/priority`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priority }),
})
if (response.ok) {
setBacklogItems(prev =>
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
)
addToast('success', `Prioritaet auf "${priority}" gesetzt`)
}
} catch (err) {
console.error('Failed to update test priority:', err)
// Trotzdem lokal aktualisieren
setBacklogItems(prev =>
prev.map(t => String(t.id) === testId ? { ...t, priority: priority as any } : t)
)
}
}
// Run tests mit Progress-Polling
const runTests = async (service: string) => {
setRunningServices((prev) => new Set(prev).add(service))
const loadingToast = addToast('loading', `Tests fuer ${service} werden gestartet...`)
// Progress-Polling starten
let pollInterval: NodeJS.Timeout | null = null
const pollProgress = async () => {
try {
const progressResponse = await fetch(`${API_BASE}/progress/${service}`)
if (progressResponse.ok) {
const progress = await progressResponse.json()
setServiceProgress((prev) => ({
...prev,
[service]: progress,
}))
// Toast-Message mit aktuellem Fortschritt aktualisieren
if (progress.status === 'running' && progress.files_total > 0) {
const toastMsg = `${service}: ${progress.current_file} (${progress.passed} bestanden, ${progress.failed} fehler)`
updateToast(loadingToast, 'loading', toastMsg)
}
}
} catch {
// Ignore polling errors
}
}
// Start polling (alle 1 Sekunde)
pollInterval = setInterval(pollProgress, 1000)
try {
const response = await fetch(`${API_BASE}/run/${service}`, {
method: 'POST',
})
if (response.ok) {
// Warte kurz und pruefe finalen Progress
await new Promise(resolve => setTimeout(resolve, 500))
await pollProgress()
const finalProgress = serviceProgress[service]
const passedMsg = finalProgress ? `${finalProgress.passed} bestanden, ${finalProgress.failed} fehler` : 'abgeschlossen'
updateToast(loadingToast, 'success', `${service}: Tests ${passedMsg}`)
await fetchData()
} else {
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
}
} catch (err) {
console.error('Failed to run tests:', err)
updateToast(loadingToast, 'info', `${service}: Demo-Modus (API nicht verfuegbar)`)
} finally {
// Polling stoppen
if (pollInterval) {
clearInterval(pollInterval)
}
setRunningServices((prev) => {
const next = new Set(prev)
next.delete(service)
return next
})
// Progress-Daten entfernen nach Abschluss
setServiceProgress((prev) => {
const next = { ...prev }
delete next[service]
return next
})
}
}
// Filter services by category
const unitServices = services.filter(s => !s.service.startsWith('bqas-'))
const bqasServices = services.filter(s => s.service.startsWith('bqas-'))
return {
// Tab
activeTab,
setActiveTab,
// Loading / Error
isLoading,
error,
fetchData,
// Toast
toasts,
removeToast,
// Data
services,
stats,
coverage,
testRuns,
failedTests,
backlogItems,
usePostgres,
// Running
runningServices,
serviceProgress,
// Actions
updateTestStatus,
updateTestPriority,
runTests,
// Derived
unitServices,
bqasServices,
}
}

View File

@@ -0,0 +1,31 @@
import type { ServiceTestInfo, TestRegistryStats } from '../types'
// API Configuration
export const API_BASE = '/api/tests'
// Demo data for when API is not available
export const DEMO_SERVICES: ServiceTestInfo[] = [
{ service: 'consent-service', display_name: 'Consent Service', port: 8081, language: 'go', total_tests: 22, passed_tests: 20, failed_tests: 2, skipped_tests: 0, pass_rate: 90.9, coverage_percent: 82.3, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'backend', display_name: 'Python Backend', port: 8000, language: 'python', total_tests: 40, passed_tests: 38, failed_tests: 2, skipped_tests: 0, pass_rate: 95.0, coverage_percent: 75.1, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'voice-service', display_name: 'Voice Service', port: 8091, language: 'python', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 68.9, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'bqas-golden', display_name: 'BQAS Golden Suite', port: 8091, language: 'python', total_tests: 97, passed_tests: 89, failed_tests: 8, skipped_tests: 0, pass_rate: 91.7, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'bqas-rag', display_name: 'BQAS RAG Tests', port: 8091, language: 'python', total_tests: 20, passed_tests: 18, failed_tests: 2, skipped_tests: 0, pass_rate: 90.0, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'failed' },
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
]
export const DEMO_STATS: TestRegistryStats = {
total_tests: 278,
total_passed: 263,
total_failed: 15,
total_skipped: 0,
overall_pass_rate: 94.6,
average_coverage: 78.5,
services_count: 11,
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
/**
* AI Act & Obligations types
*
* EU AI Act classification results, obligations tracking,
* and general regulatory obligation management.
*/
import type { AIActRiskCategory } from './core'
// =============================================================================
// AI ACT
// =============================================================================
export interface AIActObligation {
id: string
article: string
title: string
description: string
deadline: Date | null
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'
}
export interface AIActResult {
riskCategory: AIActRiskCategory
systemType: string
obligations: AIActObligation[]
assessmentDate: Date
assessedBy: string
justification: string
}
// =============================================================================
// GENERAL OBLIGATIONS
// =============================================================================
export interface Obligation {
id: string
regulation: string
article: string
title: string
description: string
deadline: Date | null
penalty: string | null
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'
responsible: string | null
}

View File

@@ -0,0 +1,39 @@
/**
* Use Case Assessment types
*
* Structures for documenting and assessing AI use cases,
* including step tracking and assessment results.
*/
import type { RiskSeverity } from './core'
// =============================================================================
// USE CASE ASSESSMENT
// =============================================================================
export interface UseCaseStep {
id: string
name: string
completed: boolean
data: Record<string, unknown>
}
export interface AssessmentResult {
riskLevel: RiskSeverity
applicableRegulations: string[]
recommendedControls: string[]
dsfaRequired: boolean
aiActClassification: string
}
export interface UseCaseAssessment {
id: string
name: string
description: string
category: string
stepsCompleted: number
steps: UseCaseStep[]
assessmentResult: AssessmentResult | null
createdAt: Date
updatedAt: Date
}

View File

@@ -0,0 +1,55 @@
/**
* Checkpoint System types
*
* Validation rules, checkpoint definitions, and checkpoint status
* for the SDK's progress-gate system.
*/
import type { CheckpointType, ReviewerType, ValidationSeverity } from './core'
// =============================================================================
// VALIDATION
// =============================================================================
export interface ValidationRule {
id: string
field: string
condition: 'NOT_EMPTY' | 'MIN_COUNT' | 'MIN_VALUE' | 'CUSTOM' | 'REGEX'
value?: number | string
message: string
severity: ValidationSeverity
}
export interface ValidationError {
ruleId: string
field: string
message: string
severity: ValidationSeverity
}
// =============================================================================
// CHECKPOINT
// =============================================================================
export interface Checkpoint {
id: string
step: string
name: string
type: CheckpointType
validation: ValidationRule[]
blocksProgress: boolean
requiresReview: ReviewerType
autoValidate: boolean
}
export interface CheckpointStatus {
checkpointId: string
passed: boolean
validatedAt: Date | null
validatedBy: string | null
errors: ValidationError[]
warnings: ValidationError[]
overrideReason?: string
overriddenBy?: string
overriddenAt?: Date
}

View File

@@ -0,0 +1,238 @@
/**
* Company Profile types
*
* Business context collected before use cases: company info,
* business model, offerings, target markets, legal form.
*/
import type { SDKPackageId } from './core'
// =============================================================================
// COMPANY PROFILE ENUMS
// =============================================================================
export type BusinessModel = 'B2B' | 'B2C' | 'B2B_B2C'
export type OfferingType =
| 'app_mobile' // Mobile App
| 'app_web' // Web Application
| 'website' // Website/Landing Pages
| 'webshop' // E-Commerce
| 'hardware' // Hardware sales
| 'software_saas' // SaaS/Software products
| 'software_onpremise' // On-Premise Software
| 'services_consulting' // Consulting/Professional Services
| 'services_agency' // Agency Services
| 'internal_only' // Internal applications only
export type TargetMarket =
| 'germany_only' // Only Germany
| 'dach' // Germany, Austria, Switzerland
| 'eu' // European Union
| 'ewr' // European Economic Area (EU + Iceland, Liechtenstein, Norway)
| 'eu_uk' // EU + United Kingdom
| 'worldwide' // Global operations
export type CompanySize = 'micro' | 'small' | 'medium' | 'large' | 'enterprise'
export type LegalForm =
| 'einzelunternehmen' // Sole proprietorship
| 'gbr' // GbR
| 'ohg' // OHG
| 'kg' // KG
| 'gmbh' // GmbH
| 'ug' // UG (haftungsbeschraenkt)
| 'ag' // AG
| 'gmbh_co_kg' // GmbH & Co. KG
| 'ev' // e.V. (Verein)
| 'stiftung' // Foundation
| 'other' // Other
// =============================================================================
// COMPANY PROFILE INTERFACE
// =============================================================================
export interface CompanyProfile {
// Basic Info
companyName: string
legalForm: LegalForm
industry: string // Free text or NACE code
foundedYear: number | null
// Business Model
businessModel: BusinessModel
offerings: OfferingType[]
// Size & Scope
companySize: CompanySize
employeeCount: string // Range: "1-9", "10-49", "50-249", "250-999", "1000+"
annualRevenue: string // Range: "< 2 Mio", "2-10 Mio", "10-50 Mio", "> 50 Mio"
// Locations
headquartersCountry: string // ISO country code, e.g., "DE"
headquartersCity: string
hasInternationalLocations: boolean
internationalCountries: string[] // ISO country codes
// Target Markets & Legal Scope
targetMarkets: TargetMarket[]
primaryJurisdiction: string // Which law primarily applies: "DE", "AT", "CH", etc.
// Data Processing Role
isDataController: boolean // Verantwortlicher (Art. 4 Nr. 7 DSGVO)
isDataProcessor: boolean // Auftragsverarbeiter (Art. 4 Nr. 8 DSGVO)
// AI Usage
usesAI: boolean
aiUseCases: string[] // Brief descriptions
// Contact Persons
dpoName: string | null // Data Protection Officer
dpoEmail: string | null
legalContactName: string | null
legalContactEmail: string | null
// Completion Status
isComplete: boolean
completedAt: Date | null
}
// =============================================================================
// COVERAGE ASSESSMENT
// =============================================================================
export interface SDKCoverageAssessment {
isFullyCovered: boolean
coveredRegulations: string[]
partiallyCoveredRegulations: string[]
notCoveredRegulations: string[]
requiresLegalCounsel: boolean
reasons: string[]
recommendations: string[]
}
// =============================================================================
// DISPLAY LABELS
// =============================================================================
export const COMPANY_SIZE_LABELS: Record<CompanySize, string> = {
micro: 'Kleinstunternehmen (< 10 MA)',
small: 'Kleinunternehmen (10-49 MA)',
medium: 'Mittelstand (50-249 MA)',
large: 'Gro\u00dfunternehmen (250-999 MA)',
enterprise: 'Konzern (1000+ MA)',
}
export const BUSINESS_MODEL_LABELS: Record<BusinessModel, string> = {
B2B: 'B2B (Gesch\u00e4ftskunden)',
B2C: 'B2C (Privatkunden)',
B2B_B2C: 'B2B und B2C',
}
export const OFFERING_TYPE_LABELS: Record<OfferingType, { label: string; description: string }> = {
app_mobile: { label: 'Mobile App', description: 'iOS/Android Anwendungen' },
app_web: { label: 'Web-Anwendung', description: 'Browser-basierte Software' },
website: { label: 'Website', description: 'Informationsseiten, Landing Pages' },
webshop: { label: 'Online-Shop', description: 'E-Commerce, Produktverkauf' },
hardware: { label: 'Hardware-Verkauf', description: 'Physische Produkte' },
software_saas: { label: 'SaaS/Cloud', description: 'Software as a Service' },
software_onpremise: { label: 'On-Premise Software', description: 'Lokale Installation' },
services_consulting: { label: 'Beratung', description: 'Consulting, Professional Services' },
services_agency: { label: 'Agentur', description: 'Marketing, Design, Entwicklung' },
internal_only: { label: 'Nur intern', description: 'Interne Unternehmensanwendungen' },
}
export const TARGET_MARKET_LABELS: Record<TargetMarket, { label: string; description: string; regulations: string[] }> = {
germany_only: {
label: 'Nur Deutschland',
description: 'Verkauf nur in Deutschland',
regulations: ['DSGVO', 'BDSG', 'TTDSG', 'AI Act'],
},
dach: {
label: 'DACH-Region',
description: 'Deutschland, \u00d6sterreich, Schweiz',
regulations: ['DSGVO', 'BDSG', 'DSG (AT)', 'DSG (CH)', 'AI Act'],
},
eu: {
label: 'Europ\u00e4ische Union',
description: 'Alle EU-Mitgliedsstaaten',
regulations: ['DSGVO', 'AI Act', 'NIS2', 'DMA/DSA'],
},
ewr: {
label: 'EWR',
description: 'EU + Island, Liechtenstein, Norwegen',
regulations: ['DSGVO', 'AI Act', 'NIS2', 'EWR-Sonderregelungen'],
},
eu_uk: {
label: 'EU + Gro\u00dfbritannien',
description: 'EU plus Vereinigtes K\u00f6nigreich',
regulations: ['DSGVO', 'UK GDPR', 'AI Act', 'UK AI Framework'],
},
worldwide: {
label: 'Weltweit',
description: 'Globaler Verkauf/Betrieb',
regulations: ['DSGVO', 'CCPA', 'LGPD', 'POPIA', 'und weitere...'],
},
}
// =============================================================================
// SDK PACKAGE DEFINITION
// =============================================================================
export interface SDKPackage {
id: SDKPackageId
order: number
name: string
nameShort: string
description: string
icon: string
result: string
}
export const SDK_PACKAGES: SDKPackage[] = [
{
id: 'vorbereitung',
order: 1,
name: 'Vorbereitung',
nameShort: 'Vorbereitung',
description: 'Grundlagen erfassen, Ausgangssituation verstehen',
icon: '\ud83c\udfaf',
result: 'Klares Verst\u00e4ndnis, welche Regulierungen greifen',
},
{
id: 'analyse',
order: 2,
name: 'Analyse',
nameShort: 'Analyse',
description: 'Risiken erkennen, Anforderungen ableiten',
icon: '\ud83d\udd0d',
result: 'Vollst\u00e4ndige Risikobewertung, Audit-Ready',
},
{
id: 'dokumentation',
order: 3,
name: 'Dokumentation',
nameShort: 'Doku',
description: 'Rechtliche Pflichtnachweise erstellen',
icon: '\ud83d\udccb',
result: 'DSFA, TOMs, VVT, L\u00f6schkonzept',
},
{
id: 'rechtliche-texte',
order: 4,
name: 'Rechtliche Texte',
nameShort: 'Legal',
description: 'Kundenf\u00e4hige Dokumente generieren',
icon: '\ud83d\udcdd',
result: 'AGB, DSI, Nutzungsbedingungen, Cookie-Banner (Code)',
},
{
id: 'betrieb',
order: 5,
name: 'Betrieb',
nameShort: 'Betrieb',
description: 'Laufender Compliance-Betrieb',
icon: '\u2699\ufe0f',
result: 'DSR-Portal, Eskalationsprozesse, Vendor-Management',
},
]

View File

@@ -0,0 +1,85 @@
/**
* Compliance types
*
* Service modules, requirements, controls, evidence,
* and audit checklist items for compliance tracking.
*/
import type {
RiskSeverity,
RequirementStatus,
ControlType,
ImplementationStatus,
EvidenceType,
} from './core'
// =============================================================================
// SERVICE MODULES
// =============================================================================
export interface ServiceModule {
id: string
name: string
description: string
regulations: string[]
criticality: RiskSeverity
processesPersonalData: boolean
hasAIComponents: boolean
}
// =============================================================================
// REQUIREMENTS & CONTROLS
// =============================================================================
export interface Requirement {
id: string
regulation: string
article: string
title: string
description: string
criticality: RiskSeverity
applicableModules: string[]
status: RequirementStatus
controls: string[]
}
export interface Control {
id: string
name: string
description: string
type: ControlType
category: string
implementationStatus: ImplementationStatus
effectiveness: RiskSeverity
evidence: string[]
owner: string | null
dueDate: Date | null
}
export interface Evidence {
id: string
controlId: string
type: EvidenceType
name: string
description: string
fileUrl: string | null
validFrom: Date
validUntil: Date | null
uploadedBy: string
uploadedAt: Date
}
// =============================================================================
// CHECKLIST
// =============================================================================
export interface ChecklistItem {
id: string
requirementId: string
title: string
description: string
status: 'PENDING' | 'PASSED' | 'FAILED' | 'NOT_APPLICABLE'
notes: string
verifiedBy: string | null
verifiedAt: Date | null
}

View File

@@ -0,0 +1,88 @@
/**
* Core SDK enums and base types
*
* Shared enums used across multiple domains: subscription tiers,
* phases, severity levels, status codes, and style enums.
*/
// =============================================================================
// ENUMS — Subscription & Phase
// =============================================================================
export type SubscriptionTier = 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'
export type SDKPhase = 1 | 2
export type SDKPackageId = 'vorbereitung' | 'analyse' | 'dokumentation' | 'rechtliche-texte' | 'betrieb'
export type CustomerType = 'new' | 'existing'
// =============================================================================
// ENUMS — Checkpoint & Validation
// =============================================================================
export type CheckpointType = 'REQUIRED' | 'RECOMMENDED' | 'OPTIONAL'
export type ReviewerType = 'NONE' | 'TEAM_LEAD' | 'DSB' | 'LEGAL'
export type ValidationSeverity = 'ERROR' | 'WARNING' | 'INFO'
// =============================================================================
// ENUMS — Risk
// =============================================================================
export type RiskSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
export type RiskLikelihood = 1 | 2 | 3 | 4 | 5
export type RiskImpact = 1 | 2 | 3 | 4 | 5
export type RiskStatus = 'IDENTIFIED' | 'ASSESSED' | 'MITIGATED' | 'ACCEPTED' | 'CLOSED'
export type MitigationType = 'AVOID' | 'TRANSFER' | 'MITIGATE' | 'ACCEPT'
// =============================================================================
// ENUMS — Implementation & Compliance
// =============================================================================
export type ImplementationStatus = 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED'
export type RequirementStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'IMPLEMENTED' | 'VERIFIED'
export type ControlType = 'TECHNICAL' | 'ORGANIZATIONAL' | 'PHYSICAL'
export type EvidenceType = 'DOCUMENT' | 'SCREENSHOT' | 'LOG' | 'CERTIFICATE' | 'AUDIT_REPORT'
// =============================================================================
// ENUMS — AI Act & DSFA
// =============================================================================
export type AIActRiskCategory = 'MINIMAL' | 'LIMITED' | 'HIGH' | 'UNACCEPTABLE'
export type DSFAStatus = 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED'
// =============================================================================
// ENUMS — Screening & Security
// =============================================================================
export type ScreeningStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED'
export type SecurityIssueSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
export type SecurityIssueStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'ACCEPTED'
// =============================================================================
// ENUMS — Cookie Banner
// =============================================================================
export type CookieBannerStyle = 'BANNER' | 'MODAL' | 'FLOATING'
export type CookieBannerPosition = 'TOP' | 'BOTTOM' | 'CENTER'
export type CookieBannerTheme = 'LIGHT' | 'DARK' | 'CUSTOM'
// =============================================================================
// ENUMS — Command Bar
// =============================================================================
export type CommandType = 'ACTION' | 'NAVIGATION' | 'SEARCH' | 'GENERATE' | 'HELP'

View File

@@ -0,0 +1,339 @@
/**
* Document Generator types (Legal Templates RAG)
*
* License types, template search, document generation,
* and template ingestion for the legal document generator.
*/
import type { CompanyProfile } from './company-profile'
// =============================================================================
// LICENSE & TEMPLATE ENUMS
// =============================================================================
/**
* License types for legal templates with compliance metadata
*/
export type LicenseType =
| 'public_domain' // SS5 UrhG German official works
| 'cc0' // CC0 1.0 Universal
| 'unlicense' // Unlicense (public domain)
| 'mit' // MIT License
| 'cc_by_4' // CC BY 4.0 International
| 'reuse_notice' // EU reuse notice (source required)
/**
* Template types available for document generation
*/
export type TemplateType =
| 'privacy_policy'
| 'terms_of_service'
| 'agb'
| 'cookie_banner'
| 'cookie_policy'
| 'impressum'
| 'widerruf'
| 'dpa'
| 'sla'
| 'nda'
| 'cloud_service_agreement'
| 'data_usage_clause'
| 'acceptable_use'
| 'community_guidelines'
| 'copyright_policy'
| 'clause'
/**
* Jurisdiction codes for legal documents
*/
export type Jurisdiction = 'DE' | 'AT' | 'CH' | 'EU' | 'US' | 'INTL'
// =============================================================================
// SEARCH & RESULTS
// =============================================================================
/**
* A single legal template search result from RAG
*/
export interface LegalTemplateResult {
id: string
score: number
text: string
documentTitle: string | null
templateType: TemplateType | null
clauseCategory: string | null
language: 'de' | 'en'
jurisdiction: Jurisdiction | null
// License information
licenseId: LicenseType | null
licenseName: string | null
licenseUrl: string | null
attributionRequired: boolean
attributionText: string | null
// Source information
sourceName: string | null
sourceUrl: string | null
sourceRepo: string | null
placeholders: string[]
// Document characteristics
isCompleteDocument: boolean
isModular: boolean
requiresCustomization: boolean
// Usage rights
outputAllowed: boolean
modificationAllowed: boolean
distortionProhibited: boolean
}
/**
* Search request for legal templates
*/
export interface TemplateSearchRequest {
query: string
templateType?: TemplateType
licenseTypes?: LicenseType[]
language?: 'de' | 'en'
jurisdiction?: Jurisdiction
attributionRequired?: boolean
limit?: number
}
// =============================================================================
// DOCUMENT GENERATION
// =============================================================================
/**
* Reference to a template used in document generation (for attribution)
*/
export interface TemplateReference {
templateId: string
sourceName: string
sourceUrl: string
licenseId: LicenseType
licenseName: string
attributionRequired: boolean
attributionText: string | null
usedAt: string // ISO timestamp
}
/**
* A customization applied to a generated document
*/
export interface DocumentCustomization {
type: 'add_section' | 'modify_section' | 'remove_section' | 'replace_placeholder'
section: string | null
originalText: string | null
newText: string | null
reason: string | null
appliedAt: string
}
/**
* A generated document with attribution tracking
*/
export interface GeneratedDocument {
id: string
documentType: TemplateType
title: string
content: string
language: 'de' | 'en'
jurisdiction: Jurisdiction
// Templates and sources used
usedTemplates: TemplateReference[]
// Generated attribution footer
attributionFooter: string
// Customization
placeholderValues: Record<string, string>
customizations: DocumentCustomization[]
// Metadata
generatedAt: string
generatedBy: string
version: number
}
/**
* Document generation request
*/
export interface DocumentGenerationRequest {
documentType: TemplateType
language: 'de' | 'en'
jurisdiction: Jurisdiction
templateIds: string[] // Selected template IDs to use
placeholderValues: Record<string, string>
companyProfile?: Partial<CompanyProfile> // For auto-filling placeholders
additionalContext?: string
}
// =============================================================================
// DOCUMENT GENERATOR STATE
// =============================================================================
/**
* State for the document generator feature
*/
export interface DocumentGeneratorState {
// Search state
searchQuery: string
searchResults: LegalTemplateResult[]
selectedTemplates: string[] // Template IDs
// Current document being generated
currentDocumentType: TemplateType | null
currentLanguage: 'de' | 'en'
currentJurisdiction: Jurisdiction
// Editor state
editorContent: string
editorMode: 'preview' | 'edit'
unsavedChanges: boolean
// Placeholder values
placeholderValues: Record<string, string>
// Generated documents history
generatedDocuments: GeneratedDocument[]
// UI state
isGenerating: boolean
isSearching: boolean
lastError: string | null
}
// =============================================================================
// TEMPLATE SOURCES & INGESTION
// =============================================================================
/**
* Source configuration for legal templates
*/
export interface TemplateSource {
name: string
description: string
licenseType: LicenseType
licenseName: string
templateTypes: TemplateType[]
languages: ('de' | 'en')[]
jurisdiction: Jurisdiction
repoUrl: string | null
webUrl: string | null
priority: number
enabled: boolean
attributionRequired: boolean
}
/**
* Result of ingesting a single source
*/
export interface SourceIngestionResult {
status: 'pending' | 'running' | 'completed' | 'failed'
documentsFound: number
chunksIndexed: number
errors: string[]
}
/**
* Status of template ingestion
*/
export interface TemplateIngestionStatus {
running: boolean
lastRun: string | null
currentSource: string | null
results: Record<string, SourceIngestionResult>
}
/**
* Statistics for the legal templates collection
*/
export interface TemplateCollectionStats {
collection: string
vectorsCount: number
pointsCount: number
status: string
templateTypes: Record<TemplateType, number>
languages: Record<string, number>
licenses: Record<LicenseType, number>
}
// =============================================================================
// DISPLAY LABELS & DEFAULTS
// =============================================================================
/**
* Default placeholder values commonly used in legal documents
*/
export const DEFAULT_PLACEHOLDERS: Record<string, string> = {
'[COMPANY_NAME]': '',
'[FIRMENNAME]': '',
'[ADDRESS]': '',
'[ADRESSE]': '',
'[EMAIL]': '',
'[PHONE]': '',
'[TELEFON]': '',
'[WEBSITE]': '',
'[LEGAL_REPRESENTATIVE]': '',
'[GESCHAEFTSFUEHRER]': '',
'[REGISTER_COURT]': '',
'[REGISTERGERICHT]': '',
'[REGISTER_NUMBER]': '',
'[REGISTERNUMMER]': '',
'[VAT_ID]': '',
'[UST_ID]': '',
'[DPO_NAME]': '',
'[DSB_NAME]': '',
'[DPO_EMAIL]': '',
'[DSB_EMAIL]': '',
}
/**
* Template type labels for display
*/
export const TEMPLATE_TYPE_LABELS: Record<TemplateType, string> = {
privacy_policy: 'Datenschutzerkl\u00e4rung',
terms_of_service: 'Nutzungsbedingungen',
agb: 'Allgemeine Gesch\u00e4ftsbedingungen',
cookie_banner: 'Cookie-Banner',
cookie_policy: 'Cookie-Richtlinie',
impressum: 'Impressum',
widerruf: 'Widerrufsbelehrung',
dpa: 'Auftragsverarbeitungsvertrag',
sla: 'Service Level Agreement',
nda: 'Geheimhaltungsvereinbarung',
cloud_service_agreement: 'Cloud-Dienstleistungsvertrag',
data_usage_clause: 'Datennutzungsklausel',
acceptable_use: 'Acceptable Use Policy',
community_guidelines: 'Community-Richtlinien',
copyright_policy: 'Urheberrechtsrichtlinie',
clause: 'Vertragsklausel',
}
/**
* License type labels for display
*/
export const LICENSE_TYPE_LABELS: Record<LicenseType, string> = {
public_domain: 'Public Domain (\u00a75 UrhG)',
cc0: 'CC0 1.0 Universal',
unlicense: 'Unlicense',
mit: 'MIT License',
cc_by_4: 'CC BY 4.0 International',
reuse_notice: 'EU Reuse Notice',
}
/**
* Jurisdiction labels for display
*/
export const JURISDICTION_LABELS: Record<Jurisdiction, string> = {
DE: 'Deutschland',
AT: '\u00d6sterreich',
CH: 'Schweiz',
EU: 'Europ\u00e4ische Union',
US: 'United States',
INTL: 'International',
}

View File

@@ -0,0 +1,239 @@
/**
* Documentation & Legal types
*
* TOMs, retention policies, VVT processing activities,
* legal documents, cookie banner, consent/DSR,
* imported documents, gap analysis, and escalation workflows.
*/
import type {
RiskSeverity,
ImplementationStatus,
CookieBannerStyle,
CookieBannerPosition,
CookieBannerTheme,
SDKPackageId,
} from './core'
// =============================================================================
// TOMs & RETENTION
// =============================================================================
export interface TOM {
id: string
category: string
name: string
description: string
type: 'TECHNICAL' | 'ORGANIZATIONAL'
implementationStatus: ImplementationStatus
priority: RiskSeverity
responsiblePerson: string | null
implementationDate: Date | null
reviewDate: Date | null
evidence: string[]
}
export interface RetentionPolicy {
id: string
dataCategory: string
description: string
legalBasis: string
retentionPeriod: string
deletionMethod: string
exceptions: string[]
}
// =============================================================================
// VVT (Processing Register)
// =============================================================================
export interface ProcessingActivity {
id: string
name: string
purpose: string
legalBasis: string
dataCategories: string[]
dataSubjects: string[]
recipients: string[]
thirdCountryTransfers: boolean
retentionPeriod: string
technicalMeasures: string[]
organizationalMeasures: string[]
}
// =============================================================================
// LEGAL DOCUMENTS
// =============================================================================
export interface LegalDocument {
id: string
type: 'AGB' | 'PRIVACY_POLICY' | 'TERMS_OF_USE' | 'IMPRINT' | 'COOKIE_POLICY'
title: string
content: string
version: string
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED'
publishedAt: Date | null
createdAt: Date
updatedAt: Date
}
// =============================================================================
// COOKIE BANNER
// =============================================================================
export interface Cookie {
id: string
name: string
provider: string
purpose: string
expiry: string
type: 'NECESSARY' | 'FUNCTIONAL' | 'ANALYTICS' | 'MARKETING'
}
export interface CookieCategory {
id: string
name: string
description: string
required: boolean
cookies: Cookie[]
}
export interface CookieBannerTexts {
title: string
description: string
acceptAll: string
rejectAll: string
settings: string
save: string
}
export interface CookieBannerGeneratedCode {
html: string
css: string
js: string
}
export interface CookieBannerConfig {
id: string
style: CookieBannerStyle
position: CookieBannerPosition
theme: CookieBannerTheme
texts: CookieBannerTexts
categories: CookieCategory[]
generatedCode: CookieBannerGeneratedCode | null
}
// =============================================================================
// CONSENT & DSR
// =============================================================================
export interface ConsentRecord {
id: string
userId: string
documentId: string
documentVersion: string
consentType: string
granted: boolean
grantedAt: Date
revokedAt: Date | null
ipAddress: string | null
userAgent: string | null
}
export interface DSRRequest {
id: string
type: 'ACCESS' | 'RECTIFICATION' | 'ERASURE' | 'PORTABILITY' | 'RESTRICTION' | 'OBJECTION'
status: 'RECEIVED' | 'VERIFIED' | 'PROCESSING' | 'COMPLETED' | 'REJECTED'
requesterEmail: string
requesterName: string
requestedAt: Date
dueDate: Date
completedAt: Date | null
notes: string
}
export interface DSRConfig {
id: string
enabled: boolean
portalUrl: string
emailTemplates: Record<string, string>
automatedResponses: boolean
verificationRequired: boolean
}
// =============================================================================
// IMPORTED DOCUMENTS (fuer Bestandskunden)
// =============================================================================
export type ImportedDocumentType =
| 'DSFA'
| 'TOM'
| 'VVT'
| 'AGB'
| 'PRIVACY_POLICY'
| 'COOKIE_POLICY'
| 'RISK_ASSESSMENT'
| 'AUDIT_REPORT'
| 'OTHER'
export interface ImportedDocument {
id: string
name: string
type: ImportedDocumentType
fileUrl: string
uploadedAt: Date
analyzedAt: Date | null
analysisResult: DocumentAnalysisResult | null
}
export interface DocumentAnalysisResult {
detectedType: ImportedDocumentType
confidence: number
extractedEntities: string[]
gaps: GapItem[]
recommendations: string[]
}
export interface GapItem {
id: string
category: string
description: string
severity: RiskSeverity
regulation: string
requiredAction: string
relatedStepId: string | null
}
export interface GapAnalysis {
id: string
createdAt: Date
totalGaps: number
criticalGaps: number
highGaps: number
mediumGaps: number
lowGaps: number
gaps: GapItem[]
recommendedPackages: SDKPackageId[]
}
// =============================================================================
// ESCALATIONS
// =============================================================================
export interface EscalationWorkflow {
id: string
name: string
description: string
triggerConditions: string[]
steps: EscalationStep[]
enabled: boolean
}
export interface EscalationStep {
id: string
order: number
action: string
assignee: string
timeLimit: string // ISO 8601 Duration
escalateOnTimeout: boolean
}

View File

@@ -0,0 +1,263 @@
/**
* DSFA RAG types (Source Attribution & Corpus Management)
*
* Types for the DSFA (Data Protection Impact Assessment) RAG pipeline:
* source documents, chunks, search results, corpus statistics,
* and ingestion management.
*/
// =============================================================================
// DSFA ENUMS
// =============================================================================
/**
* License codes for DSFA source documents
*/
export type DSFALicenseCode =
| 'DL-DE-BY-2.0' // Datenlizenz Deutschland -- Namensnennung
| 'DL-DE-ZERO-2.0' // Datenlizenz Deutschland -- Zero
| 'CC-BY-4.0' // Creative Commons Attribution 4.0
| 'EDPB-LICENSE' // EDPB Document License
| 'PUBLIC_DOMAIN' // Public Domain
| 'PROPRIETARY' // Internal/Proprietary
/**
* Document types in the DSFA corpus
*/
export type DSFADocumentType = 'guideline' | 'checklist' | 'regulation' | 'template'
/**
* Category for DSFA chunks (for filtering)
*/
export type DSFACategory =
| 'threshold_analysis'
| 'risk_assessment'
| 'mitigation'
| 'consultation'
| 'documentation'
| 'process'
| 'criteria'
// =============================================================================
// DSFA SOURCE & DOCUMENTS
// =============================================================================
/**
* DSFA source registry entry
*/
export interface DSFASource {
id: string
sourceCode: string
name: string
fullName?: string
organization?: string
sourceUrl?: string
eurLexCelex?: string
licenseCode: DSFALicenseCode
licenseName: string
licenseUrl?: string
attributionRequired: boolean
attributionText: string
documentType?: DSFADocumentType
language: string
}
/**
* DSFA document entry
*/
export interface DSFADocument {
id: string
sourceId: string
title: string
description?: string
fileName?: string
fileType?: string
fileSizeBytes?: number
minioBucket: string
minioPath?: string
originalUrl?: string
ocrProcessed: boolean
textExtracted: boolean
chunksGenerated: number
lastIndexedAt?: string
metadata: Record<string, unknown>
createdAt: string
updatedAt: string
}
// =============================================================================
// DSFA CHUNKS & SEARCH
// =============================================================================
/**
* DSFA chunk with full attribution
*/
export interface DSFAChunk {
chunkId: string
content: string
sectionTitle?: string
pageNumber?: number
category?: DSFACategory
documentId: string
documentTitle?: string
sourceId: string
sourceCode: string
sourceName: string
attributionText: string
licenseCode: DSFALicenseCode
licenseName: string
licenseUrl?: string
attributionRequired: boolean
sourceUrl?: string
documentType?: DSFADocumentType
}
/**
* DSFA search result with score and attribution
*/
export interface DSFASearchResult {
chunkId: string
content: string
score: number
sourceCode: string
sourceName: string
attributionText: string
licenseCode: DSFALicenseCode
licenseName: string
licenseUrl?: string
attributionRequired: boolean
sourceUrl?: string
documentType?: DSFADocumentType
category?: DSFACategory
sectionTitle?: string
pageNumber?: number
}
/**
* DSFA search response with aggregated attribution
*/
export interface DSFASearchResponse {
query: string
results: DSFASearchResult[]
totalResults: number
licensesUsed: string[]
attributionNotice: string
}
// =============================================================================
// DSFA STATISTICS & INGESTION
// =============================================================================
/**
* Source statistics for dashboard
*/
export interface DSFASourceStats {
sourceId: string
sourceCode: string
name: string
organization?: string
licenseCode: DSFALicenseCode
documentType?: DSFADocumentType
documentCount: number
chunkCount: number
lastIndexedAt?: string
}
/**
* Corpus statistics for dashboard
*/
export interface DSFACorpusStats {
sources: DSFASourceStats[]
totalSources: number
totalDocuments: number
totalChunks: number
qdrantCollection: string
qdrantPointsCount: number
qdrantStatus: string
}
/**
* License information
*/
export interface DSFALicenseInfo {
code: DSFALicenseCode
name: string
url?: string
attributionRequired: boolean
modificationAllowed: boolean
commercialUse: boolean
}
/**
* Ingestion request for DSFA documents
*/
export interface DSFAIngestRequest {
documentUrl?: string
documentText?: string
title?: string
}
/**
* Ingestion response
*/
export interface DSFAIngestResponse {
sourceCode: string
documentId?: string
chunksCreated: number
message: string
}
/**
* Props for SourceAttribution component
*/
export interface SourceAttributionProps {
sources: Array<{
sourceCode: string
sourceName: string
attributionText: string
licenseCode: DSFALicenseCode
sourceUrl?: string
score?: number
}>
compact?: boolean
showScores?: boolean
}
// =============================================================================
// DISPLAY LABELS
// =============================================================================
/**
* License code display labels
*/
export const DSFA_LICENSE_LABELS: Record<DSFALicenseCode, string> = {
'DL-DE-BY-2.0': 'Datenlizenz DE \u2013 Namensnennung 2.0',
'DL-DE-ZERO-2.0': 'Datenlizenz DE \u2013 Zero 2.0',
'CC-BY-4.0': 'CC BY 4.0 International',
'EDPB-LICENSE': 'EDPB Document License',
'PUBLIC_DOMAIN': 'Public Domain',
'PROPRIETARY': 'Proprietary',
}
/**
* Document type display labels
*/
export const DSFA_DOCUMENT_TYPE_LABELS: Record<DSFADocumentType, string> = {
guideline: 'Leitlinie',
checklist: 'Pr\u00fcfliste',
regulation: 'Verordnung',
template: 'Vorlage',
}
/**
* Category display labels
*/
export const DSFA_CATEGORY_LABELS: Record<DSFACategory, string> = {
threshold_analysis: 'Schwellwertanalyse',
risk_assessment: 'Risikobewertung',
mitigation: 'Risikominderung',
consultation: 'Beh\u00f6rdenkonsultation',
documentation: 'Dokumentation',
process: 'Prozessschritte',
criteria: 'Kriterien',
}

View File

@@ -0,0 +1,39 @@
/**
* DSFA (Datenschutz-Folgenabschaetzung) types
*
* Data Protection Impact Assessment sections,
* approval workflow, and document structure.
*/
import type { DSFAStatus } from './core'
// =============================================================================
// DSFA
// =============================================================================
export interface DSFASection {
id: string
title: string
content: string
status: 'DRAFT' | 'COMPLETED'
order: number
}
export interface DSFAApproval {
id: string
approver: string
role: string
status: 'PENDING' | 'APPROVED' | 'REJECTED'
comment: string | null
approvedAt: Date | null
}
export interface DSFA {
id: string
status: DSFAStatus
version: number
sections: DSFASection[]
approvals: DSFAApproval[]
createdAt: Date
updatedAt: Date
}

View File

@@ -0,0 +1,187 @@
/**
* SDK Helper Functions
*
* Navigation helpers, risk calculation, completion tracking,
* and package management utilities.
*/
import type { SDKPhase, SDKPackageId, RiskLikelihood, RiskImpact, RiskSeverity, CustomerType } from './core'
import type { SDKStep, SDK_STEPS } from './sdk-flow'
import type { SDKPackage, SDK_PACKAGES } from './company-profile'
import type { Risk } from './risk'
import type { SDKState } from './state'
// Re-import values (not just types) for runtime use
// We need the actual arrays, not just the types
import { SDK_STEPS as STEPS } from './sdk-flow'
import { SDK_PACKAGES as PACKAGES } from './company-profile'
// =============================================================================
// STEP NAVIGATION
// =============================================================================
export function getStepById(stepId: string): SDKStep | undefined {
return STEPS.find(s => s.id === stepId)
}
export function getStepByUrl(url: string): SDKStep | undefined {
return STEPS.find(s => s.url === url)
}
export function getStepsForPhase(phase: SDKPhase): SDKStep[] {
return STEPS.filter(s => s.phase === phase).sort((a, b) => a.order - b.order)
}
export function getNextStep(currentStepId: string): SDKStep | undefined {
const currentStep = getStepById(currentStepId)
if (!currentStep) return undefined
const stepsInPhase = getStepsForPhase(currentStep.phase)
const currentIndex = stepsInPhase.findIndex(s => s.id === currentStepId)
if (currentIndex < stepsInPhase.length - 1) {
return stepsInPhase[currentIndex + 1]
}
// Move to next phase
if (currentStep.phase === 1) {
return getStepsForPhase(2)[0]
}
return undefined
}
export function getPreviousStep(currentStepId: string): SDKStep | undefined {
const currentStep = getStepById(currentStepId)
if (!currentStep) return undefined
const stepsInPhase = getStepsForPhase(currentStep.phase)
const currentIndex = stepsInPhase.findIndex(s => s.id === currentStepId)
if (currentIndex > 0) {
return stepsInPhase[currentIndex - 1]
}
// Move to previous phase
if (currentStep.phase === 2) {
const phase1Steps = getStepsForPhase(1)
return phase1Steps[phase1Steps.length - 1]
}
return undefined
}
// =============================================================================
// RISK CALCULATION
// =============================================================================
export function calculateRiskScore(likelihood: RiskLikelihood, impact: RiskImpact): number {
return likelihood * impact
}
export function getRiskSeverityFromScore(score: number): RiskSeverity {
if (score >= 20) return 'CRITICAL'
if (score >= 12) return 'HIGH'
if (score >= 6) return 'MEDIUM'
return 'LOW'
}
export function calculateResidualRisk(risk: Risk): number {
const inherentScore = calculateRiskScore(risk.likelihood, risk.impact)
const totalEffectiveness = risk.mitigation
.filter(m => m.status === 'COMPLETED')
.reduce((sum, m) => sum + m.effectiveness, 0)
const effectivenessMultiplier = Math.min(totalEffectiveness, 100) / 100
return Math.max(1, Math.round(inherentScore * (1 - effectivenessMultiplier)))
}
// =============================================================================
// COMPLETION TRACKING
// =============================================================================
export function getCompletionPercentage(state: SDKState): number {
const totalSteps = STEPS.length
const completedSteps = state.completedSteps.length
return Math.round((completedSteps / totalSteps) * 100)
}
export function getPhaseCompletionPercentage(state: SDKState, phase: SDKPhase): number {
const phaseSteps = getStepsForPhase(phase)
const completedPhaseSteps = phaseSteps.filter(s => state.completedSteps.includes(s.id))
return Math.round((completedPhaseSteps.length / phaseSteps.length) * 100)
}
// =============================================================================
// PACKAGE HELPERS
// =============================================================================
export function getPackageById(packageId: SDKPackageId): SDKPackage | undefined {
return PACKAGES.find(p => p.id === packageId)
}
export function getStepsForPackage(packageId: SDKPackageId): SDKStep[] {
return STEPS.filter(s => s.package === packageId).sort((a, b) => a.order - b.order)
}
export function getPackageCompletionPercentage(state: SDKState, packageId: SDKPackageId): number {
const packageSteps = getStepsForPackage(packageId)
if (packageSteps.length === 0) return 0
const completedPackageSteps = packageSteps.filter(s => state.completedSteps.includes(s.id))
return Math.round((completedPackageSteps.length / packageSteps.length) * 100)
}
export function getCurrentPackage(currentStepId: string): SDKPackage | undefined {
const step = getStepById(currentStepId)
if (!step) return undefined
return getPackageById(step.package)
}
export function getNextPackageStep(currentStepId: string): SDKStep | undefined {
const currentStep = getStepById(currentStepId)
if (!currentStep) return undefined
const packageSteps = getStepsForPackage(currentStep.package)
const currentIndex = packageSteps.findIndex(s => s.id === currentStepId)
// Next step in same package
if (currentIndex < packageSteps.length - 1) {
return packageSteps[currentIndex + 1]
}
// Move to next package
const currentPackage = getPackageById(currentStep.package)
if (!currentPackage) return undefined
const nextPackage = PACKAGES.find(p => p.order === currentPackage.order + 1)
if (!nextPackage) return undefined
const nextPackageSteps = getStepsForPackage(nextPackage.id)
return nextPackageSteps[0]
}
export function isPackageUnlocked(state: SDKState, packageId: SDKPackageId): boolean {
if (state.preferences?.allowParallelWork) return true
const currentPackage = getPackageById(packageId)
if (!currentPackage) return false
// First package is always unlocked
if (currentPackage.order === 1) return true
// Previous package must be completed
const prevPackage = PACKAGES.find(p => p.order === currentPackage.order - 1)
if (!prevPackage) return true
return getPackageCompletionPercentage(state, prevPackage.id) === 100
}
export function getVisibleStepsForCustomerType(customerType: CustomerType): SDKStep[] {
return STEPS.filter(step => {
// Import step is only for existing customers
if (step.id === 'import') {
return customerType === 'existing'
}
return true
})
}

View File

@@ -0,0 +1,22 @@
/**
* SDK Types — Barrel Export
*
* Re-exports all domain-specific type modules so consumers
* can import from `@/lib/sdk/types` or `./types` as before.
*/
export * from './core'
export * from './company-profile'
export * from './sdk-flow'
export * from './checkpoint'
export * from './assessment'
export * from './screening-security'
export * from './compliance'
export * from './risk'
export * from './ai-act-obligations'
export * from './dsfa'
export * from './documentation'
export * from './state'
export * from './helpers'
export * from './document-generator'
export * from './dsfa-rag'

View File

@@ -0,0 +1,42 @@
/**
* Risk Management types
*
* Risk assessment, mitigation tracking, and residual risk
* calculation structures.
*/
import type { RiskLikelihood, RiskImpact, RiskSeverity, RiskStatus, MitigationType } from './core'
// =============================================================================
// RISK MITIGATION
// =============================================================================
export interface RiskMitigation {
id: string
description: string
type: MitigationType
status: 'PLANNED' | 'IN_PROGRESS' | 'COMPLETED'
effectiveness: number // 0-100
controlId: string | null
}
// =============================================================================
// RISK
// =============================================================================
export interface Risk {
id: string
title: string
description: string
category: string
likelihood: RiskLikelihood
impact: RiskImpact
severity: RiskSeverity
inherentRiskScore: number
residualRiskScore: number
status: RiskStatus
mitigation: RiskMitigation[]
owner: string | null
relatedControls: string[]
relatedRequirements: string[]
}

View File

@@ -0,0 +1,99 @@
/**
* Screening & Security types
*
* SBOM analysis, vulnerability scanning, security issues,
* and backlog tracking for the screening pipeline.
*/
import type { ScreeningStatus, SecurityIssueSeverity, SecurityIssueStatus } from './core'
// =============================================================================
// SBOM
// =============================================================================
export interface Vulnerability {
id: string
cve: string
severity: SecurityIssueSeverity
title: string
description: string
cvss: number | null
fixedIn: string | null
}
export interface SBOMComponent {
name: string
version: string
type: 'library' | 'framework' | 'application' | 'container'
purl: string
licenses: string[]
vulnerabilities: Vulnerability[]
}
export interface SBOMDependency {
from: string
to: string
}
export interface SBOM {
format: 'CycloneDX' | 'SPDX'
version: string
components: SBOMComponent[]
dependencies: SBOMDependency[]
generatedAt: Date
}
// =============================================================================
// SECURITY SCAN
// =============================================================================
export interface SecurityScanResult {
totalIssues: number
critical: number
high: number
medium: number
low: number
issues: SecurityIssue[]
}
export interface SecurityIssue {
id: string
severity: SecurityIssueSeverity
title: string
description: string
cve: string | null
cvss: number | null
affectedComponent: string
remediation: string
status: SecurityIssueStatus
}
// =============================================================================
// SCREENING RESULT
// =============================================================================
export interface ScreeningResult {
id: string
status: ScreeningStatus
startedAt: Date
completedAt: Date | null
sbom: SBOM | null
securityScan: SecurityScanResult | null
error: string | null
}
// =============================================================================
// BACKLOG
// =============================================================================
export interface BacklogItem {
id: string
title: string
description: string
severity: SecurityIssueSeverity
securityIssueId: string
status: 'OPEN' | 'IN_PROGRESS' | 'DONE'
assignee: string | null
dueDate: Date | null
createdAt: Date
}

View File

@@ -0,0 +1,429 @@
/**
* SDK Flow & Navigation
*
* Step definitions, step ordering, and the SDK_STEPS constant
* that drives the entire compliance workflow.
*/
import type { SDKPhase, SDKPackageId } from './core'
// =============================================================================
// SDK STEP
// =============================================================================
export interface SDKStep {
id: string
phase: SDKPhase
package: SDKPackageId
order: number
name: string
nameShort: string
description: string
url: string
checkpointId: string
prerequisiteSteps: string[]
isOptional: boolean
}
// =============================================================================
// SDK_STEPS — All steps in order
// =============================================================================
export const SDK_STEPS: SDKStep[] = [
// =============================================================================
// PAKET 1: VORBEREITUNG (Foundation)
// =============================================================================
{
id: 'company-profile',
phase: 1,
package: 'vorbereitung',
order: 1,
name: 'Unternehmensprofil',
nameShort: 'Profil',
description: 'Gesch\u00e4ftsmodell, Gr\u00f6\u00dfe und Zielm\u00e4rkte erfassen',
url: '/sdk/company-profile',
checkpointId: 'CP-PROF',
prerequisiteSteps: [],
isOptional: false,
},
{
id: 'compliance-scope',
phase: 1,
package: 'vorbereitung',
order: 2,
name: 'Compliance Scope',
nameShort: 'Scope',
description: 'Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen',
url: '/sdk/compliance-scope',
checkpointId: 'CP-SCOPE',
prerequisiteSteps: ['company-profile'],
isOptional: false,
},
{
id: 'use-case-assessment',
phase: 1,
package: 'vorbereitung',
order: 3,
name: 'Anwendungsfall-Erfassung',
nameShort: 'Anwendung',
description: 'AI-Anwendungsf\u00e4lle strukturiert dokumentieren',
url: '/sdk/advisory-board',
checkpointId: 'CP-UC',
prerequisiteSteps: ['company-profile'],
isOptional: false,
},
{
id: 'import',
phase: 1,
package: 'vorbereitung',
order: 4,
name: 'Dokument-Import',
nameShort: 'Import',
description: 'Bestehende Dokumente hochladen (Bestandskunden)',
url: '/sdk/import',
checkpointId: 'CP-IMP',
prerequisiteSteps: ['use-case-assessment'],
isOptional: true, // Nur fuer Bestandskunden
},
{
id: 'screening',
phase: 1,
package: 'vorbereitung',
order: 5,
name: 'System Screening',
nameShort: 'Screening',
description: 'SBOM + Security Check',
url: '/sdk/screening',
checkpointId: 'CP-SCAN',
prerequisiteSteps: ['use-case-assessment'],
isOptional: false,
},
{
id: 'modules',
phase: 1,
package: 'vorbereitung',
order: 6,
name: 'Compliance Modules',
nameShort: 'Module',
description: 'Abgleich welche Regulierungen gelten',
url: '/sdk/modules',
checkpointId: 'CP-MOD',
prerequisiteSteps: ['screening'],
isOptional: false,
},
{
id: 'source-policy',
phase: 1,
package: 'vorbereitung',
order: 7,
name: 'Source Policy',
nameShort: 'Quellen',
description: 'Datenquellen-Governance & Whitelist',
url: '/sdk/source-policy',
checkpointId: 'CP-SPOL',
prerequisiteSteps: ['modules'],
isOptional: false,
},
// =============================================================================
// PAKET 2: ANALYSE (Assessment)
// =============================================================================
{
id: 'requirements',
phase: 1,
package: 'analyse',
order: 1,
name: 'Requirements',
nameShort: 'Anforderungen',
description: 'Pr\u00fcfaspekte aus Regulierungen ableiten',
url: '/sdk/requirements',
checkpointId: 'CP-REQ',
prerequisiteSteps: ['source-policy'],
isOptional: false,
},
{
id: 'controls',
phase: 1,
package: 'analyse',
order: 2,
name: 'Controls',
nameShort: 'Controls',
description: 'Erforderliche Ma\u00dfnahmen ermitteln',
url: '/sdk/controls',
checkpointId: 'CP-CTRL',
prerequisiteSteps: ['requirements'],
isOptional: false,
},
{
id: 'evidence',
phase: 1,
package: 'analyse',
order: 3,
name: 'Evidence',
nameShort: 'Nachweise',
description: 'Nachweise dokumentieren',
url: '/sdk/evidence',
checkpointId: 'CP-EVI',
prerequisiteSteps: ['controls'],
isOptional: false,
},
{
id: 'risks',
phase: 1,
package: 'analyse',
order: 4,
name: 'Risk Matrix',
nameShort: 'Risiken',
description: 'Risikobewertung & Residual Risk',
url: '/sdk/risks',
checkpointId: 'CP-RISK',
prerequisiteSteps: ['evidence'],
isOptional: false,
},
{
id: 'ai-act',
phase: 1,
package: 'analyse',
order: 5,
name: 'AI Act Klassifizierung',
nameShort: 'AI Act',
description: 'Risikostufe nach EU AI Act',
url: '/sdk/ai-act',
checkpointId: 'CP-AI',
prerequisiteSteps: ['risks'],
isOptional: false,
},
{
id: 'audit-checklist',
phase: 1,
package: 'analyse',
order: 6,
name: 'Audit Checklist',
nameShort: 'Checklist',
description: 'Pr\u00fcfliste generieren',
url: '/sdk/audit-checklist',
checkpointId: 'CP-CHK',
prerequisiteSteps: ['ai-act'],
isOptional: false,
},
{
id: 'audit-report',
phase: 1,
package: 'analyse',
order: 7,
name: 'Audit Report',
nameShort: 'Report',
description: 'Audit-Sitzungen & PDF-Report',
url: '/sdk/audit-report',
checkpointId: 'CP-AREP',
prerequisiteSteps: ['audit-checklist'],
isOptional: false,
},
// =============================================================================
// PAKET 3: DOKUMENTATION (Compliance Docs)
// =============================================================================
{
id: 'obligations',
phase: 2,
package: 'dokumentation',
order: 1,
name: 'Pflichten\u00fcbersicht',
nameShort: 'Pflichten',
description: 'NIS2, DSGVO, AI Act Pflichten',
url: '/sdk/obligations',
checkpointId: 'CP-OBL',
prerequisiteSteps: ['audit-report'],
isOptional: false,
},
{
id: 'dsfa',
phase: 2,
package: 'dokumentation',
order: 2,
name: 'DSFA',
nameShort: 'DSFA',
description: 'Datenschutz-Folgenabsch\u00e4tzung',
url: '/sdk/dsfa',
checkpointId: 'CP-DSFA',
prerequisiteSteps: ['obligations'],
isOptional: true, // Only if dsfa_recommended
},
{
id: 'tom',
phase: 2,
package: 'dokumentation',
order: 3,
name: 'TOMs',
nameShort: 'TOMs',
description: 'Technische & Org. Ma\u00dfnahmen',
url: '/sdk/tom',
checkpointId: 'CP-TOM',
prerequisiteSteps: ['dsfa'],
isOptional: false,
},
{
id: 'loeschfristen',
phase: 2,
package: 'dokumentation',
order: 4,
name: 'L\u00f6schfristen',
nameShort: 'L\u00f6schfristen',
description: 'Aufbewahrungsrichtlinien',
url: '/sdk/loeschfristen',
checkpointId: 'CP-RET',
prerequisiteSteps: ['tom'],
isOptional: false,
},
{
id: 'vvt',
phase: 2,
package: 'dokumentation',
order: 5,
name: 'Verarbeitungsverzeichnis',
nameShort: 'VVT',
description: 'Art. 30 DSGVO Dokumentation',
url: '/sdk/vvt',
checkpointId: 'CP-VVT',
prerequisiteSteps: ['loeschfristen'],
isOptional: false,
},
// =============================================================================
// PAKET 4: RECHTLICHE TEXTE (Legal Outputs)
// =============================================================================
{
id: 'einwilligungen',
phase: 2,
package: 'rechtliche-texte',
order: 1,
name: 'Einwilligungen',
nameShort: 'Einwilligungen',
description: 'Datenpunktkatalog & DSI-Generator',
url: '/sdk/einwilligungen',
checkpointId: 'CP-CONS',
prerequisiteSteps: ['vvt'],
isOptional: false,
},
{
id: 'consent',
phase: 2,
package: 'rechtliche-texte',
order: 2,
name: 'Rechtliche Vorlagen',
nameShort: 'Vorlagen',
description: 'AGB, Datenschutz, Nutzungsbedingungen',
url: '/sdk/consent',
checkpointId: 'CP-DOC',
prerequisiteSteps: ['einwilligungen'],
isOptional: false,
},
{
id: 'cookie-banner',
phase: 2,
package: 'rechtliche-texte',
order: 3,
name: 'Cookie Banner',
nameShort: 'Cookies',
description: 'Cookie-Consent Generator',
url: '/sdk/cookie-banner',
checkpointId: 'CP-COOK',
prerequisiteSteps: ['consent'],
isOptional: false,
},
{
id: 'document-generator',
phase: 2,
package: 'rechtliche-texte',
order: 4,
name: 'Dokumentengenerator',
nameShort: 'Generator',
description: 'Rechtliche Dokumente aus Vorlagen erstellen',
url: '/sdk/document-generator',
checkpointId: 'CP-DOCGEN',
prerequisiteSteps: ['cookie-banner'],
isOptional: true,
},
{
id: 'workflow',
phase: 2,
package: 'rechtliche-texte',
order: 5,
name: 'Document Workflow',
nameShort: 'Workflow',
description: 'Versionierung & Freigabe-Workflow',
url: '/sdk/workflow',
checkpointId: 'CP-WRKF',
prerequisiteSteps: ['document-generator'],
isOptional: false,
},
// =============================================================================
// PAKET 5: BETRIEB (Operations)
// =============================================================================
{
id: 'dsr',
phase: 2,
package: 'betrieb',
order: 1,
name: 'DSR Portal',
nameShort: 'DSR',
description: 'Betroffenenrechte-Portal',
url: '/sdk/dsr',
checkpointId: 'CP-DSR',
prerequisiteSteps: ['workflow'],
isOptional: false,
},
{
id: 'escalations',
phase: 2,
package: 'betrieb',
order: 2,
name: 'Escalations',
nameShort: 'Eskalationen',
description: 'Management-Workflows',
url: '/sdk/escalations',
checkpointId: 'CP-ESC',
prerequisiteSteps: ['dsr'],
isOptional: false,
},
{
id: 'vendor-compliance',
phase: 2,
package: 'betrieb',
order: 3,
name: 'Vendor Compliance',
nameShort: 'Vendor',
description: 'Dienstleister-Management',
url: '/sdk/vendor-compliance',
checkpointId: 'CP-VEND',
prerequisiteSteps: ['escalations'],
isOptional: false,
},
{
id: 'consent-management',
phase: 2,
package: 'betrieb',
order: 4,
name: 'Consent Verwaltung',
nameShort: 'Consent Mgmt',
description: 'Dokument-Lifecycle & DSGVO-Prozesse',
url: '/sdk/consent-management',
checkpointId: 'CP-CMGMT',
prerequisiteSteps: ['vendor-compliance'],
isOptional: false,
},
{
id: 'notfallplan',
phase: 2,
package: 'betrieb',
order: 5,
name: 'Notfallplan & Breach Response',
nameShort: 'Notfallplan',
description: 'Datenpannen-Management nach Art. 33/34 DSGVO',
url: '/sdk/notfallplan',
checkpointId: 'CP-NOTF',
prerequisiteSteps: ['consent-management'],
isOptional: false,
},
]

View File

@@ -0,0 +1,197 @@
/**
* SDK State & Actions
*
* Central SDK state interface, action discriminated union,
* user preferences, and command bar types.
*/
import type {
SubscriptionTier,
SDKPhase,
CustomerType,
CommandType,
} from './core'
import type { CompanyProfile } from './company-profile'
import type { CheckpointStatus } from './checkpoint'
import type { UseCaseAssessment } from './assessment'
import type { ScreeningResult, SecurityIssue, BacklogItem, SBOM } from './screening-security'
import type { ServiceModule, Requirement, Control, Evidence, ChecklistItem } from './compliance'
import type { Risk } from './risk'
import type { AIActResult, Obligation } from './ai-act-obligations'
import type { DSFA } from './dsfa'
import type {
TOM,
RetentionPolicy,
ProcessingActivity,
LegalDocument,
CookieBannerConfig,
ConsentRecord,
DSRConfig,
ImportedDocument,
GapAnalysis,
EscalationWorkflow,
} from './documentation'
// =============================================================================
// COMMAND BAR
// =============================================================================
export interface CommandSuggestion {
id: string
type: CommandType
label: string
description: string
shortcut?: string
icon?: string
action: () => void | Promise<void>
relevanceScore: number
}
export interface CommandHistory {
id: string
query: string
type: CommandType
timestamp: Date
success: boolean
}
// =============================================================================
// USER PREFERENCES
// =============================================================================
export interface UserPreferences {
language: 'de' | 'en'
theme: 'light' | 'dark' | 'system'
compactMode: boolean
showHints: boolean
autoSave: boolean
autoValidate: boolean
allowParallelWork: boolean // Erlaubt Navigation zu allen Schritten ohne Voraussetzungen
}
// =============================================================================
// SDK STATE
// =============================================================================
export interface SDKState {
// Metadata
version: string
lastModified: Date
// Tenant & User
tenantId: string
userId: string
subscription: SubscriptionTier
// Customer Type (new vs existing)
customerType: CustomerType | null
// Company Profile (collected before use cases)
companyProfile: CompanyProfile | null
// Compliance Scope (determines depth level L1-L4)
complianceScope: import('../compliance-scope-types').ComplianceScopeState | null
// Progress
currentPhase: SDKPhase
currentStep: string
completedSteps: string[]
checkpoints: Record<string, CheckpointStatus>
// Imported Documents (for existing customers)
importedDocuments: ImportedDocument[]
gapAnalysis: GapAnalysis | null
// Phase 1 Data
useCases: UseCaseAssessment[]
activeUseCase: string | null
screening: ScreeningResult | null
modules: ServiceModule[]
requirements: Requirement[]
controls: Control[]
evidence: Evidence[]
checklist: ChecklistItem[]
risks: Risk[]
// Phase 2 Data
aiActClassification: AIActResult | null
obligations: Obligation[]
dsfa: DSFA | null
toms: TOM[]
retentionPolicies: RetentionPolicy[]
vvt: ProcessingActivity[]
documents: LegalDocument[]
cookieBanner: CookieBannerConfig | null
consents: ConsentRecord[]
dsrConfig: DSRConfig | null
escalationWorkflows: EscalationWorkflow[]
// Security
sbom: SBOM | null
securityIssues: SecurityIssue[]
securityBacklog: BacklogItem[]
// UI State
commandBarHistory: CommandHistory[]
recentSearches: string[]
preferences: UserPreferences
}
// =============================================================================
// SDK ACTIONS
// =============================================================================
export type SDKAction =
| { type: 'SET_STATE'; payload: Partial<SDKState> }
| { type: 'SET_CURRENT_STEP'; payload: string }
| { type: 'COMPLETE_STEP'; payload: string }
| { type: 'SET_CHECKPOINT_STATUS'; payload: { id: string; status: CheckpointStatus } }
| { type: 'SET_CUSTOMER_TYPE'; payload: CustomerType }
| { type: 'SET_COMPANY_PROFILE'; payload: CompanyProfile }
| { type: 'UPDATE_COMPANY_PROFILE'; payload: Partial<CompanyProfile> }
| { type: 'SET_COMPLIANCE_SCOPE'; payload: import('../compliance-scope-types').ComplianceScopeState }
| { type: 'UPDATE_COMPLIANCE_SCOPE'; payload: Partial<import('../compliance-scope-types').ComplianceScopeState> }
| { type: 'ADD_IMPORTED_DOCUMENT'; payload: ImportedDocument }
| { type: 'UPDATE_IMPORTED_DOCUMENT'; payload: { id: string; data: Partial<ImportedDocument> } }
| { type: 'DELETE_IMPORTED_DOCUMENT'; payload: string }
| { type: 'SET_GAP_ANALYSIS'; payload: GapAnalysis }
| { type: 'ADD_USE_CASE'; payload: UseCaseAssessment }
| { type: 'UPDATE_USE_CASE'; payload: { id: string; data: Partial<UseCaseAssessment> } }
| { type: 'DELETE_USE_CASE'; payload: string }
| { type: 'SET_ACTIVE_USE_CASE'; payload: string | null }
| { type: 'SET_SCREENING'; payload: ScreeningResult }
| { type: 'ADD_MODULE'; payload: ServiceModule }
| { type: 'UPDATE_MODULE'; payload: { id: string; data: Partial<ServiceModule> } }
| { type: 'ADD_REQUIREMENT'; payload: Requirement }
| { type: 'UPDATE_REQUIREMENT'; payload: { id: string; data: Partial<Requirement> } }
| { type: 'ADD_CONTROL'; payload: Control }
| { type: 'UPDATE_CONTROL'; payload: { id: string; data: Partial<Control> } }
| { type: 'ADD_EVIDENCE'; payload: Evidence }
| { type: 'UPDATE_EVIDENCE'; payload: { id: string; data: Partial<Evidence> } }
| { type: 'DELETE_EVIDENCE'; payload: string }
| { type: 'ADD_RISK'; payload: Risk }
| { type: 'UPDATE_RISK'; payload: { id: string; data: Partial<Risk> } }
| { type: 'DELETE_RISK'; payload: string }
| { type: 'SET_AI_ACT_RESULT'; payload: AIActResult }
| { type: 'ADD_OBLIGATION'; payload: Obligation }
| { type: 'UPDATE_OBLIGATION'; payload: { id: string; data: Partial<Obligation> } }
| { type: 'SET_DSFA'; payload: DSFA }
| { type: 'ADD_TOM'; payload: TOM }
| { type: 'UPDATE_TOM'; payload: { id: string; data: Partial<TOM> } }
| { type: 'ADD_RETENTION_POLICY'; payload: RetentionPolicy }
| { type: 'UPDATE_RETENTION_POLICY'; payload: { id: string; data: Partial<RetentionPolicy> } }
| { type: 'ADD_PROCESSING_ACTIVITY'; payload: ProcessingActivity }
| { type: 'UPDATE_PROCESSING_ACTIVITY'; payload: { id: string; data: Partial<ProcessingActivity> } }
| { type: 'ADD_DOCUMENT'; payload: LegalDocument }
| { type: 'UPDATE_DOCUMENT'; payload: { id: string; data: Partial<LegalDocument> } }
| { type: 'SET_COOKIE_BANNER'; payload: CookieBannerConfig }
| { type: 'SET_DSR_CONFIG'; payload: DSRConfig }
| { type: 'ADD_ESCALATION_WORKFLOW'; payload: EscalationWorkflow }
| { type: 'UPDATE_ESCALATION_WORKFLOW'; payload: { id: string; data: Partial<EscalationWorkflow> } }
| { type: 'ADD_SECURITY_ISSUE'; payload: SecurityIssue }
| { type: 'UPDATE_SECURITY_ISSUE'; payload: { id: string; data: Partial<SecurityIssue> } }
| { type: 'ADD_BACKLOG_ITEM'; payload: BacklogItem }
| { type: 'UPDATE_BACKLOG_ITEM'; payload: { id: string; data: Partial<BacklogItem> } }
| { type: 'ADD_COMMAND_HISTORY'; payload: CommandHistory }
| { type: 'SET_PREFERENCES'; payload: Partial<UserPreferences> }
| { type: 'RESET_STATE' }

View File

@@ -278,267 +278,6 @@ func (h *AIExtractionHandlers) SubmitExtractedData(c *gin.Context) {
})
}
// SubmitBatchExtractedData saves multiple AI-extracted profile data items
// POST /api/v1/ai/extraction/submit-batch
func (h *AIExtractionHandlers) SubmitBatchExtractedData(c *gin.Context) {
var batch struct {
Items []ExtractedProfileData `json:"items" binding:"required"`
}
if err := c.ShouldBindJSON(&batch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
results := make([]gin.H, 0, len(batch.Items))
successCount := 0
errorCount := 0
for _, item := range batch.Items {
// Get existing staff record
staff, err := h.repo.GetStaff(c.Request.Context(), item.StaffID)
if err != nil {
results = append(results, gin.H{
"staff_id": item.StaffID,
"status": "error",
"error": "Staff not found",
})
errorCount++
continue
}
// Apply updates (same logic as single submit)
updated := false
if item.Email != "" && (staff.Email == nil || *staff.Email == "") {
staff.Email = &item.Email
updated = true
}
if item.Phone != "" && (staff.Phone == nil || *staff.Phone == "") {
staff.Phone = &item.Phone
updated = true
}
if item.Office != "" && (staff.Office == nil || *staff.Office == "") {
staff.Office = &item.Office
updated = true
}
if item.Position != "" && (staff.Position == nil || *staff.Position == "") {
staff.Position = &item.Position
updated = true
}
if item.PositionType != "" && (staff.PositionType == nil || *staff.PositionType == "") {
staff.PositionType = &item.PositionType
updated = true
}
if item.TeamRole != "" && (staff.TeamRole == nil || *staff.TeamRole == "") {
staff.TeamRole = &item.TeamRole
updated = true
}
if len(item.ResearchInterests) > 0 && len(staff.ResearchInterests) == 0 {
staff.ResearchInterests = item.ResearchInterests
updated = true
}
if item.ORCID != "" && (staff.ORCID == nil || *staff.ORCID == "") {
staff.ORCID = &item.ORCID
updated = true
}
// Update last verified
now := time.Now()
staff.LastVerified = &now
if updated {
err = h.repo.CreateStaff(c.Request.Context(), staff)
if err != nil {
results = append(results, gin.H{
"staff_id": item.StaffID,
"status": "error",
"error": err.Error(),
})
errorCount++
continue
}
}
results = append(results, gin.H{
"staff_id": item.StaffID,
"status": "success",
"updated": updated,
})
successCount++
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"success_count": successCount,
"error_count": errorCount,
"total": len(batch.Items),
})
}
// InstituteHierarchyTask represents an institute page to crawl for hierarchy
type InstituteHierarchyTask struct {
InstituteURL string `json:"institute_url"`
InstituteName string `json:"institute_name,omitempty"`
UniversityID uuid.UUID `json:"university_id"`
}
// GetInstitutePages returns institute pages that need hierarchy crawling
// GET /api/v1/ai/extraction/institutes?university_id=...
func (h *AIExtractionHandlers) GetInstitutePages(c *gin.Context) {
var universityID *uuid.UUID
if uniIDStr := c.Query("university_id"); uniIDStr != "" {
id, err := uuid.Parse(uniIDStr)
if err == nil {
universityID = &id
}
}
// Get unique institute/department URLs from staff profiles
params := database.StaffSearchParams{
UniversityID: universityID,
Limit: 1000,
}
result, err := h.repo.SearchStaff(c.Request.Context(), params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Collect unique source URLs (these are typically department pages)
urlSet := make(map[string]bool)
var tasks []InstituteHierarchyTask
for _, staff := range result.Staff {
if staff.SourceURL != nil && *staff.SourceURL != "" {
url := *staff.SourceURL
if !urlSet[url] {
urlSet[url] = true
tasks = append(tasks, InstituteHierarchyTask{
InstituteURL: url,
UniversityID: staff.UniversityID,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"institutes": tasks,
"total": len(tasks),
})
}
// InstituteHierarchyData represents hierarchy data extracted from an institute page
type InstituteHierarchyData struct {
InstituteURL string `json:"institute_url" binding:"required"`
UniversityID uuid.UUID `json:"university_id" binding:"required"`
InstituteName string `json:"institute_name,omitempty"`
// Leadership
LeaderName string `json:"leader_name,omitempty"`
LeaderTitle string `json:"leader_title,omitempty"` // e.g., "Professor", "Lehrstuhlinhaber"
// Staff organization
StaffGroups []struct {
Role string `json:"role"` // e.g., "Leitung", "Wissenschaftliche Mitarbeiter", "Sekretariat"
Members []string `json:"members"` // Names of people in this group
} `json:"staff_groups,omitempty"`
// Teaching info (Lehrveranstaltungen)
TeachingCourses []struct {
Title string `json:"title"`
Teacher string `json:"teacher,omitempty"`
} `json:"teaching_courses,omitempty"`
}
// SubmitInstituteHierarchy saves hierarchy data from an institute page
// POST /api/v1/ai/extraction/institutes/submit
func (h *AIExtractionHandlers) SubmitInstituteHierarchy(c *gin.Context) {
var data InstituteHierarchyData
if err := c.ShouldBindJSON(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Find or create department
dept := &database.Department{
UniversityID: data.UniversityID,
Name: data.InstituteName,
}
if data.InstituteURL != "" {
dept.URL = &data.InstituteURL
}
err := h.repo.CreateDepartment(c.Request.Context(), dept)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create department: " + err.Error()})
return
}
// Find leader and set as supervisor for all staff in this institute
var leaderID *uuid.UUID
if data.LeaderName != "" {
// Search for leader
leaderParams := database.StaffSearchParams{
Query: data.LeaderName,
UniversityID: &data.UniversityID,
Limit: 1,
}
result, err := h.repo.SearchStaff(c.Request.Context(), leaderParams)
if err == nil && len(result.Staff) > 0 {
leaderID = &result.Staff[0].ID
// Update leader with department and role
leader := &result.Staff[0]
leader.DepartmentID = &dept.ID
roleLeitung := "leitung"
leader.TeamRole = &roleLeitung
leader.IsProfessor = true
if data.LeaderTitle != "" {
leader.AcademicTitle = &data.LeaderTitle
}
h.repo.CreateStaff(c.Request.Context(), leader)
}
}
// Process staff groups
updatedCount := 0
for _, group := range data.StaffGroups {
for _, memberName := range group.Members {
// Find staff member
memberParams := database.StaffSearchParams{
Query: memberName,
UniversityID: &data.UniversityID,
Limit: 1,
}
result, err := h.repo.SearchStaff(c.Request.Context(), memberParams)
if err != nil || len(result.Staff) == 0 {
continue
}
member := &result.Staff[0]
member.DepartmentID = &dept.ID
member.TeamRole = &group.Role
// Set supervisor if leader was found and this is not the leader
if leaderID != nil && member.ID != *leaderID {
member.SupervisorID = leaderID
}
h.repo.CreateStaff(c.Request.Context(), member)
updatedCount++
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"department_id": dept.ID,
"leader_id": leaderID,
"members_updated": updatedCount,
})
}
// RegisterAIExtractionRoutes registers AI extraction routes
func (h *AIExtractionHandlers) RegisterRoutes(r *gin.RouterGroup) {
ai := r.Group("/ai/extraction")

View File

@@ -0,0 +1,272 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/breakpilot/edu-search-service/internal/database"
)
// SubmitBatchExtractedData saves multiple AI-extracted profile data items
// POST /api/v1/ai/extraction/submit-batch
func (h *AIExtractionHandlers) SubmitBatchExtractedData(c *gin.Context) {
var batch struct {
Items []ExtractedProfileData `json:"items" binding:"required"`
}
if err := c.ShouldBindJSON(&batch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
results := make([]gin.H, 0, len(batch.Items))
successCount := 0
errorCount := 0
for _, item := range batch.Items {
// Get existing staff record
staff, err := h.repo.GetStaff(c.Request.Context(), item.StaffID)
if err != nil {
results = append(results, gin.H{
"staff_id": item.StaffID,
"status": "error",
"error": "Staff not found",
})
errorCount++
continue
}
// Apply updates (same logic as single submit)
updated := false
if item.Email != "" && (staff.Email == nil || *staff.Email == "") {
staff.Email = &item.Email
updated = true
}
if item.Phone != "" && (staff.Phone == nil || *staff.Phone == "") {
staff.Phone = &item.Phone
updated = true
}
if item.Office != "" && (staff.Office == nil || *staff.Office == "") {
staff.Office = &item.Office
updated = true
}
if item.Position != "" && (staff.Position == nil || *staff.Position == "") {
staff.Position = &item.Position
updated = true
}
if item.PositionType != "" && (staff.PositionType == nil || *staff.PositionType == "") {
staff.PositionType = &item.PositionType
updated = true
}
if item.TeamRole != "" && (staff.TeamRole == nil || *staff.TeamRole == "") {
staff.TeamRole = &item.TeamRole
updated = true
}
if len(item.ResearchInterests) > 0 && len(staff.ResearchInterests) == 0 {
staff.ResearchInterests = item.ResearchInterests
updated = true
}
if item.ORCID != "" && (staff.ORCID == nil || *staff.ORCID == "") {
staff.ORCID = &item.ORCID
updated = true
}
// Update last verified
now := time.Now()
staff.LastVerified = &now
if updated {
err = h.repo.CreateStaff(c.Request.Context(), staff)
if err != nil {
results = append(results, gin.H{
"staff_id": item.StaffID,
"status": "error",
"error": err.Error(),
})
errorCount++
continue
}
}
results = append(results, gin.H{
"staff_id": item.StaffID,
"status": "success",
"updated": updated,
})
successCount++
}
c.JSON(http.StatusOK, gin.H{
"results": results,
"success_count": successCount,
"error_count": errorCount,
"total": len(batch.Items),
})
}
// InstituteHierarchyTask represents an institute page to crawl for hierarchy
type InstituteHierarchyTask struct {
InstituteURL string `json:"institute_url"`
InstituteName string `json:"institute_name,omitempty"`
UniversityID uuid.UUID `json:"university_id"`
}
// GetInstitutePages returns institute pages that need hierarchy crawling
// GET /api/v1/ai/extraction/institutes?university_id=...
func (h *AIExtractionHandlers) GetInstitutePages(c *gin.Context) {
var universityID *uuid.UUID
if uniIDStr := c.Query("university_id"); uniIDStr != "" {
id, err := uuid.Parse(uniIDStr)
if err == nil {
universityID = &id
}
}
// Get unique institute/department URLs from staff profiles
params := database.StaffSearchParams{
UniversityID: universityID,
Limit: 1000,
}
result, err := h.repo.SearchStaff(c.Request.Context(), params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Collect unique source URLs (these are typically department pages)
urlSet := make(map[string]bool)
var tasks []InstituteHierarchyTask
for _, staff := range result.Staff {
if staff.SourceURL != nil && *staff.SourceURL != "" {
url := *staff.SourceURL
if !urlSet[url] {
urlSet[url] = true
tasks = append(tasks, InstituteHierarchyTask{
InstituteURL: url,
UniversityID: staff.UniversityID,
})
}
}
}
c.JSON(http.StatusOK, gin.H{
"institutes": tasks,
"total": len(tasks),
})
}
// InstituteHierarchyData represents hierarchy data extracted from an institute page
type InstituteHierarchyData struct {
InstituteURL string `json:"institute_url" binding:"required"`
UniversityID uuid.UUID `json:"university_id" binding:"required"`
InstituteName string `json:"institute_name,omitempty"`
// Leadership
LeaderName string `json:"leader_name,omitempty"`
LeaderTitle string `json:"leader_title,omitempty"` // e.g., "Professor", "Lehrstuhlinhaber"
// Staff organization
StaffGroups []struct {
Role string `json:"role"` // e.g., "Leitung", "Wissenschaftliche Mitarbeiter", "Sekretariat"
Members []string `json:"members"` // Names of people in this group
} `json:"staff_groups,omitempty"`
// Teaching info (Lehrveranstaltungen)
TeachingCourses []struct {
Title string `json:"title"`
Teacher string `json:"teacher,omitempty"`
} `json:"teaching_courses,omitempty"`
}
// SubmitInstituteHierarchy saves hierarchy data from an institute page
// POST /api/v1/ai/extraction/institutes/submit
func (h *AIExtractionHandlers) SubmitInstituteHierarchy(c *gin.Context) {
var data InstituteHierarchyData
if err := c.ShouldBindJSON(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Find or create department
dept := &database.Department{
UniversityID: data.UniversityID,
Name: data.InstituteName,
}
if data.InstituteURL != "" {
dept.URL = &data.InstituteURL
}
err := h.repo.CreateDepartment(c.Request.Context(), dept)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create department: " + err.Error()})
return
}
// Find leader and set as supervisor for all staff in this institute
var leaderID *uuid.UUID
if data.LeaderName != "" {
// Search for leader
leaderParams := database.StaffSearchParams{
Query: data.LeaderName,
UniversityID: &data.UniversityID,
Limit: 1,
}
result, err := h.repo.SearchStaff(c.Request.Context(), leaderParams)
if err == nil && len(result.Staff) > 0 {
leaderID = &result.Staff[0].ID
// Update leader with department and role
leader := &result.Staff[0]
leader.DepartmentID = &dept.ID
roleLeitung := "leitung"
leader.TeamRole = &roleLeitung
leader.IsProfessor = true
if data.LeaderTitle != "" {
leader.AcademicTitle = &data.LeaderTitle
}
h.repo.CreateStaff(c.Request.Context(), leader)
}
}
// Process staff groups
updatedCount := 0
for _, group := range data.StaffGroups {
for _, memberName := range group.Members {
// Find staff member
memberParams := database.StaffSearchParams{
Query: memberName,
UniversityID: &data.UniversityID,
Limit: 1,
}
result, err := h.repo.SearchStaff(c.Request.Context(), memberParams)
if err != nil || len(result.Staff) == 0 {
continue
}
member := &result.Staff[0]
member.DepartmentID = &dept.ID
member.TeamRole = &group.Role
// Set supervisor if leader was found and this is not the leader
if leaderID != nil && member.ID != *leaderID {
member.SupervisorID = leaderID
}
h.repo.CreateStaff(c.Request.Context(), member)
updatedCount++
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"department_id": dept.ID,
"leader_id": leaderID,
"members_updated": updatedCount,
})
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
"time"
"github.com/breakpilot/edu-search-service/internal/policy"
"github.com/gin-gonic/gin"
@@ -349,289 +348,6 @@ func (h *PolicyHandler) UpdateOperationPermission(c *gin.Context) {
c.JSON(http.StatusOK, op)
}
// =============================================================================
// PII RULES
// =============================================================================
// ListPIIRules returns all PII detection rules.
func (h *PolicyHandler) ListPIIRules(c *gin.Context) {
activeOnly := c.Query("active_only") == "true"
rules, err := h.store.ListPIIRules(c.Request.Context(), activeOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list PII rules", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"rules": rules,
"total": len(rules),
})
}
// GetPIIRule returns a single PII rule by ID.
func (h *PolicyHandler) GetPIIRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"})
return
}
rule, err := h.store.GetPIIRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()})
return
}
if rule == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"})
return
}
c.JSON(http.StatusOK, rule)
}
// CreatePIIRule creates a new PII detection rule.
func (h *PolicyHandler) CreatePIIRule(c *gin.Context) {
var req policy.CreatePIIRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
rule, err := h.store.CreatePIIRule(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create PII rule", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntityPIIRule, &rule.ID, nil, rule, userEmail)
c.JSON(http.StatusCreated, rule)
}
// UpdatePIIRule updates an existing PII rule.
func (h *PolicyHandler) UpdatePIIRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"})
return
}
// Get old value for audit
oldRule, err := h.store.GetPIIRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()})
return
}
if oldRule == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"})
return
}
var req policy.UpdatePIIRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
rule, err := h.store.UpdatePIIRule(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update PII rule", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityPIIRule, &rule.ID, oldRule, rule, userEmail)
c.JSON(http.StatusOK, rule)
}
// DeletePIIRule deletes a PII rule.
func (h *PolicyHandler) DeletePIIRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"})
return
}
// Get rule for audit before deletion
rule, err := h.store.GetPIIRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()})
return
}
if rule == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"})
return
}
if err := h.store.DeletePIIRule(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete PII rule", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionDelete, policy.AuditEntityPIIRule, &id, rule, nil, userEmail)
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
}
// TestPIIRules tests PII detection against sample text.
func (h *PolicyHandler) TestPIIRules(c *gin.Context) {
var req policy.PIITestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
response, err := h.enforcer.DetectPII(c.Request.Context(), req.Text)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test PII detection", "details": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
// =============================================================================
// AUDIT & COMPLIANCE
// =============================================================================
// ListAuditLogs returns audit log entries.
func (h *PolicyHandler) ListAuditLogs(c *gin.Context) {
var filter policy.AuditLogFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
// Set defaults
if filter.Limit <= 0 || filter.Limit > 500 {
filter.Limit = 100
}
logs, total, err := h.store.ListAuditLogs(c.Request.Context(), &filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list audit logs", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
// ListBlockedContent returns blocked content log entries.
func (h *PolicyHandler) ListBlockedContent(c *gin.Context) {
var filter policy.BlockedContentFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
// Set defaults
if filter.Limit <= 0 || filter.Limit > 500 {
filter.Limit = 100
}
logs, total, err := h.store.ListBlockedContent(c.Request.Context(), &filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list blocked content", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"blocked": logs,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
// CheckCompliance performs a compliance check for a URL.
func (h *PolicyHandler) CheckCompliance(c *gin.Context) {
var req policy.CheckComplianceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
response, err := h.enforcer.CheckCompliance(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check compliance", "details": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
// GetPolicyStats returns aggregated statistics.
func (h *PolicyHandler) GetPolicyStats(c *gin.Context) {
stats, err := h.store.GetStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get stats", "details": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GenerateComplianceReport generates an audit report.
func (h *PolicyHandler) GenerateComplianceReport(c *gin.Context) {
var auditFilter policy.AuditLogFilter
var blockedFilter policy.BlockedContentFilter
// Parse date filters
fromStr := c.Query("from")
toStr := c.Query("to")
if fromStr != "" {
from, err := time.Parse("2006-01-02", fromStr)
if err == nil {
auditFilter.FromDate = &from
blockedFilter.FromDate = &from
}
}
if toStr != "" {
to, err := time.Parse("2006-01-02", toStr)
if err == nil {
// Add 1 day to include the end date
to = to.Add(24 * time.Hour)
auditFilter.ToDate = &to
blockedFilter.ToDate = &to
}
}
// No limit for report
auditFilter.Limit = 10000
blockedFilter.Limit = 10000
auditor := policy.NewAuditor(h.store)
report, err := auditor.GenerateAuditReport(c.Request.Context(), &auditFilter, &blockedFilter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate report", "details": err.Error()})
return
}
// Set filename for download
format := c.Query("format")
if format == "download" {
filename := "compliance-report-" + time.Now().Format("2006-01-02") + ".json"
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Header("Content-Type", "application/json")
}
c.JSON(http.StatusOK, report)
}
// =============================================================================
// HELPERS
// =============================================================================

View File

@@ -0,0 +1,293 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/edu-search-service/internal/policy"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// =============================================================================
// PII RULES
// =============================================================================
// ListPIIRules returns all PII detection rules.
func (h *PolicyHandler) ListPIIRules(c *gin.Context) {
activeOnly := c.Query("active_only") == "true"
rules, err := h.store.ListPIIRules(c.Request.Context(), activeOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list PII rules", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"rules": rules,
"total": len(rules),
})
}
// GetPIIRule returns a single PII rule by ID.
func (h *PolicyHandler) GetPIIRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"})
return
}
rule, err := h.store.GetPIIRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()})
return
}
if rule == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"})
return
}
c.JSON(http.StatusOK, rule)
}
// CreatePIIRule creates a new PII detection rule.
func (h *PolicyHandler) CreatePIIRule(c *gin.Context) {
var req policy.CreatePIIRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
rule, err := h.store.CreatePIIRule(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create PII rule", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntityPIIRule, &rule.ID, nil, rule, userEmail)
c.JSON(http.StatusCreated, rule)
}
// UpdatePIIRule updates an existing PII rule.
func (h *PolicyHandler) UpdatePIIRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"})
return
}
// Get old value for audit
oldRule, err := h.store.GetPIIRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()})
return
}
if oldRule == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"})
return
}
var req policy.UpdatePIIRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
rule, err := h.store.UpdatePIIRule(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update PII rule", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityPIIRule, &rule.ID, oldRule, rule, userEmail)
c.JSON(http.StatusOK, rule)
}
// DeletePIIRule deletes a PII rule.
func (h *PolicyHandler) DeletePIIRule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"})
return
}
// Get rule for audit before deletion
rule, err := h.store.GetPIIRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()})
return
}
if rule == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"})
return
}
if err := h.store.DeletePIIRule(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete PII rule", "details": err.Error()})
return
}
// Log audit
userEmail := getUserEmail(c)
h.enforcer.LogChange(c.Request.Context(), policy.AuditActionDelete, policy.AuditEntityPIIRule, &id, rule, nil, userEmail)
c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id})
}
// TestPIIRules tests PII detection against sample text.
func (h *PolicyHandler) TestPIIRules(c *gin.Context) {
var req policy.PIITestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
response, err := h.enforcer.DetectPII(c.Request.Context(), req.Text)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test PII detection", "details": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
// =============================================================================
// AUDIT & COMPLIANCE
// =============================================================================
// ListAuditLogs returns audit log entries.
func (h *PolicyHandler) ListAuditLogs(c *gin.Context) {
var filter policy.AuditLogFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
// Set defaults
if filter.Limit <= 0 || filter.Limit > 500 {
filter.Limit = 100
}
logs, total, err := h.store.ListAuditLogs(c.Request.Context(), &filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list audit logs", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
// ListBlockedContent returns blocked content log entries.
func (h *PolicyHandler) ListBlockedContent(c *gin.Context) {
var filter policy.BlockedContentFilter
if err := c.ShouldBindQuery(&filter); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()})
return
}
// Set defaults
if filter.Limit <= 0 || filter.Limit > 500 {
filter.Limit = 100
}
logs, total, err := h.store.ListBlockedContent(c.Request.Context(), &filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list blocked content", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"blocked": logs,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
// CheckCompliance performs a compliance check for a URL.
func (h *PolicyHandler) CheckCompliance(c *gin.Context) {
var req policy.CheckComplianceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
response, err := h.enforcer.CheckCompliance(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check compliance", "details": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
// GetPolicyStats returns aggregated statistics.
func (h *PolicyHandler) GetPolicyStats(c *gin.Context) {
stats, err := h.store.GetStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get stats", "details": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GenerateComplianceReport generates an audit report.
func (h *PolicyHandler) GenerateComplianceReport(c *gin.Context) {
var auditFilter policy.AuditLogFilter
var blockedFilter policy.BlockedContentFilter
// Parse date filters
fromStr := c.Query("from")
toStr := c.Query("to")
if fromStr != "" {
from, err := time.Parse("2006-01-02", fromStr)
if err == nil {
auditFilter.FromDate = &from
blockedFilter.FromDate = &from
}
}
if toStr != "" {
to, err := time.Parse("2006-01-02", toStr)
if err == nil {
// Add 1 day to include the end date
to = to.Add(24 * time.Hour)
auditFilter.ToDate = &to
blockedFilter.ToDate = &to
}
}
// No limit for report
auditFilter.Limit = 10000
blockedFilter.Limit = 10000
auditor := policy.NewAuditor(h.store)
report, err := auditor.GenerateAuditReport(c.Request.Context(), &auditFilter, &blockedFilter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate report", "details": err.Error()})
return
}
// Set filename for download
format := c.Query("format")
if format == "download" {
filename := "compliance-report-" + time.Now().Format("2006-01-02") + ".json"
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Header("Content-Type", "application/json")
}
c.JSON(http.StatusOK, report)
}

View File

@@ -2,8 +2,6 @@ package database
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -145,395 +143,6 @@ func (r *Repository) GetDepartmentByName(ctx context.Context, uniID uuid.UUID, n
return d, nil
}
// ============================================================================
// STAFF
// ============================================================================
// CreateStaff creates or updates a staff member
func (r *Repository) CreateStaff(ctx context.Context, s *UniversityStaff) error {
query := `
INSERT INTO university_staff (
university_id, department_id, first_name, last_name, full_name,
title, academic_title, position, position_type, is_professor,
email, phone, office, profile_url, photo_url,
orcid, google_scholar_id, researchgate_url, linkedin_url, personal_website,
research_interests, research_summary, supervisor_id, team_role, source_url
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25
)
ON CONFLICT (university_id, first_name, last_name, COALESCE(department_id, '00000000-0000-0000-0000-000000000000'::uuid))
DO UPDATE SET
full_name = EXCLUDED.full_name,
title = EXCLUDED.title,
academic_title = EXCLUDED.academic_title,
position = EXCLUDED.position,
position_type = EXCLUDED.position_type,
is_professor = EXCLUDED.is_professor,
email = COALESCE(EXCLUDED.email, university_staff.email),
phone = COALESCE(EXCLUDED.phone, university_staff.phone),
office = COALESCE(EXCLUDED.office, university_staff.office),
profile_url = COALESCE(EXCLUDED.profile_url, university_staff.profile_url),
photo_url = COALESCE(EXCLUDED.photo_url, university_staff.photo_url),
orcid = COALESCE(EXCLUDED.orcid, university_staff.orcid),
google_scholar_id = COALESCE(EXCLUDED.google_scholar_id, university_staff.google_scholar_id),
researchgate_url = COALESCE(EXCLUDED.researchgate_url, university_staff.researchgate_url),
linkedin_url = COALESCE(EXCLUDED.linkedin_url, university_staff.linkedin_url),
personal_website = COALESCE(EXCLUDED.personal_website, university_staff.personal_website),
research_interests = COALESCE(EXCLUDED.research_interests, university_staff.research_interests),
research_summary = COALESCE(EXCLUDED.research_summary, university_staff.research_summary),
supervisor_id = COALESCE(EXCLUDED.supervisor_id, university_staff.supervisor_id),
team_role = COALESCE(EXCLUDED.team_role, university_staff.team_role),
source_url = COALESCE(EXCLUDED.source_url, university_staff.source_url),
crawled_at = NOW(),
updated_at = NOW()
RETURNING id, crawled_at, created_at, updated_at
`
return r.db.Pool.QueryRow(ctx, query,
s.UniversityID, s.DepartmentID, s.FirstName, s.LastName, s.FullName,
s.Title, s.AcademicTitle, s.Position, s.PositionType, s.IsProfessor,
s.Email, s.Phone, s.Office, s.ProfileURL, s.PhotoURL,
s.ORCID, s.GoogleScholarID, s.ResearchgateURL, s.LinkedInURL, s.PersonalWebsite,
s.ResearchInterests, s.ResearchSummary, s.SupervisorID, s.TeamRole, s.SourceURL,
).Scan(&s.ID, &s.CrawledAt, &s.CreatedAt, &s.UpdatedAt)
}
// GetStaff retrieves a staff member by ID
func (r *Repository) GetStaff(ctx context.Context, id uuid.UUID) (*UniversityStaff, error) {
query := `SELECT * FROM v_staff_full WHERE id = $1`
s := &UniversityStaff{}
err := r.db.Pool.QueryRow(ctx, query, id).Scan(
&s.ID, &s.UniversityID, &s.DepartmentID, &s.FirstName, &s.LastName, &s.FullName,
&s.Title, &s.AcademicTitle, &s.Position, &s.PositionType, &s.IsProfessor,
&s.Email, &s.Phone, &s.Office, &s.ProfileURL, &s.PhotoURL,
&s.ORCID, &s.GoogleScholarID, &s.ResearchgateURL, &s.LinkedInURL, &s.PersonalWebsite,
&s.ResearchInterests, &s.ResearchSummary, &s.CrawledAt, &s.LastVerified, &s.IsActive, &s.SourceURL,
&s.CreatedAt, &s.UpdatedAt, &s.UniversityName, &s.UniversityShort, nil, nil,
&s.DepartmentName, nil, &s.PublicationCount,
)
if err != nil {
return nil, err
}
return s, nil
}
// SearchStaff searches for staff members
func (r *Repository) SearchStaff(ctx context.Context, params StaffSearchParams) (*StaffSearchResult, error) {
// Build query dynamically
var conditions []string
var args []interface{}
argNum := 1
baseQuery := `
SELECT s.id, s.university_id, s.department_id, s.first_name, s.last_name, s.full_name,
s.title, s.academic_title, s.position, s.position_type, s.is_professor,
s.email, s.profile_url, s.photo_url, s.orcid,
s.research_interests, s.crawled_at, s.is_active,
u.name as university_name, u.short_name as university_short, u.state as university_state,
d.name as department_name,
(SELECT COUNT(*) FROM staff_publications sp WHERE sp.staff_id = s.id) as publication_count
FROM university_staff s
JOIN universities u ON s.university_id = u.id
LEFT JOIN departments d ON s.department_id = d.id
`
if params.Query != "" {
conditions = append(conditions, fmt.Sprintf(
`(to_tsvector('german', COALESCE(s.full_name, '') || ' ' || COALESCE(s.research_summary, '')) @@ plainto_tsquery('german', $%d)
OR s.full_name ILIKE '%%' || $%d || '%%'
OR s.last_name ILIKE '%%' || $%d || '%%')`,
argNum, argNum, argNum))
args = append(args, params.Query)
argNum++
}
if params.UniversityID != nil {
conditions = append(conditions, fmt.Sprintf("s.university_id = $%d", argNum))
args = append(args, *params.UniversityID)
argNum++
}
if params.DepartmentID != nil {
conditions = append(conditions, fmt.Sprintf("s.department_id = $%d", argNum))
args = append(args, *params.DepartmentID)
argNum++
}
if params.State != nil {
conditions = append(conditions, fmt.Sprintf("u.state = $%d", argNum))
args = append(args, *params.State)
argNum++
}
if params.UniType != nil {
conditions = append(conditions, fmt.Sprintf("u.uni_type = $%d", argNum))
args = append(args, *params.UniType)
argNum++
}
if params.PositionType != nil {
conditions = append(conditions, fmt.Sprintf("s.position_type = $%d", argNum))
args = append(args, *params.PositionType)
argNum++
}
if params.IsProfessor != nil {
conditions = append(conditions, fmt.Sprintf("s.is_professor = $%d", argNum))
args = append(args, *params.IsProfessor)
argNum++
}
// Build WHERE clause
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
// Count total
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM university_staff s JOIN universities u ON s.university_id = u.id LEFT JOIN departments d ON s.department_id = d.id %s", whereClause)
var total int
if err := r.db.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, err
}
// Apply pagination
limit := params.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := params.Offset
if offset < 0 {
offset = 0
}
// Full query with pagination
fullQuery := fmt.Sprintf("%s %s ORDER BY s.is_professor DESC, s.last_name ASC LIMIT %d OFFSET %d",
baseQuery, whereClause, limit, offset)
rows, err := r.db.Pool.Query(ctx, fullQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var staff []UniversityStaff
for rows.Next() {
var s UniversityStaff
var uniState *string
if err := rows.Scan(
&s.ID, &s.UniversityID, &s.DepartmentID, &s.FirstName, &s.LastName, &s.FullName,
&s.Title, &s.AcademicTitle, &s.Position, &s.PositionType, &s.IsProfessor,
&s.Email, &s.ProfileURL, &s.PhotoURL, &s.ORCID,
&s.ResearchInterests, &s.CrawledAt, &s.IsActive,
&s.UniversityName, &s.UniversityShort, &uniState,
&s.DepartmentName, &s.PublicationCount,
); err != nil {
return nil, err
}
staff = append(staff, s)
}
return &StaffSearchResult{
Staff: staff,
Total: total,
Limit: limit,
Offset: offset,
Query: params.Query,
}, rows.Err()
}
// ============================================================================
// PUBLICATIONS
// ============================================================================
// CreatePublication creates or updates a publication
func (r *Repository) CreatePublication(ctx context.Context, p *Publication) error {
query := `
INSERT INTO publications (
title, title_en, abstract, abstract_en, year, month,
pub_type, venue, venue_short, publisher,
doi, isbn, issn, arxiv_id, pubmed_id,
url, pdf_url, citation_count, keywords, topics, source, raw_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)
ON CONFLICT (doi) WHERE doi IS NOT NULL DO UPDATE SET
title = EXCLUDED.title,
abstract = EXCLUDED.abstract,
year = EXCLUDED.year,
venue = EXCLUDED.venue,
citation_count = EXCLUDED.citation_count,
updated_at = NOW()
RETURNING id, crawled_at, created_at, updated_at
`
// Handle potential duplicate without DOI
err := r.db.Pool.QueryRow(ctx, query,
p.Title, p.TitleEN, p.Abstract, p.AbstractEN, p.Year, p.Month,
p.PubType, p.Venue, p.VenueShort, p.Publisher,
p.DOI, p.ISBN, p.ISSN, p.ArxivID, p.PubmedID,
p.URL, p.PDFURL, p.CitationCount, p.Keywords, p.Topics, p.Source, p.RawData,
).Scan(&p.ID, &p.CrawledAt, &p.CreatedAt, &p.UpdatedAt)
if err != nil && strings.Contains(err.Error(), "duplicate") {
// Try to find existing publication by title and year
findQuery := `SELECT id FROM publications WHERE title = $1 AND year = $2`
err = r.db.Pool.QueryRow(ctx, findQuery, p.Title, p.Year).Scan(&p.ID)
}
return err
}
// LinkStaffPublication creates a link between staff and publication
func (r *Repository) LinkStaffPublication(ctx context.Context, sp *StaffPublication) error {
query := `
INSERT INTO staff_publications (staff_id, publication_id, author_position, is_corresponding)
VALUES ($1, $2, $3, $4)
ON CONFLICT (staff_id, publication_id) DO UPDATE SET
author_position = EXCLUDED.author_position,
is_corresponding = EXCLUDED.is_corresponding
`
_, err := r.db.Pool.Exec(ctx, query,
sp.StaffID, sp.PublicationID, sp.AuthorPosition, sp.IsCorresponding,
)
return err
}
// GetStaffPublications retrieves all publications for a staff member
func (r *Repository) GetStaffPublications(ctx context.Context, staffID uuid.UUID) ([]Publication, error) {
query := `
SELECT p.id, p.title, p.abstract, p.year, p.pub_type, p.venue, p.doi, p.url, p.citation_count
FROM publications p
JOIN staff_publications sp ON p.id = sp.publication_id
WHERE sp.staff_id = $1
ORDER BY p.year DESC NULLS LAST, p.title
`
rows, err := r.db.Pool.Query(ctx, query, staffID)
if err != nil {
return nil, err
}
defer rows.Close()
var pubs []Publication
for rows.Next() {
var p Publication
if err := rows.Scan(
&p.ID, &p.Title, &p.Abstract, &p.Year, &p.PubType, &p.Venue, &p.DOI, &p.URL, &p.CitationCount,
); err != nil {
return nil, err
}
pubs = append(pubs, p)
}
return pubs, rows.Err()
}
// SearchPublications searches for publications
func (r *Repository) SearchPublications(ctx context.Context, params PublicationSearchParams) (*PublicationSearchResult, error) {
var conditions []string
var args []interface{}
argNum := 1
if params.Query != "" {
conditions = append(conditions, fmt.Sprintf(
`to_tsvector('german', COALESCE(title, '') || ' ' || COALESCE(abstract, '')) @@ plainto_tsquery('german', $%d)`,
argNum))
args = append(args, params.Query)
argNum++
}
if params.StaffID != nil {
conditions = append(conditions, fmt.Sprintf(
`id IN (SELECT publication_id FROM staff_publications WHERE staff_id = $%d)`,
argNum))
args = append(args, *params.StaffID)
argNum++
}
if params.Year != nil {
conditions = append(conditions, fmt.Sprintf("year = $%d", argNum))
args = append(args, *params.Year)
argNum++
}
if params.YearFrom != nil {
conditions = append(conditions, fmt.Sprintf("year >= $%d", argNum))
args = append(args, *params.YearFrom)
argNum++
}
if params.YearTo != nil {
conditions = append(conditions, fmt.Sprintf("year <= $%d", argNum))
args = append(args, *params.YearTo)
argNum++
}
if params.PubType != nil {
conditions = append(conditions, fmt.Sprintf("pub_type = $%d", argNum))
args = append(args, *params.PubType)
argNum++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
// Count
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM publications %s", whereClause)
var total int
if err := r.db.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, err
}
// Pagination
limit := params.Limit
if limit <= 0 {
limit = 20
}
offset := params.Offset
// Query
query := fmt.Sprintf(`
SELECT id, title, abstract, year, pub_type, venue, doi, url, citation_count, keywords
FROM publications %s
ORDER BY year DESC NULLS LAST, citation_count DESC
LIMIT %d OFFSET %d
`, whereClause, limit, offset)
rows, err := r.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var pubs []Publication
for rows.Next() {
var p Publication
if err := rows.Scan(
&p.ID, &p.Title, &p.Abstract, &p.Year, &p.PubType, &p.Venue, &p.DOI, &p.URL, &p.CitationCount, &p.Keywords,
); err != nil {
return nil, err
}
pubs = append(pubs, p)
}
return &PublicationSearchResult{
Publications: pubs,
Total: total,
Limit: limit,
Offset: offset,
Query: params.Query,
}, rows.Err()
}
// ============================================================================
// CRAWL STATUS
// ============================================================================

View File

@@ -0,0 +1,398 @@
package database
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
// ============================================================================
// STAFF
// ============================================================================
// CreateStaff creates or updates a staff member
func (r *Repository) CreateStaff(ctx context.Context, s *UniversityStaff) error {
query := `
INSERT INTO university_staff (
university_id, department_id, first_name, last_name, full_name,
title, academic_title, position, position_type, is_professor,
email, phone, office, profile_url, photo_url,
orcid, google_scholar_id, researchgate_url, linkedin_url, personal_website,
research_interests, research_summary, supervisor_id, team_role, source_url
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25
)
ON CONFLICT (university_id, first_name, last_name, COALESCE(department_id, '00000000-0000-0000-0000-000000000000'::uuid))
DO UPDATE SET
full_name = EXCLUDED.full_name,
title = EXCLUDED.title,
academic_title = EXCLUDED.academic_title,
position = EXCLUDED.position,
position_type = EXCLUDED.position_type,
is_professor = EXCLUDED.is_professor,
email = COALESCE(EXCLUDED.email, university_staff.email),
phone = COALESCE(EXCLUDED.phone, university_staff.phone),
office = COALESCE(EXCLUDED.office, university_staff.office),
profile_url = COALESCE(EXCLUDED.profile_url, university_staff.profile_url),
photo_url = COALESCE(EXCLUDED.photo_url, university_staff.photo_url),
orcid = COALESCE(EXCLUDED.orcid, university_staff.orcid),
google_scholar_id = COALESCE(EXCLUDED.google_scholar_id, university_staff.google_scholar_id),
researchgate_url = COALESCE(EXCLUDED.researchgate_url, university_staff.researchgate_url),
linkedin_url = COALESCE(EXCLUDED.linkedin_url, university_staff.linkedin_url),
personal_website = COALESCE(EXCLUDED.personal_website, university_staff.personal_website),
research_interests = COALESCE(EXCLUDED.research_interests, university_staff.research_interests),
research_summary = COALESCE(EXCLUDED.research_summary, university_staff.research_summary),
supervisor_id = COALESCE(EXCLUDED.supervisor_id, university_staff.supervisor_id),
team_role = COALESCE(EXCLUDED.team_role, university_staff.team_role),
source_url = COALESCE(EXCLUDED.source_url, university_staff.source_url),
crawled_at = NOW(),
updated_at = NOW()
RETURNING id, crawled_at, created_at, updated_at
`
return r.db.Pool.QueryRow(ctx, query,
s.UniversityID, s.DepartmentID, s.FirstName, s.LastName, s.FullName,
s.Title, s.AcademicTitle, s.Position, s.PositionType, s.IsProfessor,
s.Email, s.Phone, s.Office, s.ProfileURL, s.PhotoURL,
s.ORCID, s.GoogleScholarID, s.ResearchgateURL, s.LinkedInURL, s.PersonalWebsite,
s.ResearchInterests, s.ResearchSummary, s.SupervisorID, s.TeamRole, s.SourceURL,
).Scan(&s.ID, &s.CrawledAt, &s.CreatedAt, &s.UpdatedAt)
}
// GetStaff retrieves a staff member by ID
func (r *Repository) GetStaff(ctx context.Context, id uuid.UUID) (*UniversityStaff, error) {
query := `SELECT * FROM v_staff_full WHERE id = $1`
s := &UniversityStaff{}
err := r.db.Pool.QueryRow(ctx, query, id).Scan(
&s.ID, &s.UniversityID, &s.DepartmentID, &s.FirstName, &s.LastName, &s.FullName,
&s.Title, &s.AcademicTitle, &s.Position, &s.PositionType, &s.IsProfessor,
&s.Email, &s.Phone, &s.Office, &s.ProfileURL, &s.PhotoURL,
&s.ORCID, &s.GoogleScholarID, &s.ResearchgateURL, &s.LinkedInURL, &s.PersonalWebsite,
&s.ResearchInterests, &s.ResearchSummary, &s.CrawledAt, &s.LastVerified, &s.IsActive, &s.SourceURL,
&s.CreatedAt, &s.UpdatedAt, &s.UniversityName, &s.UniversityShort, nil, nil,
&s.DepartmentName, nil, &s.PublicationCount,
)
if err != nil {
return nil, err
}
return s, nil
}
// SearchStaff searches for staff members
func (r *Repository) SearchStaff(ctx context.Context, params StaffSearchParams) (*StaffSearchResult, error) {
// Build query dynamically
var conditions []string
var args []interface{}
argNum := 1
baseQuery := `
SELECT s.id, s.university_id, s.department_id, s.first_name, s.last_name, s.full_name,
s.title, s.academic_title, s.position, s.position_type, s.is_professor,
s.email, s.profile_url, s.photo_url, s.orcid,
s.research_interests, s.crawled_at, s.is_active,
u.name as university_name, u.short_name as university_short, u.state as university_state,
d.name as department_name,
(SELECT COUNT(*) FROM staff_publications sp WHERE sp.staff_id = s.id) as publication_count
FROM university_staff s
JOIN universities u ON s.university_id = u.id
LEFT JOIN departments d ON s.department_id = d.id
`
if params.Query != "" {
conditions = append(conditions, fmt.Sprintf(
`(to_tsvector('german', COALESCE(s.full_name, '') || ' ' || COALESCE(s.research_summary, '')) @@ plainto_tsquery('german', $%d)
OR s.full_name ILIKE '%%' || $%d || '%%'
OR s.last_name ILIKE '%%' || $%d || '%%')`,
argNum, argNum, argNum))
args = append(args, params.Query)
argNum++
}
if params.UniversityID != nil {
conditions = append(conditions, fmt.Sprintf("s.university_id = $%d", argNum))
args = append(args, *params.UniversityID)
argNum++
}
if params.DepartmentID != nil {
conditions = append(conditions, fmt.Sprintf("s.department_id = $%d", argNum))
args = append(args, *params.DepartmentID)
argNum++
}
if params.State != nil {
conditions = append(conditions, fmt.Sprintf("u.state = $%d", argNum))
args = append(args, *params.State)
argNum++
}
if params.UniType != nil {
conditions = append(conditions, fmt.Sprintf("u.uni_type = $%d", argNum))
args = append(args, *params.UniType)
argNum++
}
if params.PositionType != nil {
conditions = append(conditions, fmt.Sprintf("s.position_type = $%d", argNum))
args = append(args, *params.PositionType)
argNum++
}
if params.IsProfessor != nil {
conditions = append(conditions, fmt.Sprintf("s.is_professor = $%d", argNum))
args = append(args, *params.IsProfessor)
argNum++
}
// Build WHERE clause
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
// Count total
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM university_staff s JOIN universities u ON s.university_id = u.id LEFT JOIN departments d ON s.department_id = d.id %s", whereClause)
var total int
if err := r.db.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, err
}
// Apply pagination
limit := params.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
offset := params.Offset
if offset < 0 {
offset = 0
}
// Full query with pagination
fullQuery := fmt.Sprintf("%s %s ORDER BY s.is_professor DESC, s.last_name ASC LIMIT %d OFFSET %d",
baseQuery, whereClause, limit, offset)
rows, err := r.db.Pool.Query(ctx, fullQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var staff []UniversityStaff
for rows.Next() {
var s UniversityStaff
var uniState *string
if err := rows.Scan(
&s.ID, &s.UniversityID, &s.DepartmentID, &s.FirstName, &s.LastName, &s.FullName,
&s.Title, &s.AcademicTitle, &s.Position, &s.PositionType, &s.IsProfessor,
&s.Email, &s.ProfileURL, &s.PhotoURL, &s.ORCID,
&s.ResearchInterests, &s.CrawledAt, &s.IsActive,
&s.UniversityName, &s.UniversityShort, &uniState,
&s.DepartmentName, &s.PublicationCount,
); err != nil {
return nil, err
}
staff = append(staff, s)
}
return &StaffSearchResult{
Staff: staff,
Total: total,
Limit: limit,
Offset: offset,
Query: params.Query,
}, rows.Err()
}
// ============================================================================
// PUBLICATIONS
// ============================================================================
// CreatePublication creates or updates a publication
func (r *Repository) CreatePublication(ctx context.Context, p *Publication) error {
query := `
INSERT INTO publications (
title, title_en, abstract, abstract_en, year, month,
pub_type, venue, venue_short, publisher,
doi, isbn, issn, arxiv_id, pubmed_id,
url, pdf_url, citation_count, keywords, topics, source, raw_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)
ON CONFLICT (doi) WHERE doi IS NOT NULL DO UPDATE SET
title = EXCLUDED.title,
abstract = EXCLUDED.abstract,
year = EXCLUDED.year,
venue = EXCLUDED.venue,
citation_count = EXCLUDED.citation_count,
updated_at = NOW()
RETURNING id, crawled_at, created_at, updated_at
`
// Handle potential duplicate without DOI
err := r.db.Pool.QueryRow(ctx, query,
p.Title, p.TitleEN, p.Abstract, p.AbstractEN, p.Year, p.Month,
p.PubType, p.Venue, p.VenueShort, p.Publisher,
p.DOI, p.ISBN, p.ISSN, p.ArxivID, p.PubmedID,
p.URL, p.PDFURL, p.CitationCount, p.Keywords, p.Topics, p.Source, p.RawData,
).Scan(&p.ID, &p.CrawledAt, &p.CreatedAt, &p.UpdatedAt)
if err != nil && strings.Contains(err.Error(), "duplicate") {
// Try to find existing publication by title and year
findQuery := `SELECT id FROM publications WHERE title = $1 AND year = $2`
err = r.db.Pool.QueryRow(ctx, findQuery, p.Title, p.Year).Scan(&p.ID)
}
return err
}
// LinkStaffPublication creates a link between staff and publication
func (r *Repository) LinkStaffPublication(ctx context.Context, sp *StaffPublication) error {
query := `
INSERT INTO staff_publications (staff_id, publication_id, author_position, is_corresponding)
VALUES ($1, $2, $3, $4)
ON CONFLICT (staff_id, publication_id) DO UPDATE SET
author_position = EXCLUDED.author_position,
is_corresponding = EXCLUDED.is_corresponding
`
_, err := r.db.Pool.Exec(ctx, query,
sp.StaffID, sp.PublicationID, sp.AuthorPosition, sp.IsCorresponding,
)
return err
}
// GetStaffPublications retrieves all publications for a staff member
func (r *Repository) GetStaffPublications(ctx context.Context, staffID uuid.UUID) ([]Publication, error) {
query := `
SELECT p.id, p.title, p.abstract, p.year, p.pub_type, p.venue, p.doi, p.url, p.citation_count
FROM publications p
JOIN staff_publications sp ON p.id = sp.publication_id
WHERE sp.staff_id = $1
ORDER BY p.year DESC NULLS LAST, p.title
`
rows, err := r.db.Pool.Query(ctx, query, staffID)
if err != nil {
return nil, err
}
defer rows.Close()
var pubs []Publication
for rows.Next() {
var p Publication
if err := rows.Scan(
&p.ID, &p.Title, &p.Abstract, &p.Year, &p.PubType, &p.Venue, &p.DOI, &p.URL, &p.CitationCount,
); err != nil {
return nil, err
}
pubs = append(pubs, p)
}
return pubs, rows.Err()
}
// SearchPublications searches for publications
func (r *Repository) SearchPublications(ctx context.Context, params PublicationSearchParams) (*PublicationSearchResult, error) {
var conditions []string
var args []interface{}
argNum := 1
if params.Query != "" {
conditions = append(conditions, fmt.Sprintf(
`to_tsvector('german', COALESCE(title, '') || ' ' || COALESCE(abstract, '')) @@ plainto_tsquery('german', $%d)`,
argNum))
args = append(args, params.Query)
argNum++
}
if params.StaffID != nil {
conditions = append(conditions, fmt.Sprintf(
`id IN (SELECT publication_id FROM staff_publications WHERE staff_id = $%d)`,
argNum))
args = append(args, *params.StaffID)
argNum++
}
if params.Year != nil {
conditions = append(conditions, fmt.Sprintf("year = $%d", argNum))
args = append(args, *params.Year)
argNum++
}
if params.YearFrom != nil {
conditions = append(conditions, fmt.Sprintf("year >= $%d", argNum))
args = append(args, *params.YearFrom)
argNum++
}
if params.YearTo != nil {
conditions = append(conditions, fmt.Sprintf("year <= $%d", argNum))
args = append(args, *params.YearTo)
argNum++
}
if params.PubType != nil {
conditions = append(conditions, fmt.Sprintf("pub_type = $%d", argNum))
args = append(args, *params.PubType)
argNum++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
// Count
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM publications %s", whereClause)
var total int
if err := r.db.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, err
}
// Pagination
limit := params.Limit
if limit <= 0 {
limit = 20
}
offset := params.Offset
// Query
query := fmt.Sprintf(`
SELECT id, title, abstract, year, pub_type, venue, doi, url, citation_count, keywords
FROM publications %s
ORDER BY year DESC NULLS LAST, citation_count DESC
LIMIT %d OFFSET %d
`, whereClause, limit, offset)
rows, err := r.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var pubs []Publication
for rows.Next() {
var p Publication
if err := rows.Scan(
&p.ID, &p.Title, &p.Abstract, &p.Year, &p.PubType, &p.Venue, &p.DOI, &p.URL, &p.CitationCount, &p.Keywords,
); err != nil {
return nil, err
}
pubs = append(pubs, p)
}
return &PublicationSearchResult{
Publications: pubs,
Total: total,
Limit: limit,
Offset: offset,
Query: params.Query,
}, rows.Err()
}

View File

@@ -2,7 +2,6 @@ package policy
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -205,413 +204,6 @@ func (s *Store) DeletePolicy(ctx context.Context, id uuid.UUID) error {
return nil
}
// =============================================================================
// ALLOWED SOURCES
// =============================================================================
// CreateSource creates a new allowed source.
func (s *Store) CreateSource(ctx context.Context, req *CreateAllowedSourceRequest) (*AllowedSource, error) {
trustBoost := 0.5
if req.TrustBoost != nil {
trustBoost = *req.TrustBoost
}
source := &AllowedSource{
ID: uuid.New(),
PolicyID: req.PolicyID,
Domain: req.Domain,
Name: req.Name,
Description: req.Description,
License: req.License,
LegalBasis: req.LegalBasis,
CitationTemplate: req.CitationTemplate,
TrustBoost: trustBoost,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO allowed_sources (id, policy_id, domain, name, description, license,
legal_basis, citation_template, trust_boost, is_active,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`
err := s.pool.QueryRow(ctx, query,
source.ID, source.PolicyID, source.Domain, source.Name, source.Description,
source.License, source.LegalBasis, source.CitationTemplate, source.TrustBoost,
source.IsActive, source.CreatedAt, source.UpdatedAt,
).Scan(&source.ID)
if err != nil {
return nil, fmt.Errorf("failed to create source: %w", err)
}
// Create default operation permissions
err = s.createDefaultOperations(ctx, source.ID)
if err != nil {
return nil, fmt.Errorf("failed to create default operations: %w", err)
}
return source, nil
}
// createDefaultOperations creates default operation permissions for a source.
func (s *Store) createDefaultOperations(ctx context.Context, sourceID uuid.UUID) error {
defaults := []struct {
op Operation
allowed bool
citation bool
}{
{OperationLookup, true, true},
{OperationRAG, true, true},
{OperationTraining, false, false}, // VERBOTEN by default
{OperationExport, true, true},
}
for _, d := range defaults {
query := `
INSERT INTO operation_permissions (id, source_id, operation, is_allowed, requires_citation, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := s.pool.Exec(ctx, query, uuid.New(), sourceID, d.op, d.allowed, d.citation, time.Now(), time.Now())
if err != nil {
return err
}
}
return nil
}
// GetSource retrieves a source by ID.
func (s *Store) GetSource(ctx context.Context, id uuid.UUID) (*AllowedSource, error) {
query := `
SELECT als.id, als.policy_id, als.domain, als.name, als.description, als.license,
als.legal_basis, als.citation_template, als.trust_boost, als.is_active,
als.created_at, als.updated_at, sp.name as policy_name
FROM allowed_sources als
JOIN source_policies sp ON als.policy_id = sp.id
WHERE als.id = $1`
source := &AllowedSource{}
err := s.pool.QueryRow(ctx, query, id).Scan(
&source.ID, &source.PolicyID, &source.Domain, &source.Name, &source.Description,
&source.License, &source.LegalBasis, &source.CitationTemplate, &source.TrustBoost,
&source.IsActive, &source.CreatedAt, &source.UpdatedAt, &source.PolicyName,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get source: %w", err)
}
// Load operations
ops, err := s.GetOperationsBySourceID(ctx, source.ID)
if err != nil {
return nil, err
}
source.Operations = ops
return source, nil
}
// GetSourceByDomain retrieves a source by domain with optional bundesland filter.
func (s *Store) GetSourceByDomain(ctx context.Context, domain string, bundesland *Bundesland) (*AllowedSource, error) {
query := `
SELECT als.id, als.policy_id, als.domain, als.name, als.description, als.license,
als.legal_basis, als.citation_template, als.trust_boost, als.is_active,
als.created_at, als.updated_at
FROM allowed_sources als
JOIN source_policies sp ON als.policy_id = sp.id
WHERE als.is_active = true
AND sp.is_active = true
AND (als.domain = $1 OR $1 LIKE '%.' || als.domain)
AND (sp.bundesland IS NULL OR sp.bundesland = $2)
LIMIT 1`
source := &AllowedSource{}
err := s.pool.QueryRow(ctx, query, domain, bundesland).Scan(
&source.ID, &source.PolicyID, &source.Domain, &source.Name, &source.Description,
&source.License, &source.LegalBasis, &source.CitationTemplate, &source.TrustBoost,
&source.IsActive, &source.CreatedAt, &source.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get source by domain: %w", err)
}
// Load operations
ops, err := s.GetOperationsBySourceID(ctx, source.ID)
if err != nil {
return nil, err
}
source.Operations = ops
return source, nil
}
// ListSources retrieves sources with optional filters.
func (s *Store) ListSources(ctx context.Context, filter *SourceListFilter) ([]AllowedSource, int, error) {
baseQuery := `FROM allowed_sources als JOIN source_policies sp ON als.policy_id = sp.id WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.PolicyID != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.policy_id = $%d", argCount)
args = append(args, *filter.PolicyID)
}
if filter.Domain != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.domain ILIKE $%d", argCount)
args = append(args, "%"+*filter.Domain+"%")
}
if filter.License != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.license = $%d", argCount)
args = append(args, *filter.License)
}
if filter.IsActive != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.is_active = $%d", argCount)
args = append(args, *filter.IsActive)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count sources: %w", err)
}
// Data query
dataQuery := `SELECT als.id, als.policy_id, als.domain, als.name, als.description, als.license,
als.legal_basis, als.citation_template, als.trust_boost, als.is_active,
als.created_at, als.updated_at, sp.name as policy_name ` + baseQuery +
` ORDER BY als.created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list sources: %w", err)
}
defer rows.Close()
sources := []AllowedSource{}
for rows.Next() {
var src AllowedSource
err := rows.Scan(
&src.ID, &src.PolicyID, &src.Domain, &src.Name, &src.Description,
&src.License, &src.LegalBasis, &src.CitationTemplate, &src.TrustBoost,
&src.IsActive, &src.CreatedAt, &src.UpdatedAt, &src.PolicyName,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan source: %w", err)
}
sources = append(sources, src)
}
return sources, total, nil
}
// UpdateSource updates an existing source.
func (s *Store) UpdateSource(ctx context.Context, id uuid.UUID, req *UpdateAllowedSourceRequest) (*AllowedSource, error) {
source, err := s.GetSource(ctx, id)
if err != nil {
return nil, err
}
if source == nil {
return nil, fmt.Errorf("source not found")
}
if req.Domain != nil {
source.Domain = *req.Domain
}
if req.Name != nil {
source.Name = *req.Name
}
if req.Description != nil {
source.Description = req.Description
}
if req.License != nil {
source.License = *req.License
}
if req.LegalBasis != nil {
source.LegalBasis = req.LegalBasis
}
if req.CitationTemplate != nil {
source.CitationTemplate = req.CitationTemplate
}
if req.TrustBoost != nil {
source.TrustBoost = *req.TrustBoost
}
if req.IsActive != nil {
source.IsActive = *req.IsActive
}
source.UpdatedAt = time.Now()
query := `
UPDATE allowed_sources
SET domain = $2, name = $3, description = $4, license = $5, legal_basis = $6,
citation_template = $7, trust_boost = $8, is_active = $9, updated_at = $10
WHERE id = $1`
_, err = s.pool.Exec(ctx, query,
id, source.Domain, source.Name, source.Description, source.License,
source.LegalBasis, source.CitationTemplate, source.TrustBoost,
source.IsActive, source.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to update source: %w", err)
}
return source, nil
}
// DeleteSource deletes a source by ID.
func (s *Store) DeleteSource(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM allowed_sources WHERE id = $1`
_, err := s.pool.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete source: %w", err)
}
return nil
}
// =============================================================================
// OPERATION PERMISSIONS
// =============================================================================
// GetOperationsBySourceID retrieves all operation permissions for a source.
func (s *Store) GetOperationsBySourceID(ctx context.Context, sourceID uuid.UUID) ([]OperationPermission, error) {
query := `
SELECT id, source_id, operation, is_allowed, requires_citation, notes, created_at, updated_at
FROM operation_permissions
WHERE source_id = $1
ORDER BY operation`
rows, err := s.pool.Query(ctx, query, sourceID)
if err != nil {
return nil, fmt.Errorf("failed to get operations: %w", err)
}
defer rows.Close()
ops := []OperationPermission{}
for rows.Next() {
var op OperationPermission
err := rows.Scan(
&op.ID, &op.SourceID, &op.Operation, &op.IsAllowed,
&op.RequiresCitation, &op.Notes, &op.CreatedAt, &op.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan operation: %w", err)
}
ops = append(ops, op)
}
return ops, nil
}
// UpdateOperationPermission updates an operation permission.
func (s *Store) UpdateOperationPermission(ctx context.Context, id uuid.UUID, req *UpdateOperationPermissionRequest) (*OperationPermission, error) {
query := `SELECT id, source_id, operation, is_allowed, requires_citation, notes, created_at, updated_at
FROM operation_permissions WHERE id = $1`
op := &OperationPermission{}
err := s.pool.QueryRow(ctx, query, id).Scan(
&op.ID, &op.SourceID, &op.Operation, &op.IsAllowed,
&op.RequiresCitation, &op.Notes, &op.CreatedAt, &op.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("operation permission not found")
}
if err != nil {
return nil, fmt.Errorf("failed to get operation: %w", err)
}
if req.IsAllowed != nil {
op.IsAllowed = *req.IsAllowed
}
if req.RequiresCitation != nil {
op.RequiresCitation = *req.RequiresCitation
}
if req.Notes != nil {
op.Notes = req.Notes
}
op.UpdatedAt = time.Now()
updateQuery := `
UPDATE operation_permissions
SET is_allowed = $2, requires_citation = $3, notes = $4, updated_at = $5
WHERE id = $1`
_, err = s.pool.Exec(ctx, updateQuery, id, op.IsAllowed, op.RequiresCitation, op.Notes, op.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to update operation: %w", err)
}
return op, nil
}
// GetOperationsMatrix retrieves all operation permissions grouped by source.
func (s *Store) GetOperationsMatrix(ctx context.Context) ([]AllowedSource, error) {
query := `
SELECT als.id, als.domain, als.name, als.license, als.is_active,
sp.name as policy_name, sp.bundesland
FROM allowed_sources als
JOIN source_policies sp ON als.policy_id = sp.id
WHERE als.is_active = true AND sp.is_active = true
ORDER BY sp.bundesland NULLS FIRST, als.name`
rows, err := s.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to get operations matrix: %w", err)
}
defer rows.Close()
sources := []AllowedSource{}
for rows.Next() {
var src AllowedSource
var bundesland *Bundesland
err := rows.Scan(
&src.ID, &src.Domain, &src.Name, &src.License, &src.IsActive,
&src.PolicyName, &bundesland,
)
if err != nil {
return nil, fmt.Errorf("failed to scan source: %w", err)
}
// Load operations for each source
ops, err := s.GetOperationsBySourceID(ctx, src.ID)
if err != nil {
return nil, err
}
src.Operations = ops
sources = append(sources, src)
}
return sources, nil
}
// =============================================================================
// PII RULES
// =============================================================================
@@ -765,404 +357,3 @@ func (s *Store) DeletePIIRule(ctx context.Context, id uuid.UUID) error {
}
return nil
}
// =============================================================================
// AUDIT LOG
// =============================================================================
// CreateAuditLog creates a new audit log entry.
func (s *Store) CreateAuditLog(ctx context.Context, entry *PolicyAuditLog) error {
entry.ID = uuid.New()
entry.CreatedAt = time.Now()
query := `
INSERT INTO policy_audit_log (id, action, entity_type, entity_id, old_value, new_value,
user_id, user_email, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`
_, err := s.pool.Exec(ctx, query,
entry.ID, entry.Action, entry.EntityType, entry.EntityID,
entry.OldValue, entry.NewValue, entry.UserID, entry.UserEmail,
entry.IPAddress, entry.UserAgent, entry.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
}
// ListAuditLogs retrieves audit logs with filters.
func (s *Store) ListAuditLogs(ctx context.Context, filter *AuditLogFilter) ([]PolicyAuditLog, int, error) {
baseQuery := `FROM policy_audit_log WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.EntityType != nil {
argCount++
baseQuery += fmt.Sprintf(" AND entity_type = $%d", argCount)
args = append(args, *filter.EntityType)
}
if filter.EntityID != nil {
argCount++
baseQuery += fmt.Sprintf(" AND entity_id = $%d", argCount)
args = append(args, *filter.EntityID)
}
if filter.Action != nil {
argCount++
baseQuery += fmt.Sprintf(" AND action = $%d", argCount)
args = append(args, *filter.Action)
}
if filter.UserEmail != nil {
argCount++
baseQuery += fmt.Sprintf(" AND user_email ILIKE $%d", argCount)
args = append(args, "%"+*filter.UserEmail+"%")
}
if filter.FromDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at >= $%d", argCount)
args = append(args, *filter.FromDate)
}
if filter.ToDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at <= $%d", argCount)
args = append(args, *filter.ToDate)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
// Data query
dataQuery := `SELECT id, action, entity_type, entity_id, old_value, new_value,
user_id, user_email, ip_address, user_agent, created_at ` + baseQuery +
` ORDER BY created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list audit logs: %w", err)
}
defer rows.Close()
logs := []PolicyAuditLog{}
for rows.Next() {
var l PolicyAuditLog
err := rows.Scan(
&l.ID, &l.Action, &l.EntityType, &l.EntityID, &l.OldValue, &l.NewValue,
&l.UserID, &l.UserEmail, &l.IPAddress, &l.UserAgent, &l.CreatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan audit log: %w", err)
}
logs = append(logs, l)
}
return logs, total, nil
}
// =============================================================================
// BLOCKED CONTENT LOG
// =============================================================================
// CreateBlockedContentLog creates a new blocked content log entry.
func (s *Store) CreateBlockedContentLog(ctx context.Context, entry *BlockedContentLog) error {
entry.ID = uuid.New()
entry.CreatedAt = time.Now()
query := `
INSERT INTO blocked_content_log (id, url, domain, block_reason, matched_rule_id, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := s.pool.Exec(ctx, query,
entry.ID, entry.URL, entry.Domain, entry.BlockReason,
entry.MatchedRuleID, entry.Details, entry.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create blocked content log: %w", err)
}
return nil
}
// ListBlockedContent retrieves blocked content logs with filters.
func (s *Store) ListBlockedContent(ctx context.Context, filter *BlockedContentFilter) ([]BlockedContentLog, int, error) {
baseQuery := `FROM blocked_content_log WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.Domain != nil {
argCount++
baseQuery += fmt.Sprintf(" AND domain ILIKE $%d", argCount)
args = append(args, "%"+*filter.Domain+"%")
}
if filter.BlockReason != nil {
argCount++
baseQuery += fmt.Sprintf(" AND block_reason = $%d", argCount)
args = append(args, *filter.BlockReason)
}
if filter.FromDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at >= $%d", argCount)
args = append(args, *filter.FromDate)
}
if filter.ToDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at <= $%d", argCount)
args = append(args, *filter.ToDate)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count blocked content: %w", err)
}
// Data query
dataQuery := `SELECT id, url, domain, block_reason, matched_rule_id, details, created_at ` + baseQuery +
` ORDER BY created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list blocked content: %w", err)
}
defer rows.Close()
logs := []BlockedContentLog{}
for rows.Next() {
var l BlockedContentLog
err := rows.Scan(
&l.ID, &l.URL, &l.Domain, &l.BlockReason,
&l.MatchedRuleID, &l.Details, &l.CreatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan blocked content: %w", err)
}
logs = append(logs, l)
}
return logs, total, nil
}
// =============================================================================
// STATISTICS
// =============================================================================
// GetStats retrieves aggregated statistics for the policy system.
func (s *Store) GetStats(ctx context.Context) (*PolicyStats, error) {
stats := &PolicyStats{
SourcesByLicense: make(map[string]int),
BlocksByReason: make(map[string]int),
}
// Active policies
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM source_policies WHERE is_active = true`).Scan(&stats.ActivePolicies)
if err != nil {
return nil, fmt.Errorf("failed to count active policies: %w", err)
}
// Total sources
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM allowed_sources`).Scan(&stats.TotalSources)
if err != nil {
return nil, fmt.Errorf("failed to count total sources: %w", err)
}
// Active sources
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM allowed_sources WHERE is_active = true`).Scan(&stats.ActiveSources)
if err != nil {
return nil, fmt.Errorf("failed to count active sources: %w", err)
}
// Blocked today
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM blocked_content_log WHERE created_at >= CURRENT_DATE`).Scan(&stats.BlockedToday)
if err != nil {
return nil, fmt.Errorf("failed to count blocked today: %w", err)
}
// Blocked total
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM blocked_content_log`).Scan(&stats.BlockedTotal)
if err != nil {
return nil, fmt.Errorf("failed to count blocked total: %w", err)
}
// Active PII rules
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pii_rules WHERE is_active = true`).Scan(&stats.PIIRulesActive)
if err != nil {
return nil, fmt.Errorf("failed to count active PII rules: %w", err)
}
// Sources by license
rows, err := s.pool.Query(ctx, `SELECT license, COUNT(*) FROM allowed_sources GROUP BY license`)
if err != nil {
return nil, fmt.Errorf("failed to count sources by license: %w", err)
}
defer rows.Close()
for rows.Next() {
var license string
var count int
if err := rows.Scan(&license, &count); err != nil {
return nil, err
}
stats.SourcesByLicense[license] = count
}
// Blocks by reason
rows, err = s.pool.Query(ctx, `SELECT block_reason, COUNT(*) FROM blocked_content_log GROUP BY block_reason`)
if err != nil {
return nil, fmt.Errorf("failed to count blocks by reason: %w", err)
}
defer rows.Close()
for rows.Next() {
var reason string
var count int
if err := rows.Scan(&reason, &count); err != nil {
return nil, err
}
stats.BlocksByReason[reason] = count
}
// Compliance score (simplified: active sources / total sources)
if stats.TotalSources > 0 {
stats.ComplianceScore = float64(stats.ActiveSources) / float64(stats.TotalSources) * 100
}
return stats, nil
}
// =============================================================================
// YAML LOADER
// =============================================================================
// LoadFromYAML loads initial policy data from YAML configuration.
func (s *Store) LoadFromYAML(ctx context.Context, config *BundeslaenderConfig) error {
// Load federal policy
if config.Federal.Name != "" {
err := s.loadPolicy(ctx, nil, &config.Federal, &config.DefaultOperations)
if err != nil {
return fmt.Errorf("failed to load federal policy: %w", err)
}
}
// Load Bundesland policies
for code, policyConfig := range config.Bundeslaender {
if code == "federal" || code == "default_operations" || code == "pii_rules" {
continue
}
bl := Bundesland(code)
err := s.loadPolicy(ctx, &bl, &policyConfig, &config.DefaultOperations)
if err != nil {
return fmt.Errorf("failed to load policy for %s: %w", code, err)
}
}
// Load PII rules
for _, ruleConfig := range config.PIIRules {
err := s.loadPIIRule(ctx, &ruleConfig)
if err != nil {
return fmt.Errorf("failed to load PII rule %s: %w", ruleConfig.Name, err)
}
}
return nil
}
func (s *Store) loadPolicy(ctx context.Context, bundesland *Bundesland, config *PolicyConfig, ops *OperationsConfig) error {
// Create policy
policy, err := s.CreatePolicy(ctx, &CreateSourcePolicyRequest{
Name: config.Name,
Bundesland: bundesland,
})
if err != nil {
return err
}
// Create sources
for _, srcConfig := range config.Sources {
trustBoost := 0.5
if srcConfig.TrustBoost > 0 {
trustBoost = srcConfig.TrustBoost
}
var legalBasis, citation *string
if srcConfig.LegalBasis != "" {
legalBasis = &srcConfig.LegalBasis
}
if srcConfig.CitationTemplate != "" {
citation = &srcConfig.CitationTemplate
}
_, err := s.CreateSource(ctx, &CreateAllowedSourceRequest{
PolicyID: policy.ID,
Domain: srcConfig.Domain,
Name: srcConfig.Name,
License: License(srcConfig.License),
LegalBasis: legalBasis,
CitationTemplate: citation,
TrustBoost: &trustBoost,
})
if err != nil {
return fmt.Errorf("failed to create source %s: %w", srcConfig.Domain, err)
}
}
return nil
}
func (s *Store) loadPIIRule(ctx context.Context, config *PIIRuleConfig) error {
severity := PIISeverityBlock
if config.Severity != "" {
severity = PIISeverity(config.Severity)
}
_, err := s.CreatePIIRule(ctx, &CreatePIIRuleRequest{
Name: config.Name,
RuleType: PIIRuleType(config.Type),
Pattern: config.Pattern,
Severity: severity,
})
return err
}
// ToJSON converts an entity to JSON for audit logging.
func ToJSON(v interface{}) json.RawMessage {
data, _ := json.Marshal(v)
return data
}

View File

@@ -0,0 +1,411 @@
package policy
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// =============================================================================
// AUDIT LOG
// =============================================================================
// CreateAuditLog creates a new audit log entry.
func (s *Store) CreateAuditLog(ctx context.Context, entry *PolicyAuditLog) error {
entry.ID = uuid.New()
entry.CreatedAt = time.Now()
query := `
INSERT INTO policy_audit_log (id, action, entity_type, entity_id, old_value, new_value,
user_id, user_email, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`
_, err := s.pool.Exec(ctx, query,
entry.ID, entry.Action, entry.EntityType, entry.EntityID,
entry.OldValue, entry.NewValue, entry.UserID, entry.UserEmail,
entry.IPAddress, entry.UserAgent, entry.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
}
// ListAuditLogs retrieves audit logs with filters.
func (s *Store) ListAuditLogs(ctx context.Context, filter *AuditLogFilter) ([]PolicyAuditLog, int, error) {
baseQuery := `FROM policy_audit_log WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.EntityType != nil {
argCount++
baseQuery += fmt.Sprintf(" AND entity_type = $%d", argCount)
args = append(args, *filter.EntityType)
}
if filter.EntityID != nil {
argCount++
baseQuery += fmt.Sprintf(" AND entity_id = $%d", argCount)
args = append(args, *filter.EntityID)
}
if filter.Action != nil {
argCount++
baseQuery += fmt.Sprintf(" AND action = $%d", argCount)
args = append(args, *filter.Action)
}
if filter.UserEmail != nil {
argCount++
baseQuery += fmt.Sprintf(" AND user_email ILIKE $%d", argCount)
args = append(args, "%"+*filter.UserEmail+"%")
}
if filter.FromDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at >= $%d", argCount)
args = append(args, *filter.FromDate)
}
if filter.ToDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at <= $%d", argCount)
args = append(args, *filter.ToDate)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
}
// Data query
dataQuery := `SELECT id, action, entity_type, entity_id, old_value, new_value,
user_id, user_email, ip_address, user_agent, created_at ` + baseQuery +
` ORDER BY created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list audit logs: %w", err)
}
defer rows.Close()
logs := []PolicyAuditLog{}
for rows.Next() {
var l PolicyAuditLog
err := rows.Scan(
&l.ID, &l.Action, &l.EntityType, &l.EntityID, &l.OldValue, &l.NewValue,
&l.UserID, &l.UserEmail, &l.IPAddress, &l.UserAgent, &l.CreatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan audit log: %w", err)
}
logs = append(logs, l)
}
return logs, total, nil
}
// =============================================================================
// BLOCKED CONTENT LOG
// =============================================================================
// CreateBlockedContentLog creates a new blocked content log entry.
func (s *Store) CreateBlockedContentLog(ctx context.Context, entry *BlockedContentLog) error {
entry.ID = uuid.New()
entry.CreatedAt = time.Now()
query := `
INSERT INTO blocked_content_log (id, url, domain, block_reason, matched_rule_id, details, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := s.pool.Exec(ctx, query,
entry.ID, entry.URL, entry.Domain, entry.BlockReason,
entry.MatchedRuleID, entry.Details, entry.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create blocked content log: %w", err)
}
return nil
}
// ListBlockedContent retrieves blocked content logs with filters.
func (s *Store) ListBlockedContent(ctx context.Context, filter *BlockedContentFilter) ([]BlockedContentLog, int, error) {
baseQuery := `FROM blocked_content_log WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.Domain != nil {
argCount++
baseQuery += fmt.Sprintf(" AND domain ILIKE $%d", argCount)
args = append(args, "%"+*filter.Domain+"%")
}
if filter.BlockReason != nil {
argCount++
baseQuery += fmt.Sprintf(" AND block_reason = $%d", argCount)
args = append(args, *filter.BlockReason)
}
if filter.FromDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at >= $%d", argCount)
args = append(args, *filter.FromDate)
}
if filter.ToDate != nil {
argCount++
baseQuery += fmt.Sprintf(" AND created_at <= $%d", argCount)
args = append(args, *filter.ToDate)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count blocked content: %w", err)
}
// Data query
dataQuery := `SELECT id, url, domain, block_reason, matched_rule_id, details, created_at ` + baseQuery +
` ORDER BY created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list blocked content: %w", err)
}
defer rows.Close()
logs := []BlockedContentLog{}
for rows.Next() {
var l BlockedContentLog
err := rows.Scan(
&l.ID, &l.URL, &l.Domain, &l.BlockReason,
&l.MatchedRuleID, &l.Details, &l.CreatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan blocked content: %w", err)
}
logs = append(logs, l)
}
return logs, total, nil
}
// =============================================================================
// STATISTICS
// =============================================================================
// GetStats retrieves aggregated statistics for the policy system.
func (s *Store) GetStats(ctx context.Context) (*PolicyStats, error) {
stats := &PolicyStats{
SourcesByLicense: make(map[string]int),
BlocksByReason: make(map[string]int),
}
// Active policies
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM source_policies WHERE is_active = true`).Scan(&stats.ActivePolicies)
if err != nil {
return nil, fmt.Errorf("failed to count active policies: %w", err)
}
// Total sources
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM allowed_sources`).Scan(&stats.TotalSources)
if err != nil {
return nil, fmt.Errorf("failed to count total sources: %w", err)
}
// Active sources
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM allowed_sources WHERE is_active = true`).Scan(&stats.ActiveSources)
if err != nil {
return nil, fmt.Errorf("failed to count active sources: %w", err)
}
// Blocked today
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM blocked_content_log WHERE created_at >= CURRENT_DATE`).Scan(&stats.BlockedToday)
if err != nil {
return nil, fmt.Errorf("failed to count blocked today: %w", err)
}
// Blocked total
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM blocked_content_log`).Scan(&stats.BlockedTotal)
if err != nil {
return nil, fmt.Errorf("failed to count blocked total: %w", err)
}
// Active PII rules
err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pii_rules WHERE is_active = true`).Scan(&stats.PIIRulesActive)
if err != nil {
return nil, fmt.Errorf("failed to count active PII rules: %w", err)
}
// Sources by license
rows, err := s.pool.Query(ctx, `SELECT license, COUNT(*) FROM allowed_sources GROUP BY license`)
if err != nil {
return nil, fmt.Errorf("failed to count sources by license: %w", err)
}
defer rows.Close()
for rows.Next() {
var license string
var count int
if err := rows.Scan(&license, &count); err != nil {
return nil, err
}
stats.SourcesByLicense[license] = count
}
// Blocks by reason
rows, err = s.pool.Query(ctx, `SELECT block_reason, COUNT(*) FROM blocked_content_log GROUP BY block_reason`)
if err != nil {
return nil, fmt.Errorf("failed to count blocks by reason: %w", err)
}
defer rows.Close()
for rows.Next() {
var reason string
var count int
if err := rows.Scan(&reason, &count); err != nil {
return nil, err
}
stats.BlocksByReason[reason] = count
}
// Compliance score (simplified: active sources / total sources)
if stats.TotalSources > 0 {
stats.ComplianceScore = float64(stats.ActiveSources) / float64(stats.TotalSources) * 100
}
return stats, nil
}
// =============================================================================
// YAML LOADER
// =============================================================================
// LoadFromYAML loads initial policy data from YAML configuration.
func (s *Store) LoadFromYAML(ctx context.Context, config *BundeslaenderConfig) error {
// Load federal policy
if config.Federal.Name != "" {
err := s.loadPolicy(ctx, nil, &config.Federal, &config.DefaultOperations)
if err != nil {
return fmt.Errorf("failed to load federal policy: %w", err)
}
}
// Load Bundesland policies
for code, policyConfig := range config.Bundeslaender {
if code == "federal" || code == "default_operations" || code == "pii_rules" {
continue
}
bl := Bundesland(code)
err := s.loadPolicy(ctx, &bl, &policyConfig, &config.DefaultOperations)
if err != nil {
return fmt.Errorf("failed to load policy for %s: %w", code, err)
}
}
// Load PII rules
for _, ruleConfig := range config.PIIRules {
err := s.loadPIIRule(ctx, &ruleConfig)
if err != nil {
return fmt.Errorf("failed to load PII rule %s: %w", ruleConfig.Name, err)
}
}
return nil
}
func (s *Store) loadPolicy(ctx context.Context, bundesland *Bundesland, config *PolicyConfig, ops *OperationsConfig) error {
// Create policy
policy, err := s.CreatePolicy(ctx, &CreateSourcePolicyRequest{
Name: config.Name,
Bundesland: bundesland,
})
if err != nil {
return err
}
// Create sources
for _, srcConfig := range config.Sources {
trustBoost := 0.5
if srcConfig.TrustBoost > 0 {
trustBoost = srcConfig.TrustBoost
}
var legalBasis, citation *string
if srcConfig.LegalBasis != "" {
legalBasis = &srcConfig.LegalBasis
}
if srcConfig.CitationTemplate != "" {
citation = &srcConfig.CitationTemplate
}
_, err := s.CreateSource(ctx, &CreateAllowedSourceRequest{
PolicyID: policy.ID,
Domain: srcConfig.Domain,
Name: srcConfig.Name,
License: License(srcConfig.License),
LegalBasis: legalBasis,
CitationTemplate: citation,
TrustBoost: &trustBoost,
})
if err != nil {
return fmt.Errorf("failed to create source %s: %w", srcConfig.Domain, err)
}
}
return nil
}
func (s *Store) loadPIIRule(ctx context.Context, config *PIIRuleConfig) error {
severity := PIISeverityBlock
if config.Severity != "" {
severity = PIISeverity(config.Severity)
}
_, err := s.CreatePIIRule(ctx, &CreatePIIRuleRequest{
Name: config.Name,
RuleType: PIIRuleType(config.Type),
Pattern: config.Pattern,
Severity: severity,
})
return err
}
// ToJSON converts an entity to JSON for audit logging.
func ToJSON(v interface{}) json.RawMessage {
data, _ := json.Marshal(v)
return data
}

View File

@@ -0,0 +1,417 @@
package policy
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// =============================================================================
// ALLOWED SOURCES
// =============================================================================
// CreateSource creates a new allowed source.
func (s *Store) CreateSource(ctx context.Context, req *CreateAllowedSourceRequest) (*AllowedSource, error) {
trustBoost := 0.5
if req.TrustBoost != nil {
trustBoost = *req.TrustBoost
}
source := &AllowedSource{
ID: uuid.New(),
PolicyID: req.PolicyID,
Domain: req.Domain,
Name: req.Name,
Description: req.Description,
License: req.License,
LegalBasis: req.LegalBasis,
CitationTemplate: req.CitationTemplate,
TrustBoost: trustBoost,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO allowed_sources (id, policy_id, domain, name, description, license,
legal_basis, citation_template, trust_boost, is_active,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`
err := s.pool.QueryRow(ctx, query,
source.ID, source.PolicyID, source.Domain, source.Name, source.Description,
source.License, source.LegalBasis, source.CitationTemplate, source.TrustBoost,
source.IsActive, source.CreatedAt, source.UpdatedAt,
).Scan(&source.ID)
if err != nil {
return nil, fmt.Errorf("failed to create source: %w", err)
}
// Create default operation permissions
err = s.createDefaultOperations(ctx, source.ID)
if err != nil {
return nil, fmt.Errorf("failed to create default operations: %w", err)
}
return source, nil
}
// createDefaultOperations creates default operation permissions for a source.
func (s *Store) createDefaultOperations(ctx context.Context, sourceID uuid.UUID) error {
defaults := []struct {
op Operation
allowed bool
citation bool
}{
{OperationLookup, true, true},
{OperationRAG, true, true},
{OperationTraining, false, false}, // VERBOTEN by default
{OperationExport, true, true},
}
for _, d := range defaults {
query := `
INSERT INTO operation_permissions (id, source_id, operation, is_allowed, requires_citation, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`
_, err := s.pool.Exec(ctx, query, uuid.New(), sourceID, d.op, d.allowed, d.citation, time.Now(), time.Now())
if err != nil {
return err
}
}
return nil
}
// GetSource retrieves a source by ID.
func (s *Store) GetSource(ctx context.Context, id uuid.UUID) (*AllowedSource, error) {
query := `
SELECT als.id, als.policy_id, als.domain, als.name, als.description, als.license,
als.legal_basis, als.citation_template, als.trust_boost, als.is_active,
als.created_at, als.updated_at, sp.name as policy_name
FROM allowed_sources als
JOIN source_policies sp ON als.policy_id = sp.id
WHERE als.id = $1`
source := &AllowedSource{}
err := s.pool.QueryRow(ctx, query, id).Scan(
&source.ID, &source.PolicyID, &source.Domain, &source.Name, &source.Description,
&source.License, &source.LegalBasis, &source.CitationTemplate, &source.TrustBoost,
&source.IsActive, &source.CreatedAt, &source.UpdatedAt, &source.PolicyName,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get source: %w", err)
}
// Load operations
ops, err := s.GetOperationsBySourceID(ctx, source.ID)
if err != nil {
return nil, err
}
source.Operations = ops
return source, nil
}
// GetSourceByDomain retrieves a source by domain with optional bundesland filter.
func (s *Store) GetSourceByDomain(ctx context.Context, domain string, bundesland *Bundesland) (*AllowedSource, error) {
query := `
SELECT als.id, als.policy_id, als.domain, als.name, als.description, als.license,
als.legal_basis, als.citation_template, als.trust_boost, als.is_active,
als.created_at, als.updated_at
FROM allowed_sources als
JOIN source_policies sp ON als.policy_id = sp.id
WHERE als.is_active = true
AND sp.is_active = true
AND (als.domain = $1 OR $1 LIKE '%.' || als.domain)
AND (sp.bundesland IS NULL OR sp.bundesland = $2)
LIMIT 1`
source := &AllowedSource{}
err := s.pool.QueryRow(ctx, query, domain, bundesland).Scan(
&source.ID, &source.PolicyID, &source.Domain, &source.Name, &source.Description,
&source.License, &source.LegalBasis, &source.CitationTemplate, &source.TrustBoost,
&source.IsActive, &source.CreatedAt, &source.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get source by domain: %w", err)
}
// Load operations
ops, err := s.GetOperationsBySourceID(ctx, source.ID)
if err != nil {
return nil, err
}
source.Operations = ops
return source, nil
}
// ListSources retrieves sources with optional filters.
func (s *Store) ListSources(ctx context.Context, filter *SourceListFilter) ([]AllowedSource, int, error) {
baseQuery := `FROM allowed_sources als JOIN source_policies sp ON als.policy_id = sp.id WHERE 1=1`
args := []interface{}{}
argCount := 0
if filter.PolicyID != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.policy_id = $%d", argCount)
args = append(args, *filter.PolicyID)
}
if filter.Domain != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.domain ILIKE $%d", argCount)
args = append(args, "%"+*filter.Domain+"%")
}
if filter.License != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.license = $%d", argCount)
args = append(args, *filter.License)
}
if filter.IsActive != nil {
argCount++
baseQuery += fmt.Sprintf(" AND als.is_active = $%d", argCount)
args = append(args, *filter.IsActive)
}
// Count query
var total int
countQuery := "SELECT COUNT(*) " + baseQuery
err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to count sources: %w", err)
}
// Data query
dataQuery := `SELECT als.id, als.policy_id, als.domain, als.name, als.description, als.license,
als.legal_basis, als.citation_template, als.trust_boost, als.is_active,
als.created_at, als.updated_at, sp.name as policy_name ` + baseQuery +
` ORDER BY als.created_at DESC`
if filter.Limit > 0 {
argCount++
dataQuery += fmt.Sprintf(" LIMIT $%d", argCount)
args = append(args, filter.Limit)
}
if filter.Offset > 0 {
argCount++
dataQuery += fmt.Sprintf(" OFFSET $%d", argCount)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list sources: %w", err)
}
defer rows.Close()
sources := []AllowedSource{}
for rows.Next() {
var src AllowedSource
err := rows.Scan(
&src.ID, &src.PolicyID, &src.Domain, &src.Name, &src.Description,
&src.License, &src.LegalBasis, &src.CitationTemplate, &src.TrustBoost,
&src.IsActive, &src.CreatedAt, &src.UpdatedAt, &src.PolicyName,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan source: %w", err)
}
sources = append(sources, src)
}
return sources, total, nil
}
// UpdateSource updates an existing source.
func (s *Store) UpdateSource(ctx context.Context, id uuid.UUID, req *UpdateAllowedSourceRequest) (*AllowedSource, error) {
source, err := s.GetSource(ctx, id)
if err != nil {
return nil, err
}
if source == nil {
return nil, fmt.Errorf("source not found")
}
if req.Domain != nil {
source.Domain = *req.Domain
}
if req.Name != nil {
source.Name = *req.Name
}
if req.Description != nil {
source.Description = req.Description
}
if req.License != nil {
source.License = *req.License
}
if req.LegalBasis != nil {
source.LegalBasis = req.LegalBasis
}
if req.CitationTemplate != nil {
source.CitationTemplate = req.CitationTemplate
}
if req.TrustBoost != nil {
source.TrustBoost = *req.TrustBoost
}
if req.IsActive != nil {
source.IsActive = *req.IsActive
}
source.UpdatedAt = time.Now()
query := `
UPDATE allowed_sources
SET domain = $2, name = $3, description = $4, license = $5, legal_basis = $6,
citation_template = $7, trust_boost = $8, is_active = $9, updated_at = $10
WHERE id = $1`
_, err = s.pool.Exec(ctx, query,
id, source.Domain, source.Name, source.Description, source.License,
source.LegalBasis, source.CitationTemplate, source.TrustBoost,
source.IsActive, source.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to update source: %w", err)
}
return source, nil
}
// DeleteSource deletes a source by ID.
func (s *Store) DeleteSource(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM allowed_sources WHERE id = $1`
_, err := s.pool.Exec(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete source: %w", err)
}
return nil
}
// =============================================================================
// OPERATION PERMISSIONS
// =============================================================================
// GetOperationsBySourceID retrieves all operation permissions for a source.
func (s *Store) GetOperationsBySourceID(ctx context.Context, sourceID uuid.UUID) ([]OperationPermission, error) {
query := `
SELECT id, source_id, operation, is_allowed, requires_citation, notes, created_at, updated_at
FROM operation_permissions
WHERE source_id = $1
ORDER BY operation`
rows, err := s.pool.Query(ctx, query, sourceID)
if err != nil {
return nil, fmt.Errorf("failed to get operations: %w", err)
}
defer rows.Close()
ops := []OperationPermission{}
for rows.Next() {
var op OperationPermission
err := rows.Scan(
&op.ID, &op.SourceID, &op.Operation, &op.IsAllowed,
&op.RequiresCitation, &op.Notes, &op.CreatedAt, &op.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan operation: %w", err)
}
ops = append(ops, op)
}
return ops, nil
}
// UpdateOperationPermission updates an operation permission.
func (s *Store) UpdateOperationPermission(ctx context.Context, id uuid.UUID, req *UpdateOperationPermissionRequest) (*OperationPermission, error) {
query := `SELECT id, source_id, operation, is_allowed, requires_citation, notes, created_at, updated_at
FROM operation_permissions WHERE id = $1`
op := &OperationPermission{}
err := s.pool.QueryRow(ctx, query, id).Scan(
&op.ID, &op.SourceID, &op.Operation, &op.IsAllowed,
&op.RequiresCitation, &op.Notes, &op.CreatedAt, &op.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("operation permission not found")
}
if err != nil {
return nil, fmt.Errorf("failed to get operation: %w", err)
}
if req.IsAllowed != nil {
op.IsAllowed = *req.IsAllowed
}
if req.RequiresCitation != nil {
op.RequiresCitation = *req.RequiresCitation
}
if req.Notes != nil {
op.Notes = req.Notes
}
op.UpdatedAt = time.Now()
updateQuery := `
UPDATE operation_permissions
SET is_allowed = $2, requires_citation = $3, notes = $4, updated_at = $5
WHERE id = $1`
_, err = s.pool.Exec(ctx, updateQuery, id, op.IsAllowed, op.RequiresCitation, op.Notes, op.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to update operation: %w", err)
}
return op, nil
}
// GetOperationsMatrix retrieves all operation permissions grouped by source.
func (s *Store) GetOperationsMatrix(ctx context.Context) ([]AllowedSource, error) {
query := `
SELECT als.id, als.domain, als.name, als.license, als.is_active,
sp.name as policy_name, sp.bundesland
FROM allowed_sources als
JOIN source_policies sp ON als.policy_id = sp.id
WHERE als.is_active = true AND sp.is_active = true
ORDER BY sp.bundesland NULLS FIRST, als.name`
rows, err := s.pool.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to get operations matrix: %w", err)
}
defer rows.Close()
sources := []AllowedSource{}
for rows.Next() {
var src AllowedSource
var bundesland *Bundesland
err := rows.Scan(
&src.ID, &src.Domain, &src.Name, &src.License, &src.IsActive,
&src.PolicyName, &bundesland,
)
if err != nil {
return nil, fmt.Errorf("failed to scan source: %w", err)
}
// Load operations for each source
ops, err := s.GetOperationsBySourceID(ctx, src.ID)
if err != nil {
return nil, err
}
src.Operations = ops
sources = append(sources, src)
}
return sources, nil
}

View File

@@ -214,355 +214,6 @@ func (s *Service) Search(ctx context.Context, req *SearchRequest) (*SearchRespon
}, nil
}
// buildQuery constructs the OpenSearch query
func (s *Service) buildQuery(req *SearchRequest) map[string]interface{} {
// Main query
must := []map[string]interface{}{}
filter := []map[string]interface{}{}
// Text search
if req.Query != "" {
must = append(must, map[string]interface{}{
"multi_match": map[string]interface{}{
"query": req.Query,
"fields": []string{"title^3", "content_text"},
"type": "best_fields",
},
})
}
// Filters
if len(req.Filters.Language) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"language": req.Filters.Language},
})
}
if len(req.Filters.CountryHint) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"country_hint": req.Filters.CountryHint},
})
}
if len(req.Filters.SourceCategory) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"source_category": req.Filters.SourceCategory},
})
}
if len(req.Filters.DocType) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"doc_type": req.Filters.DocType},
})
}
if len(req.Filters.SchoolLevel) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"school_level": req.Filters.SchoolLevel},
})
}
if len(req.Filters.Subjects) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"subjects": req.Filters.Subjects},
})
}
if len(req.Filters.State) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"state": req.Filters.State},
})
}
if req.Filters.MinTrustScore > 0 {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"trust_score": map[string]interface{}{"gte": req.Filters.MinTrustScore},
},
})
}
if req.Filters.DateFrom != "" {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"fetch_time": map[string]interface{}{"gte": req.Filters.DateFrom},
},
})
}
// Build bool query
boolQuery := map[string]interface{}{}
if len(must) > 0 {
boolQuery["must"] = must
}
if len(filter) > 0 {
boolQuery["filter"] = filter
}
// Construct full query
query := map[string]interface{}{
"query": map[string]interface{}{
"bool": boolQuery,
},
"from": req.Offset,
"size": req.Limit,
"_source": []string{
"doc_id", "title", "url", "domain", "language",
"doc_type", "school_level", "subjects",
"trust_score", "quality_score", "snippet_text",
},
}
// Add highlighting if requested
if req.Include.Highlights {
query["highlight"] = map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content_text": map[string]interface{}{"fragment_size": 150, "number_of_fragments": 3},
},
}
}
// Add function score for trust/quality boosting
query["query"] = map[string]interface{}{
"function_score": map[string]interface{}{
"query": query["query"],
"functions": []map[string]interface{}{
{
"field_value_factor": map[string]interface{}{
"field": "trust_score",
"factor": 1.5,
"modifier": "sqrt",
"missing": 0.5,
},
},
{
"field_value_factor": map[string]interface{}{
"field": "quality_score",
"factor": 1.0,
"modifier": "sqrt",
"missing": 0.5,
},
},
},
"score_mode": "multiply",
"boost_mode": "multiply",
},
}
return query
}
// buildSemanticQuery constructs a pure vector search query using k-NN
func (s *Service) buildSemanticQuery(req *SearchRequest, embedding []float32) map[string]interface{} {
filter := s.buildFilters(req)
// k-NN query for semantic search
knnQuery := map[string]interface{}{
"content_embedding": map[string]interface{}{
"vector": embedding,
"k": req.Limit + req.Offset, // Get enough results for pagination
},
}
// Add filter if present
if len(filter) > 0 {
knnQuery["content_embedding"].(map[string]interface{})["filter"] = map[string]interface{}{
"bool": map[string]interface{}{
"filter": filter,
},
}
}
query := map[string]interface{}{
"knn": knnQuery,
"from": req.Offset,
"size": req.Limit,
"_source": []string{
"doc_id", "title", "url", "domain", "language",
"doc_type", "school_level", "subjects",
"trust_score", "quality_score", "snippet_text",
},
}
// Add highlighting if requested
if req.Include.Highlights {
query["highlight"] = map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content_text": map[string]interface{}{"fragment_size": 150, "number_of_fragments": 3},
},
}
}
return query
}
// buildHybridQuery constructs a combined BM25 + vector search query
func (s *Service) buildHybridQuery(req *SearchRequest, embedding []float32) map[string]interface{} {
filter := s.buildFilters(req)
// Build the bool query for BM25
must := []map[string]interface{}{}
if req.Query != "" {
must = append(must, map[string]interface{}{
"multi_match": map[string]interface{}{
"query": req.Query,
"fields": []string{"title^3", "content_text"},
"type": "best_fields",
},
})
}
boolQuery := map[string]interface{}{}
if len(must) > 0 {
boolQuery["must"] = must
}
if len(filter) > 0 {
boolQuery["filter"] = filter
}
// Convert embedding to []interface{} for JSON
embeddingInterface := make([]interface{}, len(embedding))
for i, v := range embedding {
embeddingInterface[i] = v
}
// Hybrid query using script_score to combine BM25 and cosine similarity
// This is a simpler approach than OpenSearch's neural search plugin
query := map[string]interface{}{
"query": map[string]interface{}{
"script_score": map[string]interface{}{
"query": map[string]interface{}{
"bool": boolQuery,
},
"script": map[string]interface{}{
"source": "cosineSimilarity(params.query_vector, 'content_embedding') + 1.0 + _score * 0.5",
"params": map[string]interface{}{
"query_vector": embeddingInterface,
},
},
},
},
"from": req.Offset,
"size": req.Limit,
"_source": []string{
"doc_id", "title", "url", "domain", "language",
"doc_type", "school_level", "subjects",
"trust_score", "quality_score", "snippet_text",
},
}
// Add highlighting if requested
if req.Include.Highlights {
query["highlight"] = map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content_text": map[string]interface{}{"fragment_size": 150, "number_of_fragments": 3},
},
}
}
return query
}
// buildFilters constructs the filter array for queries
func (s *Service) buildFilters(req *SearchRequest) []map[string]interface{} {
filter := []map[string]interface{}{}
if len(req.Filters.Language) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"language": req.Filters.Language},
})
}
if len(req.Filters.CountryHint) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"country_hint": req.Filters.CountryHint},
})
}
if len(req.Filters.SourceCategory) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"source_category": req.Filters.SourceCategory},
})
}
if len(req.Filters.DocType) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"doc_type": req.Filters.DocType},
})
}
if len(req.Filters.SchoolLevel) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"school_level": req.Filters.SchoolLevel},
})
}
if len(req.Filters.Subjects) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"subjects": req.Filters.Subjects},
})
}
if len(req.Filters.State) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"state": req.Filters.State},
})
}
if req.Filters.MinTrustScore > 0 {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"trust_score": map[string]interface{}{"gte": req.Filters.MinTrustScore},
},
})
}
if req.Filters.DateFrom != "" {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"fetch_time": map[string]interface{}{"gte": req.Filters.DateFrom},
},
})
}
return filter
}
// hitToResult converts an OpenSearch hit to SearchResult
func (s *Service) hitToResult(source map[string]interface{}, score float64, highlight map[string][]string, include SearchInclude) SearchResult {
result := SearchResult{
DocID: getString(source, "doc_id"),
Title: getString(source, "title"),
URL: getString(source, "url"),
Domain: getString(source, "domain"),
Language: getString(source, "language"),
DocType: getString(source, "doc_type"),
SchoolLevel: getString(source, "school_level"),
Subjects: getStringArray(source, "subjects"),
Scores: Scores{
BM25: score,
Trust: getFloat(source, "trust_score"),
Quality: getFloat(source, "quality_score"),
Final: score, // MVP: final = BM25 * trust * quality (via function_score)
},
}
if include.Snippets {
result.Snippet = getString(source, "snippet_text")
}
if include.Highlights && highlight != nil {
if h, ok := highlight["content_text"]; ok {
result.Highlights = h
}
}
return result
}
// Helper functions
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {

View File

@@ -0,0 +1,350 @@
package search
// buildQuery constructs the OpenSearch query
func (s *Service) buildQuery(req *SearchRequest) map[string]interface{} {
// Main query
must := []map[string]interface{}{}
filter := []map[string]interface{}{}
// Text search
if req.Query != "" {
must = append(must, map[string]interface{}{
"multi_match": map[string]interface{}{
"query": req.Query,
"fields": []string{"title^3", "content_text"},
"type": "best_fields",
},
})
}
// Filters
if len(req.Filters.Language) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"language": req.Filters.Language},
})
}
if len(req.Filters.CountryHint) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"country_hint": req.Filters.CountryHint},
})
}
if len(req.Filters.SourceCategory) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"source_category": req.Filters.SourceCategory},
})
}
if len(req.Filters.DocType) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"doc_type": req.Filters.DocType},
})
}
if len(req.Filters.SchoolLevel) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"school_level": req.Filters.SchoolLevel},
})
}
if len(req.Filters.Subjects) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"subjects": req.Filters.Subjects},
})
}
if len(req.Filters.State) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"state": req.Filters.State},
})
}
if req.Filters.MinTrustScore > 0 {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"trust_score": map[string]interface{}{"gte": req.Filters.MinTrustScore},
},
})
}
if req.Filters.DateFrom != "" {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"fetch_time": map[string]interface{}{"gte": req.Filters.DateFrom},
},
})
}
// Build bool query
boolQuery := map[string]interface{}{}
if len(must) > 0 {
boolQuery["must"] = must
}
if len(filter) > 0 {
boolQuery["filter"] = filter
}
// Construct full query
query := map[string]interface{}{
"query": map[string]interface{}{
"bool": boolQuery,
},
"from": req.Offset,
"size": req.Limit,
"_source": []string{
"doc_id", "title", "url", "domain", "language",
"doc_type", "school_level", "subjects",
"trust_score", "quality_score", "snippet_text",
},
}
// Add highlighting if requested
if req.Include.Highlights {
query["highlight"] = map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content_text": map[string]interface{}{"fragment_size": 150, "number_of_fragments": 3},
},
}
}
// Add function score for trust/quality boosting
query["query"] = map[string]interface{}{
"function_score": map[string]interface{}{
"query": query["query"],
"functions": []map[string]interface{}{
{
"field_value_factor": map[string]interface{}{
"field": "trust_score",
"factor": 1.5,
"modifier": "sqrt",
"missing": 0.5,
},
},
{
"field_value_factor": map[string]interface{}{
"field": "quality_score",
"factor": 1.0,
"modifier": "sqrt",
"missing": 0.5,
},
},
},
"score_mode": "multiply",
"boost_mode": "multiply",
},
}
return query
}
// buildSemanticQuery constructs a pure vector search query using k-NN
func (s *Service) buildSemanticQuery(req *SearchRequest, embedding []float32) map[string]interface{} {
filter := s.buildFilters(req)
// k-NN query for semantic search
knnQuery := map[string]interface{}{
"content_embedding": map[string]interface{}{
"vector": embedding,
"k": req.Limit + req.Offset, // Get enough results for pagination
},
}
// Add filter if present
if len(filter) > 0 {
knnQuery["content_embedding"].(map[string]interface{})["filter"] = map[string]interface{}{
"bool": map[string]interface{}{
"filter": filter,
},
}
}
query := map[string]interface{}{
"knn": knnQuery,
"from": req.Offset,
"size": req.Limit,
"_source": []string{
"doc_id", "title", "url", "domain", "language",
"doc_type", "school_level", "subjects",
"trust_score", "quality_score", "snippet_text",
},
}
// Add highlighting if requested
if req.Include.Highlights {
query["highlight"] = map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content_text": map[string]interface{}{"fragment_size": 150, "number_of_fragments": 3},
},
}
}
return query
}
// buildHybridQuery constructs a combined BM25 + vector search query
func (s *Service) buildHybridQuery(req *SearchRequest, embedding []float32) map[string]interface{} {
filter := s.buildFilters(req)
// Build the bool query for BM25
must := []map[string]interface{}{}
if req.Query != "" {
must = append(must, map[string]interface{}{
"multi_match": map[string]interface{}{
"query": req.Query,
"fields": []string{"title^3", "content_text"},
"type": "best_fields",
},
})
}
boolQuery := map[string]interface{}{}
if len(must) > 0 {
boolQuery["must"] = must
}
if len(filter) > 0 {
boolQuery["filter"] = filter
}
// Convert embedding to []interface{} for JSON
embeddingInterface := make([]interface{}, len(embedding))
for i, v := range embedding {
embeddingInterface[i] = v
}
// Hybrid query using script_score to combine BM25 and cosine similarity
// This is a simpler approach than OpenSearch's neural search plugin
query := map[string]interface{}{
"query": map[string]interface{}{
"script_score": map[string]interface{}{
"query": map[string]interface{}{
"bool": boolQuery,
},
"script": map[string]interface{}{
"source": "cosineSimilarity(params.query_vector, 'content_embedding') + 1.0 + _score * 0.5",
"params": map[string]interface{}{
"query_vector": embeddingInterface,
},
},
},
},
"from": req.Offset,
"size": req.Limit,
"_source": []string{
"doc_id", "title", "url", "domain", "language",
"doc_type", "school_level", "subjects",
"trust_score", "quality_score", "snippet_text",
},
}
// Add highlighting if requested
if req.Include.Highlights {
query["highlight"] = map[string]interface{}{
"fields": map[string]interface{}{
"title": map[string]interface{}{},
"content_text": map[string]interface{}{"fragment_size": 150, "number_of_fragments": 3},
},
}
}
return query
}
// buildFilters constructs the filter array for queries
func (s *Service) buildFilters(req *SearchRequest) []map[string]interface{} {
filter := []map[string]interface{}{}
if len(req.Filters.Language) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"language": req.Filters.Language},
})
}
if len(req.Filters.CountryHint) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"country_hint": req.Filters.CountryHint},
})
}
if len(req.Filters.SourceCategory) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"source_category": req.Filters.SourceCategory},
})
}
if len(req.Filters.DocType) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"doc_type": req.Filters.DocType},
})
}
if len(req.Filters.SchoolLevel) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"school_level": req.Filters.SchoolLevel},
})
}
if len(req.Filters.Subjects) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"subjects": req.Filters.Subjects},
})
}
if len(req.Filters.State) > 0 {
filter = append(filter, map[string]interface{}{
"terms": map[string]interface{}{"state": req.Filters.State},
})
}
if req.Filters.MinTrustScore > 0 {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"trust_score": map[string]interface{}{"gte": req.Filters.MinTrustScore},
},
})
}
if req.Filters.DateFrom != "" {
filter = append(filter, map[string]interface{}{
"range": map[string]interface{}{
"fetch_time": map[string]interface{}{"gte": req.Filters.DateFrom},
},
})
}
return filter
}
// hitToResult converts an OpenSearch hit to SearchResult
func (s *Service) hitToResult(source map[string]interface{}, score float64, highlight map[string][]string, include SearchInclude) SearchResult {
result := SearchResult{
DocID: getString(source, "doc_id"),
Title: getString(source, "title"),
URL: getString(source, "url"),
Domain: getString(source, "domain"),
Language: getString(source, "language"),
DocType: getString(source, "doc_type"),
SchoolLevel: getString(source, "school_level"),
Subjects: getStringArray(source, "subjects"),
Scores: Scores{
BM25: score,
Trust: getFloat(source, "trust_score"),
Quality: getFloat(source, "quality_score"),
Final: score, // MVP: final = BM25 * trust * quality (via function_score)
},
}
if include.Snippets {
result.Snippet = getString(source, "snippet_text")
}
if include.Highlights && highlight != nil {
if h, ok := highlight["content_text"]; ok {
result.Highlights = h
}
}
return result
}

Some files were not shown because too many files have changed in this diff Show More