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
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:
@@ -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!
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
interface GlobalDragOverlayProps {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function GlobalDragOverlay({ active }: GlobalDragOverlayProps) {
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-purple-900/80 backdrop-blur-sm flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-7xl mb-4 animate-bounce">📄</div>
|
||||
<div className="text-2xl font-bold text-white">Bild hier ablegen</div>
|
||||
<div className="text-purple-200 mt-2">PNG, JPG - Handgeschriebener Text</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsModal({ open, onClose }: KeyboardShortcutsModalProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 bg-black/50 flex items-center justify-center" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4">Tastenkuerzel</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Bild einfuegen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+V</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">OCR starten</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Ctrl+Enter</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Tab wechseln</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Alt+1-6</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Bild entfernen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">Escape</kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Shortcuts anzeigen</span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs font-mono">?</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
export function TabArchitecture() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Architecture Diagram */}
|
||||
<ArchitectureDiagram />
|
||||
|
||||
{/* Components */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<ComponentCard
|
||||
icon="🔍"
|
||||
title="TrOCR Service"
|
||||
description="Das TrOCR-Modell von Microsoft ist speziell fuer Handschrifterkennung trainiert. Es verwendet eine Vision-Transformer (ViT) Architektur fuer Bildverarbeitung und einen Text-Decoder fuer die Textgenerierung."
|
||||
specs={[
|
||||
{ label: 'Modell', value: 'microsoft/trocr-base-handwritten' },
|
||||
{ label: 'Groesse', value: '~350 MB' },
|
||||
{ label: 'Lizenz', value: 'MIT' },
|
||||
{ label: 'Framework', value: 'PyTorch / Transformers' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="🎯"
|
||||
title="LoRA Fine-Tuning"
|
||||
description="LoRA fuegt kleine, trainierbare Matrizen zu bestimmten Schichten hinzu, ohne das Basismodell zu veraendern. Dies ermoeglicht effizientes Fine-Tuning mit minimaler Speichernutzung."
|
||||
specs={[
|
||||
{ label: 'Methode', value: 'Low-Rank Adaptation' },
|
||||
{ label: 'Adapter-Groesse', value: '~10 MB' },
|
||||
{ label: 'Trainingszeit', value: '5-15 Min (CPU)' },
|
||||
{ label: 'Min. Beispiele', value: '10' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="🔒"
|
||||
title="Pseudonymisierung"
|
||||
description="Schuelernamen werden durch anonyme Tokens ersetzt, bevor Daten die lokale Umgebung verlassen. Das Mapping wird ausschliesslich lokal gespeichert."
|
||||
specs={[
|
||||
{ label: 'Methode', value: 'QR-Code Tokens' },
|
||||
{ label: 'Token-Format', value: 'UUID v4' },
|
||||
{ label: 'Mapping', value: 'Lokal beim Lehrer' },
|
||||
{ label: 'Cloud-Daten', value: 'Nur Tokens + Text' },
|
||||
]}
|
||||
/>
|
||||
<ComponentCard
|
||||
icon="☁️"
|
||||
title="Cloud LLM"
|
||||
description="Die KI-Korrektur erfolgt auf deutschen Servern mit strikter Mandantentrennung. Es werden keine Klarnamen oder identifizierenden Informationen uebertragen."
|
||||
specs={[
|
||||
{ label: 'Provider', value: 'SysEleven (DE)' },
|
||||
{ label: 'Standort', value: 'Deutschland' },
|
||||
{ label: 'Isolation', value: 'Namespace pro Schule' },
|
||||
{ label: 'Datenverarbeitung', value: 'Nur pseudonymisiert' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Flow */}
|
||||
<DataFlowCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function ArchitectureDiagram() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-6">Systemarchitektur</h2>
|
||||
|
||||
<div className="bg-slate-900 rounded-lg p-6 font-mono text-xs overflow-x-auto">
|
||||
<pre className="text-slate-300">
|
||||
{`┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MAGIC HELP ARCHITEKTUR │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
||||
│ │ FRONTEND │ │ BACKEND │ │ STORAGE │ │
|
||||
│ │ (Next.js) │ │ (FastAPI) │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ REST │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Admin │──┼─────────┼──│ TrOCR │ │ │ │ Models │ │ │
|
||||
│ │ │ Panel │ │ │ │ Service │──┼─────────┼──│ (ONNX) │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ │ ┌─────────┐ │ WebSocket│ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Lehrer │──┼─────────┼──│ Klausur │ │ │ │ LoRA │ │ │
|
||||
│ │ │ Portal │ │ │ │ Processor │──┼─────────┼──│ Adapter │ │ │
|
||||
│ │ └─────────┘ │ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │ │ │ │
|
||||
│ └───────────────┘ │ ┌────────────┐ │ │ ┌─────────┐ │ │
|
||||
│ │ │ Pseudo- │ │ │ │Training │ │ │
|
||||
│ │ │ nymizer │──┼─────────┼──│ Data │ │ │
|
||||
│ │ └────────────┘ │ │ └─────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └──────────────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
│ │ (nur pseudonymisiert) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ CLOUD LLM │ │
|
||||
│ │ (SysEleven) │ │
|
||||
│ │ Namespace- │ │
|
||||
│ │ Isolation │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ComponentCardProps {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
specs: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
function ComponentCard({ icon, title, description, specs }: ComponentCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<span>{icon}</span> {title}
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{specs.map((spec) => (
|
||||
<div key={spec.label} className="flex justify-between">
|
||||
<span className="text-slate-500">{spec.label}</span>
|
||||
<span className="text-slate-900">{spec.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm mt-4">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DATA_FLOW_STEPS = [
|
||||
{
|
||||
num: 1,
|
||||
color: 'bg-blue-100 text-blue-600',
|
||||
title: 'Lokale Header-Extraktion',
|
||||
desc: 'TrOCR erkennt Schuelernamen, Klasse und Fach direkt im Browser/PWA (offline-faehig)',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
color: 'bg-purple-100 text-purple-600',
|
||||
title: 'Pseudonymisierung',
|
||||
desc: 'Namen werden durch QR-Code Tokens ersetzt, Mapping bleibt lokal',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
color: 'bg-green-100 text-green-600',
|
||||
title: 'Cloud-Korrektur',
|
||||
desc: 'Nur pseudonymisierte Dokument-Tokens werden an die KI gesendet',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
color: 'bg-yellow-100 text-yellow-600',
|
||||
title: 'Re-Identifikation',
|
||||
desc: 'Ergebnisse werden lokal mit dem Mapping wieder den echten Namen zugeordnet',
|
||||
},
|
||||
]
|
||||
|
||||
function DataFlowCard() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Datenfluss</h2>
|
||||
<div className="space-y-4">
|
||||
{DATA_FLOW_STEPS.map((step) => (
|
||||
<div key={step.num} className="flex items-start gap-4 bg-slate-50 rounded-lg p-4">
|
||||
<div className={`w-8 h-8 rounded-full ${step.color} flex items-center justify-center font-bold`}>
|
||||
{step.num}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{step.title}</div>
|
||||
<div className="text-sm text-slate-500">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { BatchUploader } from '@/components/ai/BatchUploader'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
export function TabBatch() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Batch OCR Processing */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-2">Batch-Verarbeitung</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Verarbeite mehrere Bilder gleichzeitig mit Echtzeit-Fortschrittsanzeige.
|
||||
Die Ergebnisse werden per Server-Sent Events gestreamt.
|
||||
</p>
|
||||
|
||||
<BatchUploader
|
||||
apiBase={API_BASE}
|
||||
maxFiles={20}
|
||||
autoProcess={false}
|
||||
onComplete={(results) => {
|
||||
console.log('Batch complete:', results)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Batch Processing Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🚀</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Parallele Verarbeitung</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Mehrere Bilder werden parallel verarbeitet fuer maximale Geschwindigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">💾</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Smart Caching</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Identische Bilder werden automatisch aus dem Cache geladen (unter 50ms).
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📊</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Live-Fortschritt</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Echtzeit-Updates via Server-Sent Events zeigen den Verarbeitungsfortschritt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { SkeletonText } from '@/components/common/SkeletonText'
|
||||
import type { TrOCRStatus } from '../types'
|
||||
|
||||
interface TabOverviewProps {
|
||||
status: TrOCRStatus | null
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function TabOverview({ status, loading, onRefresh }: TabOverviewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Systemstatus</h2>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-slate-50 rounded-lg p-4">
|
||||
<SkeletonText lines={1} className="mb-2" />
|
||||
<div className="h-3 w-16 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : status?.status === 'available' ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.model_name || 'trocr-base'}</div>
|
||||
<div className="text-xs text-slate-500">Modell</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.device || 'CPU'}</div>
|
||||
<div className="text-xs text-slate-500">Geraet</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.training_examples_count || 0}</div>
|
||||
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-2xl font-bold text-slate-900">{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}</div>
|
||||
<div className="text-xs text-slate-500">LoRA Adapter</div>
|
||||
</div>
|
||||
</div>
|
||||
) : status?.status === 'not_installed' ? (
|
||||
<div className="text-slate-600">
|
||||
<p className="mb-2">TrOCR ist nicht installiert. Fuehre aus:</p>
|
||||
<code className="bg-slate-100 px-3 py-2 rounded text-sm block font-mono">{status.install_command}</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600">{status?.error || 'Unbekannter Fehler'}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 border border-purple-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🎯</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Handschrifterkennung</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
TrOCR erkennt automatisch handgeschriebenen Text in Klausuren.
|
||||
Das Modell wurde speziell fuer deutsche Handschriften optimiert.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-50 to-green-100 border border-green-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Privacy by Design</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Alle Daten werden lokal verarbeitet. Schuelernamen werden durch
|
||||
QR-Codes pseudonymisiert - DSGVO-konform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||
<div className="text-3xl mb-2">📈</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Kontinuierliches Lernen</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Mit LoRA Fine-Tuning passt sich das Modell an individuelle
|
||||
Handschriften an - ohne das Basismodell zu veraendern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Overview */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Magic Onboarding Workflow</h2>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
{WORKFLOW_STEPS.map((step, i) => (
|
||||
<WorkflowStep key={step.title} step={step} showArrow={i < WORKFLOW_STEPS.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WORKFLOW_STEPS = [
|
||||
{ icon: '📄', title: '1. Upload', desc: '25 Klausuren hochladen' },
|
||||
{ icon: '🔍', title: '2. Analyse', desc: 'Lokale OCR in 5-10 Sek' },
|
||||
{ icon: '✅', title: '3. Bestaetigung', desc: 'Klasse, Schueler, Fach' },
|
||||
{ icon: '🤖', title: '4. KI-Korrektur', desc: 'Cloud mit Pseudonymisierung' },
|
||||
{ icon: '📊', title: '5. Integration', desc: 'Notenbuch, Zeugnisse' },
|
||||
]
|
||||
|
||||
function WorkflowStep({ step, showArrow }: { step: typeof WORKFLOW_STEPS[number]; showArrow: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 bg-slate-50 rounded-lg px-4 py-3">
|
||||
<span className="text-2xl">{step.icon}</span>
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">{step.title}</div>
|
||||
<div className="text-slate-500">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showArrow && <div className="text-slate-400">→</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import type { MagicSettings } from '../types'
|
||||
import { DEFAULT_SETTINGS } from '../types'
|
||||
|
||||
interface TabSettingsProps {
|
||||
settings: MagicSettings
|
||||
settingsSaved: boolean
|
||||
onUpdateSettings: (settings: MagicSettings) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function TabSettings({ settings, settingsSaved, onUpdateSettings, onSave }: TabSettingsProps) {
|
||||
const update = (partial: Partial<MagicSettings>) => {
|
||||
onUpdateSettings({ ...settings, ...partial })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* OCR Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">OCR Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CheckboxSetting
|
||||
label="Automatische Zeilenerkennung"
|
||||
description="Erkennt und verarbeitet einzelne Zeilen separat"
|
||||
checked={settings.autoDetectLines}
|
||||
onChange={(v) => update({ autoDetectLines: v })}
|
||||
/>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Live-Vorschau"
|
||||
description="OCR startet automatisch nach Bild-Upload"
|
||||
checked={settings.livePreview}
|
||||
onChange={(v) => update({ livePreview: v })}
|
||||
/>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Sound-Feedback"
|
||||
description="Akustisches Feedback bei erfolgreicher Erkennung"
|
||||
checked={settings.soundFeedback}
|
||||
onChange={(v) => update({ soundFeedback: v })}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Konfidenz-Schwellwert</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={settings.confidenceThreshold}
|
||||
onChange={(e) => update({ confidenceThreshold: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-1">
|
||||
<span>0%</span>
|
||||
<span className="text-slate-900">{(settings.confidenceThreshold * 100).toFixed(0)}%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Max. Bildgroesse (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxImageSize}
|
||||
onChange={(e) => update({ maxImageSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 mt-1">Groessere Bilder werden skaliert</div>
|
||||
</div>
|
||||
|
||||
<CheckboxSetting
|
||||
label="Ergebnis-Cache aktivieren"
|
||||
description="Speichert OCR-Ergebnisse fuer identische Bilder"
|
||||
checked={settings.enableCache}
|
||||
onChange={(v) => update({ enableCache: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Training Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">LoRA Rank</label>
|
||||
<select
|
||||
value={settings.loraRank}
|
||||
onChange={(e) => update({ loraRank: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="4">4 (Schnell, weniger Kapazitaet)</option>
|
||||
<option value="8">8 (Ausgewogen)</option>
|
||||
<option value="16">16 (Mehr Kapazitaet)</option>
|
||||
<option value="32">32 (Maximum)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">LoRA Alpha</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.loraAlpha}
|
||||
onChange={(e) => update({ loraAlpha: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
<div className="text-xs text-slate-400 mt-1">Empfohlen: 4 x LoRA Rank</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Epochen</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={settings.epochs}
|
||||
onChange={(e) => update({ epochs: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Batch Size</label>
|
||||
<select
|
||||
value={settings.batchSize}
|
||||
onChange={(e) => update({ batchSize: parseInt(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="1">1 (Wenig RAM)</option>
|
||||
<option value="2">2</option>
|
||||
<option value="4">4 (Standard)</option>
|
||||
<option value="8">8 (Viel RAM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-2">Learning Rate</label>
|
||||
<select
|
||||
value={settings.learningRate}
|
||||
onChange={(e) => update({ learningRate: parseFloat(e.target.value) })}
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
>
|
||||
<option value="0.0001">0.0001 (Schnell)</option>
|
||||
<option value="0.00005">0.00005 (Standard)</option>
|
||||
<option value="0.00001">0.00001 (Konservativ)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => onUpdateSettings(DEFAULT_SETTINGS)}
|
||||
className="px-6 py-2 bg-slate-200 hover:bg-slate-300 text-slate-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{settingsSaved ? '\u2713 Gespeichert!' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Technische Informationen</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">API Endpoint:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">/api/klausur/trocr</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Model Path:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">~/.cache/huggingface</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">LoRA Path:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./models/lora</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Training Data:</span>
|
||||
<code className="text-slate-900 ml-2 bg-slate-100 px-2 py-1 rounded text-xs">./data/training</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CheckboxSetting({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-slate-100 border-slate-300"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-slate-900 font-medium">{label}</div>
|
||||
<div className="text-sm text-slate-500">{description}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
304
admin-lehrer/app/(admin)/ai/magic-help/_components/TabTest.tsx
Normal file
304
admin-lehrer/app/(admin)/ai/magic-help/_components/TabTest.tsx
Normal 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">< 70%</div>
|
||||
<div className="text-sm text-slate-600 mt-1">Niedrige Sicherheit - manuelle Eingabe erforderlich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface UploadAreaProps {
|
||||
imagePreview: string | null
|
||||
uploadedImage: File | null
|
||||
ocrLoading: boolean
|
||||
livePreview: boolean
|
||||
onFileUpload: (file: File) => void
|
||||
onManualOCR: () => void
|
||||
onClearImage: () => void
|
||||
}
|
||||
|
||||
function UploadArea({ imagePreview, uploadedImage, ocrLoading, livePreview, onFileUpload, onManualOCR, onClearImage }: UploadAreaProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
|
||||
imagePreview
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-slate-300 hover:border-purple-500'
|
||||
}`}
|
||||
onClick={() => document.getElementById('ocr-file-input')?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-purple-500', 'bg-purple-50') }}
|
||||
onDragLeave={(e) => { e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50')
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file?.type.startsWith('image/')) onFileUpload(file)
|
||||
}}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Hochgeladenes Bild"
|
||||
className="max-h-64 mx-auto rounded-lg shadow-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClearImage()
|
||||
}}
|
||||
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
|
||||
title="Bild entfernen (Escape)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-4xl mb-2">📄</div>
|
||||
<div className="text-slate-700">Bild hierher ziehen oder klicken zum Hochladen</div>
|
||||
<div className="text-xs text-slate-400 mt-1">PNG, JPG - Handgeschriebener Text</div>
|
||||
<div className="text-xs text-purple-500 mt-2">
|
||||
oder <kbd className="px-1.5 py-0.5 bg-purple-100 rounded font-mono">Ctrl+V</kbd> zum Einfuegen
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="ocr-file-input"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
|
||||
{uploadedImage && !livePreview && (
|
||||
<button
|
||||
onClick={onManualOCR}
|
||||
disabled={ocrLoading}
|
||||
className="w-full mt-4 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{ocrLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<SkeletonDots />
|
||||
Analysiere...
|
||||
</span>
|
||||
) : (
|
||||
'OCR starten (Ctrl+Enter)'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ResultsAreaProps {
|
||||
ocrResult: OCRResult | null
|
||||
ocrLoading: boolean
|
||||
onSendToTraining: () => void
|
||||
}
|
||||
|
||||
function ResultsArea({ ocrResult, ocrLoading, onSendToTraining }: ResultsAreaProps) {
|
||||
if (ocrLoading) return <SkeletonOCRResult />
|
||||
|
||||
if (!ocrResult) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center text-slate-400">
|
||||
<div className="text-4xl mb-2">🔍</div>
|
||||
<div>Lade ein Bild hoch um die Erkennung zu testen</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-slate-700">Erkannter Text:</h3>
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
ocrResult.confidence >= 0.9 ? 'bg-green-100 text-green-700' :
|
||||
ocrResult.confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(ocrResult.confidence * 100).toFixed(0)}% Konfidenz
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-white border p-3 rounded text-sm text-slate-900 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{ocrResult.text || '(Kein Text erkannt)'}
|
||||
</pre>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<div className="mt-3 mb-3">
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${getConfidenceColor(ocrResult.confidence)}`}
|
||||
style={{ width: `${ocrResult.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Konfidenz</div>
|
||||
<div className="text-slate-900 font-medium">{(ocrResult.confidence * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Verarbeitungszeit</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.processing_time_ms}ms</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">Modell</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.model || 'TrOCR'}</div>
|
||||
</div>
|
||||
<div className="bg-white border rounded p-2">
|
||||
<div className="text-slate-500 text-xs">LoRA Adapter</div>
|
||||
<div className="text-slate-900 font-medium">{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ocrResult.confidence < 0.9 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800 mb-2">
|
||||
Die Erkennung koennte verbessert werden! Moechtest du dieses Beispiel zum Training hinzufuegen?
|
||||
</p>
|
||||
<button
|
||||
onClick={onSendToTraining}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors"
|
||||
>
|
||||
Als Trainingsbeispiel hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SkeletonDots } from '@/components/common/SkeletonText'
|
||||
import { TrainingMetrics } from '@/components/ai/TrainingMetrics'
|
||||
import type { TrOCRStatus, TrainingExample, MagicSettings } from '../types'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
interface TabTrainingProps {
|
||||
status: TrOCRStatus | null
|
||||
examples: TrainingExample[]
|
||||
trainingImage: File | null
|
||||
trainingText: string
|
||||
fineTuning: boolean
|
||||
settings: MagicSettings
|
||||
showTrainingDashboard: boolean
|
||||
onSetTrainingImage: (file: File | null) => void
|
||||
onSetTrainingText: (text: string) => void
|
||||
onAddExample: () => void
|
||||
onFineTune: () => void
|
||||
onToggleDashboard: () => void
|
||||
}
|
||||
|
||||
export function TabTraining({
|
||||
status,
|
||||
examples,
|
||||
trainingImage,
|
||||
trainingText,
|
||||
fineTuning,
|
||||
settings,
|
||||
showTrainingDashboard,
|
||||
onSetTrainingImage,
|
||||
onSetTrainingText,
|
||||
onAddExample,
|
||||
onFineTune,
|
||||
onToggleDashboard,
|
||||
}: TabTrainingProps) {
|
||||
const exampleCount = status?.training_examples_count || 0
|
||||
const progressPct = Math.min(100, (exampleCount / 10) * 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Training Overview */}
|
||||
<TrainingOverviewCard
|
||||
status={status}
|
||||
settings={settings}
|
||||
exampleCount={exampleCount}
|
||||
progressPct={progressPct}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Add Training Example */}
|
||||
<AddExampleCard
|
||||
trainingImage={trainingImage}
|
||||
trainingText={trainingText}
|
||||
onSetTrainingImage={onSetTrainingImage}
|
||||
onSetTrainingText={onSetTrainingText}
|
||||
onAddExample={onAddExample}
|
||||
/>
|
||||
|
||||
{/* Fine-Tuning */}
|
||||
<FineTuningCard
|
||||
settings={settings}
|
||||
fineTuning={fineTuning}
|
||||
exampleCount={exampleCount}
|
||||
hasLoraAdapter={status?.has_lora_adapter || false}
|
||||
onFineTune={onFineTune}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Training Examples List */}
|
||||
{examples.length > 0 && (
|
||||
<ExamplesListCard examples={examples} />
|
||||
)}
|
||||
|
||||
{/* Training Dashboard Demo */}
|
||||
<TrainingDashboardCard
|
||||
showDashboard={showTrainingDashboard}
|
||||
onToggle={onToggleDashboard}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function TrainingOverviewCard({
|
||||
status,
|
||||
settings,
|
||||
exampleCount,
|
||||
progressPct,
|
||||
}: {
|
||||
status: TrOCRStatus | null
|
||||
settings: MagicSettings
|
||||
exampleCount: number
|
||||
progressPct: number
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Training mit LoRA</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
LoRA (Low-Rank Adaptation) ermoeglicht effizientes Fine-Tuning ohne das Basismodell zu veraendern.
|
||||
Das Training erfolgt lokal auf Ihrem System.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{exampleCount}</div>
|
||||
<div className="text-xs text-slate-500">Trainingsbeispiele</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">10</div>
|
||||
<div className="text-xs text-slate-500">Minimum benoetigt</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{settings.loraRank}</div>
|
||||
<div className="text-xs text-slate-500">LoRA Rank</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-slate-900">{status?.has_lora_adapter ? '\u2713' : '\u2717'}</div>
|
||||
<div className="text-xs text-slate-500">Adapter aktiv</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-500">Fortschritt zum Fine-Tuning</span>
|
||||
<span className="text-slate-500">{progressPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddExampleCard({
|
||||
trainingImage,
|
||||
trainingText,
|
||||
onSetTrainingImage,
|
||||
onSetTrainingText,
|
||||
onAddExample,
|
||||
}: {
|
||||
trainingImage: File | null
|
||||
trainingText: string
|
||||
onSetTrainingImage: (file: File | null) => void
|
||||
onSetTrainingText: (text: string) => void
|
||||
onAddExample: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiel hinzufuegen</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-1">Bild</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm"
|
||||
onChange={(e) => onSetTrainingImage(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{trainingImage && (
|
||||
<div className="mt-2 text-xs text-green-600">
|
||||
Bild ausgewaehlt: {trainingImage.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-700 mb-1">Korrekter Text (Ground Truth)</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 border border-slate-300 rounded-lg px-3 py-2 text-sm text-slate-900 resize-none"
|
||||
rows={3}
|
||||
placeholder="Gib hier den korrekten Text ein..."
|
||||
value={trainingText}
|
||||
onChange={(e) => onSetTrainingText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAddExample}
|
||||
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
+ Trainingsbeispiel hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FineTuningCard({
|
||||
settings,
|
||||
fineTuning,
|
||||
exampleCount,
|
||||
hasLoraAdapter,
|
||||
onFineTune,
|
||||
}: {
|
||||
settings: MagicSettings
|
||||
fineTuning: boolean
|
||||
exampleCount: number
|
||||
hasLoraAdapter: boolean
|
||||
onFineTune: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Fine-Tuning starten</h2>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
Trainiere das Modell mit den gesammelten Beispielen. Der Prozess dauert
|
||||
je nach Anzahl der Beispiele einige Minuten.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Epochen:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.epochs}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Learning Rate:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.learningRate}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">LoRA Rank:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.loraRank}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Batch Size:</span>
|
||||
<span className="text-slate-900 ml-2">{settings.batchSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onFineTune}
|
||||
disabled={fineTuning || exampleCount < 10}
|
||||
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{fineTuning ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<SkeletonDots />
|
||||
Fine-Tuning laeuft...
|
||||
</span>
|
||||
) : (
|
||||
'Fine-Tuning starten'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{exampleCount < 10 && (
|
||||
<p className="text-xs text-yellow-600 mt-2 text-center">
|
||||
Noch {10 - exampleCount} Beispiele benoetigt
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/ai/ocr-labeling?model=trocr-lora"
|
||||
className="w-full mt-4 px-4 py-2 bg-teal-100 text-teal-700 border border-teal-300 rounded-lg hover:bg-teal-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>🏷️</span>
|
||||
Ground Truth in OCR-Labeling sammeln
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExamplesListCard({ examples }: { examples: TrainingExample[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Trainingsbeispiele ({examples.length})</h2>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{examples.map((ex, i) => (
|
||||
<div key={i} className="flex items-center gap-4 bg-slate-50 rounded-lg p-3">
|
||||
<span className="text-slate-400 font-mono text-sm w-8">{i + 1}.</span>
|
||||
<span className="text-slate-900 text-sm flex-1 truncate">{ex.ground_truth}</span>
|
||||
<span className="text-slate-400 text-xs">{new Date(ex.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingDashboardCard({
|
||||
showDashboard,
|
||||
onToggle,
|
||||
}: {
|
||||
showDashboard: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Training Dashboard</h2>
|
||||
<p className="text-sm text-slate-500">Live-Metriken waehrend des Trainings</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDashboard
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-purple-600 hover:bg-purple-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{showDashboard ? 'Demo stoppen' : 'Demo starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDashboard ? (
|
||||
<TrainingMetrics
|
||||
apiBase={API_BASE}
|
||||
simulateMode={true}
|
||||
onComplete={onToggle}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-slate-50 rounded-lg p-8 text-center">
|
||||
<div className="text-4xl mb-3">📈</div>
|
||||
<div className="text-slate-600 mb-2">
|
||||
Das Training Dashboard zeigt Echtzeit-Metriken waehrend des Fine-Tunings
|
||||
</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
Klicke "Demo starten" um eine simulierte Training-Session zu sehen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { GlobalDragOverlay, KeyboardShortcutsModal } from './GlobalOverlays'
|
||||
export { TabOverview } from './TabOverview'
|
||||
export { TabTest } from './TabTest'
|
||||
export { TabBatch } from './TabBatch'
|
||||
export { TabTraining } from './TabTraining'
|
||||
export { TabArchitecture } from './TabArchitecture'
|
||||
export { TabSettings } from './TabSettings'
|
||||
File diff suppressed because it is too large
Load Diff
71
admin-lehrer/app/(admin)/ai/magic-help/types.ts
Normal file
71
admin-lehrer/app/(admin)/ai/magic-help/types.ts
Normal 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'
|
||||
382
admin-lehrer/app/(admin)/ai/magic-help/useMagicHelp.ts
Normal file
382
admin-lehrer/app/(admin)/ai/magic-help/useMagicHelp.ts
Normal 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>
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
export function ArchitectureTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* What is this module */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Was macht dieses Modul?
|
||||
</h2>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Das <strong>RAG-Indexierungs-Modul</strong> verarbeitet Dokumente und macht sie fuer die KI-gestuetzte Suche verfuegbar.
|
||||
Es handelt sich <strong>nicht</strong> um klassisches Machine-Learning-Training, sondern um:
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">1.</span>
|
||||
<span><strong>Dokumentenextraktion:</strong> PDFs und Bilder werden per OCR in Text umgewandelt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">2.</span>
|
||||
<span><strong>Chunking:</strong> Lange Texte werden in suchbare Abschnitte (1000 Zeichen) aufgeteilt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">3.</span>
|
||||
<span><strong>Embedding:</strong> Jeder Chunk wird in einen Vektor (1536 Dimensionen) umgewandelt</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-500 mt-1">4.</span>
|
||||
<span><strong>Indexierung:</strong> Vektoren werden in Qdrant gespeichert fuer semantische Suche</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Technische Architektur
|
||||
</h2>
|
||||
|
||||
{/* Visual Pipeline */}
|
||||
<div className="relative">
|
||||
{/* Data Sources Row */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<SourceCard icon="📄" title="NiBiS PDFs" subtitle="Erwartungshorizonte" color="blue" />
|
||||
<SourceCard icon="📤" title="Uploads" subtitle="Eigene EH" color="green" />
|
||||
<SourceCard icon="⚖️" title="Rechtskorpus" subtitle="DSGVO, AI Act" color="purple" />
|
||||
<SourceCard icon="📚" title="Schulordnungen" subtitle="Bundeslaender" color="orange" />
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Processing Layer */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 mb-8">
|
||||
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-4">
|
||||
Verarbeitungs-Pipeline
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<PipelineStep icon="🔍" title="OCR" subtitle="Text-Extraktion" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="✂️" title="Chunking" subtitle="1000 Zeichen" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="🧮" title="Embedding" subtitle="1536-dim Vektor" />
|
||||
<ArrowRight />
|
||||
<PipelineStep icon="💾" title="Speichern" subtitle="Qdrant" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Storage Layer */}
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-xl p-6 mb-8 border-2 border-indigo-200 dark:border-indigo-800">
|
||||
<h3 className="text-sm font-semibold text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-4">
|
||||
Vektor-Datenbank (Qdrant)
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<CollectionCard collection="bp_nibis_eh" label="Offizielle EH" />
|
||||
<CollectionCard collection="bp_eh" label="Benutzer EH" />
|
||||
<CollectionCard collection="bp_legal_corpus" label="Rechtskorpus" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowDown />
|
||||
|
||||
{/* Usage Layer */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl border-2 border-emerald-200 dark:border-emerald-800">
|
||||
<h4 className="font-medium text-emerald-700 dark:text-emerald-400 mb-2">Semantische Suche</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Fragen werden in Vektoren umgewandelt und aehnliche Dokumente gefunden
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border-2 border-amber-200 dark:border-amber-800">
|
||||
<h4 className="font-medium text-amber-700 dark:text-amber-400 mb-2">RAG-Antworten</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
LLM generiert Antworten basierend auf gefundenen Dokumenten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Details */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Technische Details
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Embedding-Service</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Modell</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">text-embedding-3-small</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Dimensionen</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">1536</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Port</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">8087</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Chunk-Konfiguration</h3>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Chunk-Groesse</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">1000 Zeichen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Ueberlappung</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">200 Zeichen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-gray-500 dark:text-gray-400">Distanzmetrik</td>
|
||||
<td className="py-2 font-mono text-gray-900 dark:text-white">COSINE</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helper components ---
|
||||
|
||||
function SourceCard({ icon, title, subtitle, color }: {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
color: string
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border-2 text-center ${colorClasses[color]}`}>
|
||||
<div className="text-3xl mb-2">{icon}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStep({ icon, title, subtitle }: {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="text-2xl mb-1">{icon}</div>
|
||||
<div className="font-medium text-sm">{title}</div>
|
||||
<div className="text-xs text-gray-500">{subtitle}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionCard({ collection, label }: { collection: string; label: string }) {
|
||||
return (
|
||||
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg text-center">
|
||||
<div className="font-mono text-xs text-gray-500">{collection}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowDown() {
|
||||
return (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="text-4xl text-gray-400">↓</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowRight() {
|
||||
return <div className="text-2xl text-gray-400">→</div>
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import type { DataSource } from '../types'
|
||||
|
||||
export function DataSourcesTab({ sources }: { sources: DataSource[] }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Introduction */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-6 border border-blue-200 dark:border-blue-800">
|
||||
<h2 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Wie werden Daten hinzugefuegt?
|
||||
</h2>
|
||||
<p className="text-blue-800 dark:text-blue-200 mb-4">
|
||||
Das RAG-System nutzt verschiedene Datenquellen. Jede Quelle hat einen eigenen Ingestion-Prozess:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white mb-1">Automatisch</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
NiBiS-PDFs werden automatisch aus dem za-download Verzeichnis eingelesen
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900 dark:text-white mb-1">Manuell</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Eigene EH koennen ueber die Klausur-Korrektur hochgeladen werden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Sources List */}
|
||||
<div className="grid gap-4">
|
||||
{sources.map((source) => (
|
||||
<DataSourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* How to add data */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Daten hinzufuegen
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<AddDataCard
|
||||
icon="📤"
|
||||
title="Erwartungshorizont hochladen"
|
||||
description="Laden Sie eigene EH-Dokumente in der Klausur-Korrektur hoch"
|
||||
linkHref="/admin/klausur-korrektur"
|
||||
linkText="Zur Klausur-Korrektur →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="🔄"
|
||||
title="NiBiS neu einlesen"
|
||||
description="Starten Sie die automatische Ingestion der NiBiS-PDFs"
|
||||
linkText="Ingestion starten →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="⚖️"
|
||||
title="Rechtskorpus erweitern"
|
||||
description="Neue Regelwerke (DSGVO, BSI, etc.) zum Korpus hinzufuegen"
|
||||
linkText="Regelwerk hinzufuegen →"
|
||||
/>
|
||||
<AddDataCard
|
||||
icon="📋"
|
||||
title="DSFA-Quellen verwalten"
|
||||
description="WP248, DSK, Muss-Listen mit Lizenzattribution"
|
||||
linkHref="/ai/rag-pipeline/dsfa"
|
||||
linkText="DSFA-Manager oeffnen →"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal helper components ---
|
||||
|
||||
function DataSourceCard({ source }: { source: DataSource }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{source.name}
|
||||
</h3>
|
||||
<DataSourceStatusBadge status={source.status} />
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{source.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Collection: </span>
|
||||
<span className="font-mono text-gray-900 dark:text-white">{source.collection}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Dokumente: </span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{source.document_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Chunks: </span>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{source.chunk_count}</span>
|
||||
</div>
|
||||
{source.last_updated && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Aktualisiert: </span>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{new Date(source.last_updated).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg">
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataSourceStatusBadge({ status }: { status: DataSource['status'] }) {
|
||||
const className = status === 'active'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
|
||||
const label = status === 'active' ? 'Aktiv' : status === 'pending' ? 'Ausstehend' : 'Fehler'
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDataCard({ icon, title, description, linkHref, linkText }: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
linkHref?: string
|
||||
linkText: string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||
<div className="text-2xl mb-2">{icon}</div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{description}</p>
|
||||
{linkHref ? (
|
||||
<a
|
||||
href={linkHref}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
) : (
|
||||
<button className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
|
||||
{linkText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { DatasetStats } from '../types'
|
||||
|
||||
export function DatasetOverview({ stats }: { stats: DatasetStats }) {
|
||||
const maxBundesland = Math.max(...Object.values(stats.by_bundesland))
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Datensatz-Uebersicht
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{stats.total_documents.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Dokumente</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{stats.total_chunks.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Chunks</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-xl">
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{stats.training_allowed.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Indexiert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Verteilung nach Bundesland
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.by_bundesland)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([code, count]) => (
|
||||
<div key={code} className="flex items-center gap-3">
|
||||
<span className="w-8 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">
|
||||
{code}
|
||||
</span>
|
||||
<div className="flex-1 h-4 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"
|
||||
style={{ width: `${(count / maxBundesland) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-sm text-right text-gray-600 dark:text-gray-400">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { TrainingConfig } from '../types'
|
||||
|
||||
const BUNDESLAENDER = [
|
||||
{ code: 'ni', name: 'Niedersachsen', allowed: true },
|
||||
{ code: 'by', name: 'Bayern', allowed: true },
|
||||
{ code: 'nw', name: 'NRW', allowed: true },
|
||||
{ code: 'he', name: 'Hessen', allowed: true },
|
||||
{ code: 'bw', name: 'Baden-Wuerttemberg', allowed: true },
|
||||
{ code: 'rp', name: 'Rheinland-Pfalz', allowed: true },
|
||||
{ code: 'sn', name: 'Sachsen', allowed: true },
|
||||
{ code: 'sh', name: 'Schleswig-Holstein', allowed: true },
|
||||
{ code: 'th', name: 'Thueringen', allowed: true },
|
||||
{ code: 'be', name: 'Berlin', allowed: false },
|
||||
{ code: 'bb', name: 'Brandenburg', allowed: false },
|
||||
{ code: 'hb', name: 'Bremen', allowed: false },
|
||||
{ code: 'hh', name: 'Hamburg', allowed: false },
|
||||
{ code: 'mv', name: 'Mecklenburg-Vorpommern', allowed: false },
|
||||
{ code: 'sl', name: 'Saarland', allowed: false },
|
||||
{ code: 'st', name: 'Sachsen-Anhalt', allowed: false },
|
||||
]
|
||||
|
||||
export function NewTrainingModal({ isOpen, onClose, onSubmit }: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [config, setConfig] = useState<Partial<TrainingConfig>>({
|
||||
batch_size: 16,
|
||||
learning_rate: 0.00005,
|
||||
epochs: 10,
|
||||
warmup_steps: 500,
|
||||
weight_decay: 0.01,
|
||||
gradient_accumulation: 4,
|
||||
mixed_precision: true,
|
||||
bundeslaender: [],
|
||||
})
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Neue Indexierung starten
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Schritt {step} von 3</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[50vh]">
|
||||
{step === 1 && (
|
||||
<BundeslaenderStep config={config} setConfig={setConfig} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<ParameterStep config={config} setConfig={setConfig} />
|
||||
)}
|
||||
{step === 3 && (
|
||||
<ConfirmStep config={config} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(step - 1) : onClose()}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{step > 1 ? 'Zurueck' : 'Abbrechen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 3 ? setStep(step + 1) : onSubmit(config)}
|
||||
disabled={step === 1 && (!config.bundeslaender || config.bundeslaender.length === 0)}
|
||||
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{step < 3 ? 'Weiter' : 'Indexierung starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Internal step components ---
|
||||
|
||||
function StepIndicator({ currentStep }: { currentStep: number }) {
|
||||
return (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s <= currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}>
|
||||
{s < currentStep ? '\u2713' : s}
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div className={`w-16 h-1 mx-2 rounded ${
|
||||
s < currentStep ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-20 mt-2 text-xs text-gray-500">
|
||||
<span>Daten</span>
|
||||
<span>Parameter</span>
|
||||
<span>Bestaetigen</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BundeslaenderStep({ config, setConfig }: {
|
||||
config: Partial<TrainingConfig>
|
||||
setConfig: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Waehlen Sie die Bundeslaender fuer die Indexierung
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Nur Bundeslaender mit verfuegbaren Dokumenten koennen ausgewaehlt werden.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{BUNDESLAENDER.map((bl) => (
|
||||
<label
|
||||
key={bl.code}
|
||||
className={`flex items-center p-3 rounded-lg border-2 transition cursor-pointer ${
|
||||
config.bundeslaender?.includes(bl.code)
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: bl.allowed
|
||||
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
|
||||
: 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!bl.allowed}
|
||||
checked={config.bundeslaender?.includes(bl.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setConfig({ ...config, bundeslaender: [...(config.bundeslaender || []), bl.code] })
|
||||
} else {
|
||||
setConfig({ ...config, bundeslaender: config.bundeslaender?.filter(c => c !== bl.code) })
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`w-5 h-5 rounded border-2 flex items-center justify-center mr-3 ${
|
||||
config.bundeslaender?.includes(bl.code)
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{config.bundeslaender?.includes(bl.code) && '\u2713'}
|
||||
</span>
|
||||
<span className="flex-1 text-gray-900 dark:text-white">{bl.name}</span>
|
||||
{!bl.allowed && (
|
||||
<span className="text-xs text-red-500">Keine Daten</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ParameterStep({ config, setConfig }: {
|
||||
config: Partial<TrainingConfig>
|
||||
setConfig: (config: Partial<TrainingConfig>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Indexierungs-Parameter
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Diese Parameter steuern die Batch-Verarbeitung der Dokumente.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.batch_size}
|
||||
onChange={(e) => setConfig({ ...config, batch_size: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Dokumente pro Batch</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Durchlaeufe
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.epochs}
|
||||
onChange={(e) => setConfig({ ...config, epochs: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Fuer Validierung</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mixedPrecision"
|
||||
checked={config.mixed_precision}
|
||||
onChange={(e) => setConfig({ ...config, mixed_precision: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 rounded"
|
||||
/>
|
||||
<label htmlFor="mixedPrecision" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Parallele Verarbeitung - schneller bei grossem Datensatz
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmStep({ config }: { config: Partial<TrainingConfig> }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-4">
|
||||
Konfiguration bestaetigen
|
||||
</h3>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Bundeslaender</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{config.bundeslaender?.length || 0} ausgewaehlt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Batch Size</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{config.batch_size}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Parallele Verarbeitung</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{config.mixed_precision ? 'Aktiviert' : 'Deaktiviert'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Was passiert:</strong> Die ausgewaehlten Dokumente werden extrahiert,
|
||||
in Chunks aufgeteilt, und als Vektoren in Qdrant indexiert.
|
||||
Dieser Prozess kann je nach Datenmenge einige Minuten dauern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingJob } from '../types'
|
||||
|
||||
// Tab Button
|
||||
export function TabButton({ active, onClick, children }: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Ring Component
|
||||
export function ProgressRing({ progress, size = 120, strokeWidth = 8, color = '#10B981' }: {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
color?: string
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
className="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mini Line Chart Component
|
||||
export function MiniChart({ data, color = '#10B981', height = 60 }: {
|
||||
data: number[]
|
||||
color?: string
|
||||
height?: number
|
||||
}) {
|
||||
if (!data.length) return null
|
||||
|
||||
const max = Math.max(...data)
|
||||
const min = Math.min(...data)
|
||||
const range = max - min || 1
|
||||
const width = 200
|
||||
const padding = 4
|
||||
|
||||
const points = data.map((value, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
|
||||
const y = padding + (1 - (value - min) / range) * (height - 2 * padding)
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{data.length > 0 && (
|
||||
<circle
|
||||
cx={padding + ((data.length - 1) / (data.length - 1)) * (width - 2 * padding)}
|
||||
cy={padding + (1 - (data[data.length - 1] - min) / range) * (height - 2 * padding)}
|
||||
r={4}
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
export function StatusBadge({ status }: { status: TrainingJob['status'] }) {
|
||||
const styles = {
|
||||
queued: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
preparing: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
training: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
validating: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
paused: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
queued: 'In Warteschlange',
|
||||
preparing: 'Vorbereitung',
|
||||
training: 'Indexierung laeuft',
|
||||
validating: 'Validierung',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
paused: 'Pausiert',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
||||
{status === 'training' && (
|
||||
<span className="w-2 h-2 mr-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Metric Card
|
||||
export function MetricCard({ label, value, trend, color }: {
|
||||
label: string
|
||||
value: number | string
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">{label}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold" style={{ color: color || 'inherit' }}>
|
||||
{typeof value === 'number' ? value.toFixed(3) : value}
|
||||
</span>
|
||||
{trend && (
|
||||
<span className={`ml-2 text-sm ${
|
||||
trend === 'up' ? 'text-green-500' : trend === 'down' ? 'text-red-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingJob } from '../types'
|
||||
import { ProgressRing, MiniChart, StatusBadge, MetricCard } from './SharedWidgets'
|
||||
|
||||
export function TrainingJobCard({ job, onPause, onResume, onStop, onViewDetails }: {
|
||||
job: TrainingJob
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onViewDetails: () => void
|
||||
}) {
|
||||
const isActive = ['training', 'preparing', 'validating'].includes(job.status)
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{job.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Typ: {job.model_type.charAt(0).toUpperCase() + job.model_type.slice(1)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ProgressRing
|
||||
progress={job.progress}
|
||||
color={job.status === 'failed' ? '#EF4444' : '#10B981'}
|
||||
/>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">Durchlauf</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{job.current_epoch} / {job.total_epochs}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${(job.current_epoch / job.total_epochs) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">Dokumente</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{job.documents_processed.toLocaleString()} / {job.total_documents.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full transition-all duration-500"
|
||||
style={{ width: `${(job.documents_processed / job.total_documents) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 mt-6">
|
||||
<MetricCard label="Loss" value={job.loss} trend="down" color="#3B82F6" />
|
||||
<MetricCard label="Val Loss" value={job.val_loss} trend="down" color="#8B5CF6" />
|
||||
<MetricCard label="Precision" value={job.metrics.precision} color="#10B981" />
|
||||
<MetricCard label="F1 Score" value={job.metrics.f1_score} color="#F59E0B" />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Fortschritt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<MiniChart data={job.metrics.loss_history} color="#3B82F6" />
|
||||
<MiniChart data={job.metrics.val_loss_history} color="#8B5CF6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Gestartet: {job.started_at ? new Date(job.started_at).toLocaleTimeString('de-DE') : '-'}
|
||||
</span>
|
||||
<span>
|
||||
Geschaetzt: {job.estimated_completion
|
||||
? new Date(job.estimated_completion).toLocaleTimeString('de-DE')
|
||||
: '-'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={job.status === 'paused' ? onResume : onPause}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
{job.status === 'paused' ? 'Fortsetzen' : 'Pausieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
admin-lehrer/app/(admin)/ai/rag-pipeline/api.ts
Normal file
146
admin-lehrer/app/(admin)/ai/rag-pipeline/api.ts
Normal 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
63
admin-lehrer/app/(admin)/ai/rag-pipeline/types.ts
Normal file
63
admin-lehrer/app/(admin)/ai/rag-pipeline/types.ts
Normal 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'
|
||||
}
|
||||
147
admin-lehrer/app/(admin)/ai/rag-pipeline/useRagPipeline.ts
Normal file
147
admin-lehrer/app/(admin)/ai/rag-pipeline/useRagPipeline.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
195
admin-lehrer/app/(admin)/ai/rag/_components/DataTab.tsx
Normal file
195
admin-lehrer/app/(admin)/ai/rag/_components/DataTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
admin-lehrer/app/(admin)/ai/rag/_components/IngestionTab.tsx
Normal file
69
admin-lehrer/app/(admin)/ai/rag/_components/IngestionTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
373
admin-lehrer/app/(admin)/ai/rag/_components/MapTab.tsx
Normal file
373
admin-lehrer/app/(admin)/ai/rag/_components/MapTab.tsx
Normal 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
|
||||
199
admin-lehrer/app/(admin)/ai/rag/_components/MapTabSections.tsx
Normal file
199
admin-lehrer/app/(admin)/ai/rag/_components/MapTabSections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
admin-lehrer/app/(admin)/ai/rag/_components/OverviewTab.tsx
Normal file
113
admin-lehrer/app/(admin)/ai/rag/_components/OverviewTab.tsx
Normal 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 · Bildungs-Erwartungshorizonte</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-orange-200 bg-orange-50 text-left">
|
||||
<p className="text-xs font-medium text-orange-600 uppercase">Legal Templates</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">7.689</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Chunks · Dokumentvorlagen (VVT, TOM, DSFA)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats per Type */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{Object.entries(TYPE_LABELS).map(([type, label]) => {
|
||||
const regs = REGULATIONS.filter((r) => r.type === type)
|
||||
const inRagCount = regs.filter((r) => isInRag(r.code)).length
|
||||
const totalChunks = regs.reduce((sum, r) => sum + getKnownChunks(r.code), 0)
|
||||
return (
|
||||
<div key={type} className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[type]}`}>{label}</span>
|
||||
<span className="text-slate-500 text-sm">{inRagCount}/{regs.length} im RAG</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-slate-900">{totalChunks.toLocaleString()} Chunks</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top Regulations */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-slate-50">
|
||||
<h3 className="font-semibold text-slate-900">Top Regulierungen (nach Chunks)</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{[...REGULATIONS].sort((a, b) => getKnownChunks(b.code) - getKnownChunks(a.code))
|
||||
.slice(0, 10)
|
||||
.map((reg) => {
|
||||
const chunks = getKnownChunks(reg.code)
|
||||
return (
|
||||
<div key={reg.code} className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isInRag(reg.code) ? (
|
||||
<span className="text-green-500 text-sm">✓</span>
|
||||
) : (
|
||||
<span className="text-red-400 text-sm">✗</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${TYPE_COLORS[reg.type]}`}>
|
||||
{TYPE_LABELS[reg.type]}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900">{reg.name}</span>
|
||||
<span className="text-slate-500 text-sm">({reg.code})</span>
|
||||
</div>
|
||||
<span className={`font-bold ${chunks > 0 ? 'text-teal-600' : 'text-slate-300'}`}>{chunks > 0 ? chunks.toLocaleString() + ' Chunks' : '—'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
410
admin-lehrer/app/(admin)/ai/rag/_components/PipelineTab.tsx
Normal file
410
admin-lehrer/app/(admin)/ai/rag/_components/PipelineTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
451
admin-lehrer/app/(admin)/ai/rag/_components/RegulationsTab.tsx
Normal file
451
admin-lehrer/app/(admin)/ai/rag/_components/RegulationsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
admin-lehrer/app/(admin)/ai/rag/_components/SearchTab.tsx
Normal file
97
admin-lehrer/app/(admin)/ai/rag/_components/SearchTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
441
admin-lehrer/app/(admin)/ai/rag/_hooks/useRAGPage.ts
Normal file
441
admin-lehrer/app/(admin)/ai/rag/_hooks/useRAGPage.ts
Normal 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
352
admin-lehrer/app/(admin)/ai/rag/rag-data.ts
Normal file
352
admin-lehrer/app/(admin)/ai/rag/rag-data.ts
Normal 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: '🔄' },
|
||||
]
|
||||
221
admin-lehrer/app/(admin)/ai/rag/rag-sources.ts
Normal file
221
admin-lehrer/app/(admin)/ai/rag/rag-sources.ts
Normal 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',
|
||||
}
|
||||
183
admin-lehrer/app/(admin)/ai/rag/types.ts
Normal file
183
admin-lehrer/app/(admin)/ai/rag/types.ts
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
// Demo failed test details for illustration
|
||||
const FAILED_TEST_DETAILS: Record<string, { description: string; cause: string; action: string }> = {
|
||||
// Golden Suite - Intent Tests
|
||||
'INT-001': {
|
||||
description: 'Student Observation - Simple',
|
||||
cause: 'Notiz zu Max wird nicht korrekt als student_observation erkannt',
|
||||
action: 'Training-Daten fuer kurze Notiz-Befehle erweitern',
|
||||
},
|
||||
'INT-002': {
|
||||
description: 'Student Observation - Needs Help',
|
||||
cause: 'Anfrage "Anna braucht extra Uebungsblatt" wird falsch klassifiziert',
|
||||
action: 'Intent-Erkennung fuer Hilfe-Anfragen verbessern',
|
||||
},
|
||||
'INT-003': {
|
||||
description: 'Reminder - Simple',
|
||||
cause: 'Erinnerungs-Intent nicht erkannt',
|
||||
action: 'Trigger-Woerter fuer Erinnerungen pruefen',
|
||||
},
|
||||
'INT-010': {
|
||||
description: 'Quick Activity - With Time',
|
||||
cause: '"10 Minuten Einstieg" wird nicht als quick_activity erkannt',
|
||||
action: 'Zeitmuster in Quick-Activity-Intent aufnehmen',
|
||||
},
|
||||
'INT-011': {
|
||||
description: 'Quiz Generate - Vocabulary',
|
||||
cause: 'Vokabeltest wird nicht als quiz_generate klassifiziert',
|
||||
action: 'Quiz-Keywords wie "Test", "Vokabel" staerker gewichten',
|
||||
},
|
||||
'INT-012': {
|
||||
description: 'Quiz Generate - Short Test',
|
||||
cause: '"Kurzer Test zu Kapitel 5" falsch erkannt',
|
||||
action: 'Kontext-Keywords fuer Quiz verbessern',
|
||||
},
|
||||
'INT-015': {
|
||||
description: 'Class Message',
|
||||
cause: 'Nachricht an Klasse wird als anderer Intent erkannt',
|
||||
action: 'Klassen-Nachrichten-Patterns erweitern',
|
||||
},
|
||||
'INT-019': {
|
||||
description: 'Operator Checklist',
|
||||
cause: 'Operatoren-Anfrage nicht korrekt klassifiziert',
|
||||
action: 'EH/Operator-bezogene Intents pruefen',
|
||||
},
|
||||
'INT-021': {
|
||||
description: 'Feedback Suggest',
|
||||
cause: 'Feedback-Vorschlag Intent nicht erkannt',
|
||||
action: 'Feedback-Synonyme hinzufuegen',
|
||||
},
|
||||
'INT-022': {
|
||||
description: 'Reminder Schedule - Tomorrow',
|
||||
cause: 'Zeitbasierte Erinnerung falsch klassifiziert',
|
||||
action: 'Zeitausdrucke wie "morgen" besser verarbeiten',
|
||||
},
|
||||
'INT-023': {
|
||||
description: 'Task Summary',
|
||||
cause: 'Zusammenfassungs-Intent nicht erkannt',
|
||||
action: 'Summary-Trigger erweitern',
|
||||
},
|
||||
// RAG Tests
|
||||
'RAG-EH-001': {
|
||||
description: 'EH Passage Retrieval - Textanalyse Sachtext',
|
||||
cause: 'EH-Passage nicht gefunden oder unvollstaendig',
|
||||
action: 'RAG-Retrieval fuer Textanalyse optimieren',
|
||||
},
|
||||
'RAG-HAL-002': {
|
||||
description: 'No Fictional EH Passages',
|
||||
cause: 'System generiert fiktive EH-Inhalte',
|
||||
action: 'Hallucination-Control verstaerken',
|
||||
},
|
||||
'RAG-HAL-004': {
|
||||
description: 'Grounded Response Only',
|
||||
cause: 'Antwort basiert nicht auf vorhandenen Daten',
|
||||
action: 'Grounding-Check im Response-Flow einbauen',
|
||||
},
|
||||
'RAG-CIT-003': {
|
||||
description: 'Multiple Source Attribution',
|
||||
cause: 'Mehrere Quellen nicht korrekt zugeordnet',
|
||||
action: 'Multi-Source Citation verbessern',
|
||||
},
|
||||
'RAG-EDGE-002': {
|
||||
description: 'Ambiguous Operator Query',
|
||||
cause: 'Bei mehrdeutiger Anfrage keine Klaerung angefordert',
|
||||
action: 'Clarification-Flow implementieren',
|
||||
},
|
||||
// Synthetic Tests
|
||||
'SYN-STUD-003': {
|
||||
description: 'Synthetic Student Observation',
|
||||
cause: 'Generierte Variante nicht erkannt',
|
||||
action: 'Robustheit gegen Variationen erhoehen',
|
||||
},
|
||||
'SYN-WORK-002': {
|
||||
description: 'Synthetic Worksheet Generate',
|
||||
cause: 'Arbeitsblatt-Intent bei Variation nicht erkannt',
|
||||
action: 'Mehr Variationen ins Training aufnehmen',
|
||||
},
|
||||
'SYN-WORK-005': {
|
||||
description: 'Synthetic Worksheet mit Tippfehler',
|
||||
cause: 'Tippfehler fuehrt zu Fehlklassifikation',
|
||||
action: 'Tippfehler-Normalisierung pruefen',
|
||||
},
|
||||
'SYN-REM-001': {
|
||||
description: 'Synthetic Reminder',
|
||||
cause: 'Reminder-Variante nicht erkannt',
|
||||
action: 'Reminder-Patterns erweitern',
|
||||
},
|
||||
'SYN-REM-004': {
|
||||
description: 'Synthetic Reminder mit Dialekt',
|
||||
cause: 'Dialekt-Formulierung nicht verstanden',
|
||||
action: 'Dialekt-Normalisierung verbessern',
|
||||
},
|
||||
}
|
||||
|
||||
export function FailedTestsList({ testIds }: { testIds: string[] }) {
|
||||
const [expandedTest, setExpandedTest] = useState<string | null>(null)
|
||||
|
||||
if (testIds.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-emerald-600">
|
||||
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Alle Tests bestanden!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{testIds.map((testId) => {
|
||||
const details = FAILED_TEST_DETAILS[testId]
|
||||
const isExpanded = expandedTest === testId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={testId}
|
||||
className="rounded-lg border border-red-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedTest(isExpanded ? null : testId)}
|
||||
className="w-full flex items-center justify-between p-3 bg-red-50 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="font-mono text-sm text-red-700">{testId}</span>
|
||||
{details && (
|
||||
<span className="text-xs text-red-600 hidden sm:inline">- {details.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-red-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isExpanded && details && (
|
||||
<div className="p-4 bg-white border-t border-red-100 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Ursache</p>
|
||||
<p className="text-sm text-slate-700 mt-1">{details.cause}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase">Empfohlene Aktion</p>
|
||||
<p className="text-sm text-slate-700 mt-1 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{details.action}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-400">
|
||||
Test-ID: <span className="font-mono">{testId}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-xs text-amber-800">
|
||||
<strong>Tipp:</strong> Klicken Sie auf einen Test um Details zur Ursache und empfohlene Aktionen zu sehen.
|
||||
Fehlgeschlagene Tests sollten vor dem naechsten Release behoben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import type { BQASMetrics } from '../types'
|
||||
import { IntentScoresChart } from './IntentScoresChart'
|
||||
import { FailedTestsList } from './FailedTestsList'
|
||||
|
||||
export function GoldenTab({
|
||||
goldenMetrics,
|
||||
isRunningGolden,
|
||||
runGoldenTests,
|
||||
}: {
|
||||
goldenMetrics: BQASMetrics | null
|
||||
isRunningGolden: boolean
|
||||
runGoldenTests: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Golden Test Suite</h3>
|
||||
<p className="text-sm text-slate-500">Validierte Referenz-Tests gegen definierte Erwartungen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={runGoldenTests}
|
||||
disabled={isRunningGolden}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunningGolden
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{isRunningGolden ? 'Laeuft...' : 'Tests starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{goldenMetrics && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-slate-900">{goldenMetrics.total_tests}</p>
|
||||
<p className="text-xs text-slate-500">Tests</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-emerald-600">{goldenMetrics.passed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Bestanden</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-red-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-red-600">{goldenMetrics.failed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600">{goldenMetrics.avg_intent_accuracy.toFixed(0)}%</p>
|
||||
<p className="text-xs text-slate-500">Intent Accuracy</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600">{goldenMetrics.avg_composite_score.toFixed(2)}</p>
|
||||
<p className="text-xs text-slate-500">Composite Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-4">Scores nach Intent</h4>
|
||||
<IntentScoresChart scores={goldenMetrics.scores_by_intent} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests ({goldenMetrics.failed_tests})</h4>
|
||||
<FailedTestsList testIds={goldenMetrics.failed_test_ids} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export function GuideTab() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Introduction */}
|
||||
<div className="bg-gradient-to-r from-teal-50 to-emerald-50 rounded-xl border border-teal-200 p-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Was ist BQAS?
|
||||
</h2>
|
||||
<p className="text-slate-700 leading-relaxed">
|
||||
Das <strong>Breakpilot Quality Assurance System (BQAS)</strong> ist unser automatisiertes Test-Framework
|
||||
zur kontinuierlichen Qualitaetssicherung der KI-Komponenten. Es stellt sicher, dass Aenderungen am
|
||||
Voice-Service, den Prompts oder den RAG-Pipelines keine Regressionen verursachen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* For Whom */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Fuer wen ist dieses Dashboard?</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
Entwickler
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Pruefen Sie nach Code-Aenderungen ob alle Tests noch bestehen. Analysieren Sie fehlgeschlagene Tests
|
||||
und implementieren Sie Fixes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<h4 className="font-medium text-purple-800 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Data Scientists
|
||||
</h4>
|
||||
<p className="text-sm text-purple-700 mt-2">
|
||||
Analysieren Sie Intent-Scores, Faithfulness und Relevance. Identifizieren Sie Schwachstellen
|
||||
in den ML-Modellen und RAG-Pipelines.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<h4 className="font-medium text-amber-800 flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
Auditoren / QA
|
||||
</h4>
|
||||
<p className="text-sm text-amber-700 mt-2">
|
||||
Dokumentieren Sie die Testabdeckung und Qualitaetsmetriken. Nutzen Sie die Historie
|
||||
fuer Audit-Trails und Compliance-Nachweise.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Suites Explained */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Die drei Test-Suites</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-blue-600">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">Golden Suite (97 Tests)</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Was:</strong> Manuell validierte Referenz-Tests mit definierten Erwartungen. Jeder Test
|
||||
hat eine Eingabe, eine erwartete Ausgabe und Bewertungskriterien.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Wann ausfuehren:</strong> Nach jeder Aenderung am Voice-Service oder den Prompts.
|
||||
Automatisch taeglich um 07:00 Uhr via launchd.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Ziel-Score:</strong> {'>'}= 4.0 (von 5.0)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-purple-600">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">RAG/Korrektur Tests</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Was:</strong> Tests fuer das Retrieval-Augmented Generation System. Pruefen ob der richtige
|
||||
Erwartungshorizont gefunden wird und ob Antworten korrekt zitiert werden.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Wann ausfuehren:</strong> Nach Aenderungen an Qdrant, Chunking-Strategien oder EH-Uploads.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Kategorien:</strong> EH-Retrieval, Operator-Alignment, Hallucination-Control, Citation-Enforcement,
|
||||
Privacy-Compliance, Namespace-Isolation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-amber-600">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">Synthetic Tests</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Was:</strong> LLM-generierte Variationen der Golden-Tests. Testet Robustheit gegenueber
|
||||
Umformulierungen, Tippfehlern, Dialekt und Edge-Cases.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Wann ausfuehren:</strong> Woechentlich oder vor Major-Releases.
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<strong>Hinweis:</strong> Generierung dauert laenger da LLM-Calls benoetigt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Explained */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Metriken verstehen</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Metrik</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-700">Beschreibung</th>
|
||||
<th className="text-center py-3 px-4 font-medium text-slate-700">Zielwert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Composite Score</td>
|
||||
<td className="py-3 px-4 text-slate-600">Gewichteter Durchschnitt aller Einzelmetriken (1-5)</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Intent Accuracy</td>
|
||||
<td className="py-3 px-4 text-slate-600">Wie oft wird die richtige Nutzerabsicht erkannt?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 90%</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Faithfulness</td>
|
||||
<td className="py-3 px-4 text-slate-600">Ist die Antwort dem EH treu? Keine Halluzinationen?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Relevance</td>
|
||||
<td className="py-3 px-4 text-slate-600">Beantwortet die Antwort die Frage des Nutzers?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-3 px-4 font-medium">Coherence</td>
|
||||
<td className="py-3 px-4 text-slate-600">Ist die Antwort logisch aufgebaut und verstaendlich?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">{'>'}= 4.0</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 font-medium">Safety Pass Rate</td>
|
||||
<td className="py-3 px-4 text-slate-600">Werden kritische Inhalte korrekt gefiltert?</td>
|
||||
<td className="py-3 px-4 text-center"><span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded text-xs">100%</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Typischer Workflow</h3>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200"></div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ step: 1, title: 'Tests starten', desc: 'Klicken Sie auf "Tests starten" bei der gewuenschten Suite. Eine Benachrichtigung zeigt den Status.' },
|
||||
{ step: 2, title: 'Ergebnisse pruefen', desc: 'Nach Abschluss werden Pass Rate und Score angezeigt. Pruefen Sie ob der Zielwert erreicht wurde.' },
|
||||
{ step: 3, title: 'Fehlgeschlagene Tests analysieren', desc: 'Klicken Sie auf fehlgeschlagene Tests um Ursache und empfohlene Aktionen zu sehen.' },
|
||||
{ step: 4, title: 'Fixes implementieren', desc: 'Beheben Sie die identifizierten Probleme im Code, Prompts oder Training-Daten.' },
|
||||
{ step: 5, title: 'Erneut testen', desc: 'Fuehren Sie die Tests erneut aus um zu verifizieren dass die Fixes wirksam sind.' },
|
||||
{ step: 6, title: 'Dokumentieren', desc: 'Nutzen Sie die Historie als Audit-Trail. Exportieren Sie Reports fuer Compliance-Nachweise.' },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="flex gap-4 relative">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-teal-600 text-white flex items-center justify-center text-sm font-bold z-10">
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<h4 className="font-medium text-slate-900">{item.title}</h4>
|
||||
<p className="text-sm text-slate-600 mt-0.5">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Haeufige Fragen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
q: 'Wie lange dauert ein Test-Lauf?',
|
||||
a: 'Golden Suite: ca. 45 Sekunden. RAG Tests: ca. 60 Sekunden. Synthetic Tests: 2-5 Minuten (abhaengig von LLM-Verfuegbarkeit).',
|
||||
},
|
||||
{
|
||||
q: 'Was passiert wenn Tests fehlschlagen?',
|
||||
a: 'Fehlgeschlagene Tests werden rot markiert. Klicken Sie darauf um Details zu sehen. Bei kritischen Regressionen wird automatisch eine Desktop-Benachrichtigung gesendet.',
|
||||
},
|
||||
{
|
||||
q: 'Wann werden Tests automatisch ausgefuehrt?',
|
||||
a: 'Die Golden Suite laeuft taeglich um 07:00 Uhr via launchd. Zusaetzlich bei jedem Commit im voice-service via Git-Hook (Quick-Tests).',
|
||||
},
|
||||
{
|
||||
q: 'Wie kann ich einen neuen Golden-Test hinzufuegen?',
|
||||
a: 'Tests werden in /voice-service/bqas/golden_tests.json definiert. Jeder Test braucht: ID, Input, Expected Intent, Bewertungskriterien.',
|
||||
},
|
||||
{
|
||||
q: 'Was bedeutet "Demo-Daten"?',
|
||||
a: 'Wenn die Voice-Service API nicht erreichbar ist, werden Demo-Daten angezeigt. Dies ist normal in der Entwicklungsumgebung wenn der Service nicht laeuft.',
|
||||
},
|
||||
].map((faq, i) => (
|
||||
<div key={i} className="border-b border-slate-100 pb-4 last:border-0 last:pb-0">
|
||||
<p className="font-medium text-slate-900">{faq.q}</p>
|
||||
<p className="text-sm text-slate-600 mt-1">{faq.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/infrastructure/ci-cd"
|
||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">CI/CD Scheduler</p>
|
||||
<p className="text-xs text-slate-500">Automatische Test-Planung konfigurieren</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai/rag"
|
||||
className="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-teal-300 hover:bg-teal-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">RAG Management</p>
|
||||
<p className="text-xs text-slate-500">Erwartungshorizonte und Chunking verwalten</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
export function IntentScoresChart({ scores }: { scores: Record<string, number> }) {
|
||||
const entries = Object.entries(scores).sort((a, b) => b[1] - a[1])
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Keine Intent-Scores verfuegbar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([intent, score]) => (
|
||||
<div key={intent}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-slate-600 truncate max-w-[200px]">{intent.replace(/_/g, ' ')}</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
score >= 4 ? 'text-emerald-600' : score >= 3 ? 'text-amber-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{score.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
score >= 4 ? 'bg-emerald-500' : score >= 3 ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${(score / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
trend,
|
||||
color = 'blue',
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
trend?: 'up' | 'down' | 'stable'
|
||||
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 border-blue-200',
|
||||
green: 'bg-emerald-50 border-emerald-200',
|
||||
red: 'bg-red-50 border-red-200',
|
||||
yellow: 'bg-amber-50 border-amber-200',
|
||||
purple: 'bg-purple-50 border-purple-200',
|
||||
}
|
||||
|
||||
const trendIcons = {
|
||||
up: (
|
||||
<svg className="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
),
|
||||
down: (
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
),
|
||||
stable: (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-5 ${colorClasses[color]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600">{title}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
|
||||
{subtitle && <p className="mt-1 text-xs text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
{trend && <div className="mt-1">{trendIcons[trend]}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import type { BQASMetrics, TrendData, TestRun } from '../types'
|
||||
import { MetricCard } from './MetricCard'
|
||||
import { TrendChart } from './TrendChart'
|
||||
import { TestSuiteCard } from './TestSuiteCard'
|
||||
|
||||
export function OverviewTab({
|
||||
goldenMetrics,
|
||||
syntheticMetrics,
|
||||
ragMetrics,
|
||||
trendData,
|
||||
testRuns,
|
||||
isRunningGolden,
|
||||
isRunningSynthetic,
|
||||
isRunningRag,
|
||||
runGoldenTests,
|
||||
runSyntheticTests,
|
||||
runRagTests,
|
||||
}: {
|
||||
goldenMetrics: BQASMetrics | null
|
||||
syntheticMetrics: BQASMetrics | null
|
||||
ragMetrics: BQASMetrics | null
|
||||
trendData: TrendData | null
|
||||
testRuns: TestRun[]
|
||||
isRunningGolden: boolean
|
||||
isRunningSynthetic: boolean
|
||||
isRunningRag: boolean
|
||||
runGoldenTests: () => void
|
||||
runSyntheticTests: () => void
|
||||
runRagTests: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="Golden Score"
|
||||
value={goldenMetrics?.avg_composite_score.toFixed(2) || '-'}
|
||||
subtitle="Durchschnitt aller Golden Tests"
|
||||
trend={trendData?.trend === 'improving' ? 'up' : trendData?.trend === 'declining' ? 'down' : 'stable'}
|
||||
color="blue"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Pass Rate"
|
||||
value={goldenMetrics ? `${((goldenMetrics.passed_tests / goldenMetrics.total_tests) * 100).toFixed(0)}%` : '-'}
|
||||
subtitle={goldenMetrics ? `${goldenMetrics.passed_tests}/${goldenMetrics.total_tests} bestanden` : undefined}
|
||||
color="green"
|
||||
/>
|
||||
<MetricCard
|
||||
title="RAG Qualitaet"
|
||||
value={ragMetrics?.avg_composite_score.toFixed(2) || '-'}
|
||||
subtitle="RAG Retrieval Score"
|
||||
color="purple"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Test Runs"
|
||||
value={testRuns.length}
|
||||
subtitle="Letzte 30 Tage"
|
||||
color="yellow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Score-Trend (30 Tage)</h3>
|
||||
<TrendChart data={trendData || { dates: [], scores: [], trend: 'insufficient_data' }} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<TestSuiteCard
|
||||
title="Golden Suite"
|
||||
description="97 validierte Referenz-Tests fuer Intent-Erkennung"
|
||||
metrics={goldenMetrics || undefined}
|
||||
onRun={runGoldenTests}
|
||||
isRunning={isRunningGolden}
|
||||
/>
|
||||
<TestSuiteCard
|
||||
title="RAG/Korrektur Tests"
|
||||
description="EH-Retrieval, Operatoren-Alignment, Citation Tests"
|
||||
metrics={ragMetrics || undefined}
|
||||
onRun={runRagTests}
|
||||
isRunning={isRunningRag}
|
||||
/>
|
||||
<TestSuiteCard
|
||||
title="Synthetic Tests"
|
||||
description="LLM-generierte Variationen fuer Robustheit"
|
||||
metrics={syntheticMetrics || undefined}
|
||||
onRun={runSyntheticTests}
|
||||
isRunning={isRunningSynthetic}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Help */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Neu hier?</strong> Wechseln Sie zum Tab "Anleitung" fuer eine ausfuehrliche Erklaerung
|
||||
des BQAS-Systems und wie Sie es nutzen koennen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
admin-lehrer/app/(admin)/ai/test-quality/_components/RagTab.tsx
Normal file
111
admin-lehrer/app/(admin)/ai/test-quality/_components/RagTab.tsx
Normal 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 "Tests starten" um die RAG-Suite auszufuehren</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg border bg-blue-50 border-blue-200">
|
||||
<h4 className="font-medium text-slate-900">EH Retrieval</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Korrektes Abrufen von Erwartungshorizont-Passagen</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-purple-50 border-purple-200">
|
||||
<h4 className="font-medium text-slate-900">Operator Alignment</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Passende Operatoren fuer Abitur-Aufgaben</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-red-50 border-red-200">
|
||||
<h4 className="font-medium text-slate-900">Hallucination Control</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Keine erfundenen Fakten oder Inhalte</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-green-50 border-green-200">
|
||||
<h4 className="font-medium text-slate-900">Citation Enforcement</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Quellenangaben bei EH-Bezuegen</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-amber-50 border-amber-200">
|
||||
<h4 className="font-medium text-slate-900">Privacy Compliance</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Keine PII-Leaks, DSGVO-Konformitaet</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border bg-slate-50 border-slate-200">
|
||||
<h4 className="font-medium text-slate-900">Namespace Isolation</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">Strikte Trennung zwischen Lehrern</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import type { BQASMetrics } from '../types'
|
||||
import { IntentScoresChart } from './IntentScoresChart'
|
||||
import { FailedTestsList } from './FailedTestsList'
|
||||
|
||||
export function SyntheticTab({
|
||||
syntheticMetrics,
|
||||
isRunningSynthetic,
|
||||
runSyntheticTests,
|
||||
}: {
|
||||
syntheticMetrics: BQASMetrics | null
|
||||
isRunningSynthetic: boolean
|
||||
runSyntheticTests: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Synthetic Test Suite</h3>
|
||||
<p className="text-sm text-slate-500">LLM-generierte Variationen fuer Robustheit-Tests</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={runSyntheticTests}
|
||||
disabled={isRunningSynthetic}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunningSynthetic
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{isRunningSynthetic ? 'Laeuft...' : 'Tests starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{syntheticMetrics ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-slate-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-slate-900">{syntheticMetrics.total_tests}</p>
|
||||
<p className="text-xs text-slate-500">Generierte Tests</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-emerald-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-emerald-600">{syntheticMetrics.passed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Bestanden</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-blue-600">{syntheticMetrics.avg_composite_score.toFixed(2)}</p>
|
||||
<p className="text-xs text-slate-500">Avg Score</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-purple-600">{syntheticMetrics.avg_coherence.toFixed(2)}</p>
|
||||
<p className="text-xs text-slate-500">Coherence</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-4">Intent-Variationen</h4>
|
||||
<IntentScoresChart scores={syntheticMetrics.scores_by_intent} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-4">Fehlgeschlagene Tests</h4>
|
||||
<FailedTestsList testIds={syntheticMetrics.failed_test_ids} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<svg className="w-16 h-16 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
<p>Noch keine synthetischen Tests ausgefuehrt</p>
|
||||
<p className="text-sm mt-2">Klicke "Tests starten" um Variationen zu generieren</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { TestRun } from '../types'
|
||||
|
||||
export function TestRunsTable({ runs }: { runs: TestRun[] }) {
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
Keine Test-Laeufe vorhanden
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-600">ID</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-600">Zeitpunkt</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-slate-600">Commit</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Golden Score</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Tests</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Bestanden</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-slate-600">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4 font-mono text-slate-900">#{run.id}</td>
|
||||
<td className="py-3 px-4 text-slate-600">
|
||||
{new Date(run.timestamp).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-xs text-slate-500">
|
||||
{run.git_commit?.slice(0, 7) || '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span
|
||||
className={`font-medium ${
|
||||
run.golden_score >= 4 ? 'text-emerald-600' : run.golden_score >= 3 ? 'text-amber-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{run.golden_score.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-600">{run.total_tests}</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<span className="text-emerald-600">{run.passed_tests}</span>
|
||||
<span className="text-slate-400"> / </span>
|
||||
<span className="text-red-600">{run.failed_tests}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-500">
|
||||
{run.duration_seconds.toFixed(1)}s
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import type { BQASMetrics } from '../types'
|
||||
|
||||
export function TestSuiteCard({
|
||||
title,
|
||||
description,
|
||||
metrics,
|
||||
onRun,
|
||||
isRunning,
|
||||
lastRun,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
metrics?: BQASMetrics
|
||||
onRun: () => void
|
||||
isRunning: boolean
|
||||
lastRun?: string
|
||||
}) {
|
||||
const passRate = metrics ? (metrics.passed_tests / metrics.total_tests) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRun}
|
||||
disabled={isRunning}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isRunning
|
||||
? 'bg-teal-100 text-teal-600 cursor-wait'
|
||||
: 'bg-teal-600 text-white hover:bg-teal-700 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Laeuft...
|
||||
</span>
|
||||
) : (
|
||||
'Tests starten'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{metrics && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">Pass Rate</span>
|
||||
<span className="font-medium text-slate-900">{passRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
passRate >= 80 ? 'bg-emerald-500' : passRate >= 60 ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${passRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-slate-900">{metrics.total_tests}</p>
|
||||
<p className="text-xs text-slate-500">Tests</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{metrics.passed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Bestanden</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{metrics.failed_tests}</p>
|
||||
<p className="text-xs text-slate-500">Fehlgeschlagen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-500">
|
||||
Durchschnittlicher Score: <span className="font-medium">{metrics.avg_composite_score.toFixed(2)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRun && (
|
||||
<p className="mt-4 text-xs text-slate-400">Letzter Lauf: {new Date(lastRun).toLocaleString('de-DE')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { Toast } from '../useTestQuality'
|
||||
|
||||
export function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border animate-slide-in ${
|
||||
toast.type === 'success'
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||||
: toast.type === 'error'
|
||||
? 'bg-red-50 border-red-200 text-red-800'
|
||||
: toast.type === 'loading'
|
||||
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{toast.type === 'loading' ? (
|
||||
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : toast.type === 'success' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : toast.type === 'error' ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
{toast.type !== 'loading' && (
|
||||
<button onClick={() => onDismiss(toast.id)} className="ml-2 opacity-60 hover:opacity-100">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import type { TrendData } from '../types'
|
||||
|
||||
export function TrendChart({ data }: { data: TrendData }) {
|
||||
if (!data || data.dates.length === 0) {
|
||||
return (
|
||||
<div className="h-48 flex items-center justify-center text-slate-400">
|
||||
Keine Trend-Daten verfuegbar
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxScore = Math.max(...data.scores, 5)
|
||||
const minScore = Math.min(...data.scores, 0)
|
||||
const range = maxScore - minScore || 1
|
||||
|
||||
return (
|
||||
<div className="h-48 relative">
|
||||
<div className="absolute left-0 top-0 bottom-4 w-8 flex flex-col justify-between text-xs text-slate-400">
|
||||
<span>{maxScore.toFixed(1)}</span>
|
||||
<span>{((maxScore + minScore) / 2).toFixed(1)}</span>
|
||||
<span>{minScore.toFixed(1)}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-10 h-full pr-4">
|
||||
<svg className="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<line x1="0" y1="0" x2="100" y2="0" stroke="#e2e8f0" strokeWidth="0.5" />
|
||||
<line x1="0" y1="50" x2="100" y2="50" stroke="#e2e8f0" strokeWidth="0.5" />
|
||||
<line x1="0" y1="100" x2="100" y2="100" stroke="#e2e8f0" strokeWidth="0.5" />
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#14b8a6"
|
||||
strokeWidth="2"
|
||||
points={data.scores
|
||||
.map((score, i) => {
|
||||
const x = (i / (data.scores.length - 1 || 1)) * 100
|
||||
const y = 100 - ((score - minScore) / range) * 100
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
|
||||
{data.scores.map((score, i) => {
|
||||
const x = (i / (data.scores.length - 1 || 1)) * 100
|
||||
const y = 100 - ((score - minScore) / range) * 100
|
||||
return <circle key={i} cx={x} cy={y} r="2" fill="#14b8a6" />
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="ml-10 flex justify-between text-xs text-slate-400 mt-1">
|
||||
{data.dates.slice(0, 5).map((date, i) => (
|
||||
<span key={i}>{new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
data.trend === 'improving'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: data.trend === 'declining'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{data.trend === 'improving' ? 'Verbessernd' : data.trend === 'declining' ? 'Verschlechternd' : 'Stabil'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { ToastContainer } from './ToastContainer'
|
||||
export { MetricCard } from './MetricCard'
|
||||
export { TestSuiteCard } from './TestSuiteCard'
|
||||
export { TrendChart } from './TrendChart'
|
||||
export { TestRunsTable } from './TestRunsTable'
|
||||
export { IntentScoresChart } from './IntentScoresChart'
|
||||
export { FailedTestsList } from './FailedTestsList'
|
||||
export { GuideTab } from './GuideTab'
|
||||
export { OverviewTab } from './OverviewTab'
|
||||
export { GoldenTab } from './GoldenTab'
|
||||
export { RagTab } from './RagTab'
|
||||
export { SyntheticTab } from './SyntheticTab'
|
||||
41
admin-lehrer/app/(admin)/ai/test-quality/constants.ts
Normal file
41
admin-lehrer/app/(admin)/ai/test-quality/constants.ts
Normal 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
219
admin-lehrer/app/(admin)/ai/test-quality/useTestQuality.ts
Normal file
219
admin-lehrer/app/(admin)/ai/test-quality/useTestQuality.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 → Actions → 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>
|
||||
)
|
||||
}
|
||||
@@ -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 → <strong>User Settings</strong> → <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>
|
||||
)
|
||||
}
|
||||
@@ -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
105
admin-lehrer/app/(admin)/infrastructure/ci-cd/types.ts
Normal file
105
admin-lehrer/app/(admin)/infrastructure/ci-cd/types.ts
Normal 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'
|
||||
244
admin-lehrer/app/(admin)/infrastructure/ci-cd/useCiCdData.ts
Normal file
244
admin-lehrer/app/(admin)/infrastructure/ci-cd/useCiCdData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 "In Arbeit" 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 "Behoben" wenn der Test besteht</li>
|
||||
{usePostgres && <li>Setze "Flaky" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
46
admin-lehrer/lib/sdk/types/ai-act-obligations.ts
Normal file
46
admin-lehrer/lib/sdk/types/ai-act-obligations.ts
Normal 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
|
||||
}
|
||||
39
admin-lehrer/lib/sdk/types/assessment.ts
Normal file
39
admin-lehrer/lib/sdk/types/assessment.ts
Normal 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
|
||||
}
|
||||
55
admin-lehrer/lib/sdk/types/checkpoint.ts
Normal file
55
admin-lehrer/lib/sdk/types/checkpoint.ts
Normal 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
|
||||
}
|
||||
238
admin-lehrer/lib/sdk/types/company-profile.ts
Normal file
238
admin-lehrer/lib/sdk/types/company-profile.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
85
admin-lehrer/lib/sdk/types/compliance.ts
Normal file
85
admin-lehrer/lib/sdk/types/compliance.ts
Normal 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
|
||||
}
|
||||
88
admin-lehrer/lib/sdk/types/core.ts
Normal file
88
admin-lehrer/lib/sdk/types/core.ts
Normal 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'
|
||||
339
admin-lehrer/lib/sdk/types/document-generator.ts
Normal file
339
admin-lehrer/lib/sdk/types/document-generator.ts
Normal 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',
|
||||
}
|
||||
239
admin-lehrer/lib/sdk/types/documentation.ts
Normal file
239
admin-lehrer/lib/sdk/types/documentation.ts
Normal 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
|
||||
}
|
||||
263
admin-lehrer/lib/sdk/types/dsfa-rag.ts
Normal file
263
admin-lehrer/lib/sdk/types/dsfa-rag.ts
Normal 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',
|
||||
}
|
||||
39
admin-lehrer/lib/sdk/types/dsfa.ts
Normal file
39
admin-lehrer/lib/sdk/types/dsfa.ts
Normal 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
|
||||
}
|
||||
187
admin-lehrer/lib/sdk/types/helpers.ts
Normal file
187
admin-lehrer/lib/sdk/types/helpers.ts
Normal 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
|
||||
})
|
||||
}
|
||||
22
admin-lehrer/lib/sdk/types/index.ts
Normal file
22
admin-lehrer/lib/sdk/types/index.ts
Normal 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'
|
||||
42
admin-lehrer/lib/sdk/types/risk.ts
Normal file
42
admin-lehrer/lib/sdk/types/risk.ts
Normal 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[]
|
||||
}
|
||||
99
admin-lehrer/lib/sdk/types/screening-security.ts
Normal file
99
admin-lehrer/lib/sdk/types/screening-security.ts
Normal 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
|
||||
}
|
||||
429
admin-lehrer/lib/sdk/types/sdk-flow.ts
Normal file
429
admin-lehrer/lib/sdk/types/sdk-flow.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
197
admin-lehrer/lib/sdk/types/state.ts
Normal file
197
admin-lehrer/lib/sdk/types/state.ts
Normal 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' }
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
398
edu-search-service/internal/database/repository_staff.go
Normal file
398
edu-search-service/internal/database/repository_staff.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
411
edu-search-service/internal/policy/store_audit.go
Normal file
411
edu-search-service/internal/policy/store_audit.go
Normal 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
|
||||
}
|
||||
417
edu-search-service/internal/policy/store_sources.go
Normal file
417
edu-search-service/internal/policy/store_sources.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
350
edu-search-service/internal/search/search_query.go
Normal file
350
edu-search-service/internal/search/search_query.go
Normal 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
Reference in New Issue
Block a user