Files
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

1064 lines
38 KiB
TypeScript

'use client'
/**
* RAG & Daten-Management Admin Page
*
* Manages training data, RAG collections, ingestion, and quality metrics.
* See: klausur-service/docs/RAG-Admin-Spec.md
*/
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
// API Base URL for klausur-service
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Types
interface Collection {
name: string
displayName: string
bundesland: string
useCase: string
documentCount: number
chunkCount: number
years: number[]
subjects: string[]
status: 'ready' | 'indexing' | 'empty'
}
interface IngestionStatus {
running: boolean
lastRun: string | null
documentsIndexed: number | null
chunksCreated: number | null
errors: string[]
}
interface SearchResult {
id: string
score: number
text: string
year: number | null
subject: string | null
niveau: string | null
taskNumber: number | null
}
// Tab definitions
type TabId = 'collections' | 'upload' | 'ingestion' | 'search' | 'metrics'
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'collections',
name: 'Sammlungen',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
id: 'upload',
name: 'Upload',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
),
},
{
id: 'ingestion',
name: 'Ingestion',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
id: 'search',
name: 'Suche & Test',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
},
{
id: 'metrics',
name: 'Metriken',
icon: (
<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>
),
},
]
// Main Component
export default function RAGAdminPage() {
const [activeTab, setActiveTab] = useState<TabId>('collections')
const [collections, setCollections] = useState<Collection[]>([])
const [ingestionStatus, setIngestionStatus] = useState<IngestionStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch collections and status
const fetchData = useCallback(async () => {
try {
setLoading(true)
// Fetch collections info
const collectionsRes = await fetch(`${API_BASE}/api/v1/admin/nibis/collections`)
const statsRes = await fetch(`${API_BASE}/api/v1/admin/nibis/stats`)
const statusRes = await fetch(`${API_BASE}/api/v1/admin/nibis/status`)
if (collectionsRes.ok && statsRes.ok && statusRes.ok) {
const collectionsData = await collectionsRes.json()
const statsData = await statsRes.json()
const statusData = await statusRes.json()
// Build collections from data
const cols: Collection[] = []
// Check if we have the NiBiS collection
const nibisCollection = collectionsData.collections?.find(
(c: { name: string }) => c.name === 'bp_nibis_eh'
)
if (nibisCollection || statsData.indexed) {
cols.push({
name: 'bp_nibis_eh',
displayName: 'Niedersachsen - Klausurkorrektur',
bundesland: 'NI',
useCase: 'klausur',
documentCount: statsData.total_chunks || nibisCollection?.points_count || 0,
chunkCount: statsData.total_chunks || nibisCollection?.points_count || 0,
years: statsData.years || [],
subjects: statsData.subjects || [],
status: statusData.running ? 'indexing' : (statsData.indexed ? 'ready' : 'empty'),
})
}
setCollections(cols)
setIngestionStatus({
running: statusData.running,
lastRun: statusData.last_run,
documentsIndexed: statusData.documents_indexed,
chunksCreated: statusData.chunks_created,
errors: statusData.errors || [],
})
}
setError(null)
} catch (err) {
console.error('Failed to fetch data:', err)
setError('Verbindung zum Klausur-Service fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
// Refresh every 5 seconds if ingestion is running
const interval = setInterval(() => {
if (ingestionStatus?.running) {
fetchData()
}
}, 5000)
return () => clearInterval(interval)
}, [fetchData, ingestionStatus?.running])
return (
<AdminLayout
title="Daten & RAG"
description="Training Data Management und RAG-Sammlungen"
>
{/* Error Banner */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-700">{error}</span>
<button onClick={fetchData} className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium">
Erneut versuchen
</button>
</div>
)}
{/* Tab Navigation */}
<div className="border-b border-slate-200 mb-6">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}
`}
>
{tab.icon}
{tab.name}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'collections' && (
<CollectionsTab
collections={collections}
loading={loading}
onRefresh={fetchData}
/>
)}
{activeTab === 'upload' && (
<UploadTab onUploadComplete={fetchData} />
)}
{activeTab === 'ingestion' && (
<IngestionTab
status={ingestionStatus}
onRefresh={fetchData}
/>
)}
{activeTab === 'search' && (
<SearchTab collections={collections} />
)}
{activeTab === 'metrics' && (
<MetricsTab collections={collections} />
)}
</AdminLayout>
)
}
// ============================================================================
// Collections Tab
// ============================================================================
function CollectionsTab({
collections,
loading,
onRefresh
}: {
collections: Collection[]
loading: boolean
onRefresh: () => void
}) {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Sammlungen</h2>
<p className="text-sm text-slate-500">Verwaltung der indexierten Dokumentensammlungen</p>
</div>
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
{/* Collections Grid */}
{!loading && (
<div className="grid gap-4">
{collections.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine Sammlungen vorhanden</h3>
<p className="text-slate-500 mb-4">Starten Sie die Ingestion oder laden Sie Dokumente hoch.</p>
</div>
) : (
collections.map((col) => (
<CollectionCard key={col.name} collection={col} />
))
)}
{/* Placeholder for future collections */}
<div className="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-slate-400 transition-colors cursor-pointer">
<svg className="w-8 h-8 text-slate-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-sm font-medium text-slate-600">Neue Sammlung (demnächst)</span>
</div>
</div>
)}
</div>
)
}
function CollectionCard({ collection }: { collection: Collection }) {
const statusColors = {
ready: 'bg-green-100 text-green-800',
indexing: 'bg-yellow-100 text-yellow-800',
empty: 'bg-slate-100 text-slate-800',
}
const statusLabels = {
ready: 'Bereit',
indexing: 'Indexierung...',
empty: 'Leer',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">{collection.displayName}</h3>
<p className="text-sm text-slate-500 font-mono">{collection.name}</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[collection.status]}`}>
{statusLabels[collection.status]}
</span>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Chunks</p>
<p className="text-lg font-semibold text-slate-900">
{collection.chunkCount.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Jahre</p>
<p className="text-lg font-semibold text-slate-900">
{collection.years.length > 0
? `${Math.min(...collection.years)}-${Math.max(...collection.years)}`
: '-'
}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Fächer</p>
<p className="text-lg font-semibold text-slate-900">
{collection.subjects.length}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Bundesland</p>
<p className="text-lg font-semibold text-slate-900">
{collection.bundesland}
</p>
</div>
</div>
{collection.subjects.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{collection.subjects.slice(0, 8).map((subject) => (
<span
key={subject}
className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded-md"
>
{subject}
</span>
))}
{collection.subjects.length > 8 && (
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-xs rounded-md">
+{collection.subjects.length - 8} weitere
</span>
)}
</div>
)}
</div>
)
}
// ============================================================================
// Upload Tab
// ============================================================================
function UploadTab({ onUploadComplete }: { onUploadComplete: () => void }) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
const validFiles = droppedFiles.filter(
f => f.name.endsWith('.zip') || f.name.endsWith('.pdf')
)
setFiles(prev => [...prev, ...validFiles])
}, [])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
setFiles(prev => [...prev, ...selectedFiles])
}
}, [])
const removeFile = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index))
}, [])
const handleUpload = async () => {
if (files.length === 0) return
setUploading(true)
try {
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
formData.append('collection', 'bp_nibis_eh')
// Note: This endpoint needs to be implemented in the backend
await fetch(`${API_BASE}/api/v1/admin/rag/upload`, {
method: 'POST',
body: formData,
})
}
setFiles([])
onUploadComplete()
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Dokumente hochladen</h2>
<p className="text-sm text-slate-500">
ZIP-Archive oder einzelne PDFs hochladen. ZIPs werden automatisch entpackt.
</p>
</div>
{/* Collection Selector */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Ziel-Sammlung
</label>
<select className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
<option value="bp_nibis_eh">Niedersachsen - Klausurkorrektur</option>
<option value="bp_ni_zeugnis" disabled>Niedersachsen - Zeugnisgenerator (demnächst)</option>
</select>
</div>
{/* Drop Zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-12 text-center transition-colors
${isDragging
? 'border-primary-500 bg-primary-50'
: 'border-slate-300 hover:border-slate-400'
}
`}
>
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-lg font-medium text-slate-700 mb-2">
ZIP-Datei oder Ordner hierher ziehen
</p>
<p className="text-sm text-slate-500 mb-4">
oder
</p>
<label className="cursor-pointer">
<span className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50">
Dateien auswählen
</span>
<input
type="file"
multiple
accept=".zip,.pdf"
onChange={handleFileSelect}
className="hidden"
/>
</label>
<p className="text-xs text-slate-400 mt-4">
Unterstützt: .zip, .pdf
</p>
</div>
{/* File Queue */}
{files.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-slate-700">Upload-Queue ({files.length})</h3>
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-slate-50 rounded-lg p-3"
>
<div className="flex items-center gap-3">
<svg className="w-5 h-5 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>
<p className="text-sm font-medium text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={() => removeFile(index)}
className="p-1 text-slate-400 hover:text-red-500"
>
<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>
))}
<button
onClick={handleUpload}
disabled={uploading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Wird hochgeladen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Hochladen & Indexieren
</>
)}
</button>
</div>
)}
</div>
)
}
// ============================================================================
// Ingestion Tab
// ============================================================================
function IngestionTab({
status,
onRefresh
}: {
status: IngestionStatus | null
onRefresh: () => void
}) {
const [starting, setStarting] = useState(false)
const startIngestion = async () => {
setStarting(true)
try {
await fetch(`${API_BASE}/api/v1/admin/nibis/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ewh_only: true }),
})
onRefresh()
} catch (err) {
console.error('Failed to start ingestion:', err)
} finally {
setStarting(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">Ingestion Status</h2>
<p className="text-sm text-slate-500">
Übersicht über laufende und vergangene Indexierungsvorgänge
</p>
</div>
<div className="flex gap-3">
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
<button
onClick={startIngestion}
disabled={status?.running || starting}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{starting ? 'Startet...' : 'Ingestion starten'}
</button>
</div>
</div>
{/* Current Status */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="flex items-center gap-4 mb-4">
{status?.running ? (
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
) : (
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
)}
<span className="text-lg font-medium text-slate-900">
{status?.running ? 'Indexierung läuft...' : 'Bereit'}
</span>
</div>
{status && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Ausführung</p>
<p className="text-lg font-semibold text-slate-900">
{status.lastRun
? new Date(status.lastRun).toLocaleString('de-DE')
: '-'
}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Dokumente</p>
<p className="text-lg font-semibold text-slate-900">
{status.documentsIndexed ?? '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Chunks</p>
<p className="text-lg font-semibold text-slate-900">
{status.chunksCreated ?? '-'}
</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Fehler</p>
<p className={`text-lg font-semibold ${status.errors.length > 0 ? 'text-red-600' : 'text-slate-900'}`}>
{status.errors.length}
</p>
</div>
</div>
)}
{status?.errors && status.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{status.errors.slice(0, 5).map((error, i) => (
<li key={i}>{error}</li>
))}
{status.errors.length > 5 && (
<li className="text-red-500">... und {status.errors.length - 5} weitere</li>
)}
</ul>
</div>
)}
</div>
</div>
)
}
// ============================================================================
// Search Tab
// ============================================================================
function SearchTab({ collections }: { collections: Collection[] }) {
const [query, setQuery] = useState('')
const [subject, setSubject] = useState<string>('')
const [year, setYear] = useState<string>('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [latency, setLatency] = useState<number | null>(null)
const [ratings, setRatings] = useState<Record<string, number>>({})
const submitRating = async (resultId: string, rating: number) => {
setRatings(prev => ({ ...prev, [resultId]: rating }))
try {
const formData = new FormData()
formData.append('result_id', resultId)
formData.append('rating', rating.toString())
await fetch(`${API_BASE}/api/v1/admin/rag/search/feedback`, {
method: 'POST',
body: formData,
})
} catch (err) {
console.error('Failed to submit rating:', err)
}
}
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
const startTime = Date.now()
try {
const res = await fetch(`${API_BASE}/api/v1/admin/nibis/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query.trim(),
subject: subject || undefined,
year: year ? parseInt(year) : undefined,
limit: 10,
}),
})
if (res.ok) {
const data = await res.json()
setResults(data)
setLatency(Date.now() - startTime)
}
} catch (err) {
console.error('Search failed:', err)
} finally {
setSearching(false)
}
}
// Get unique subjects from collections
const subjects = collections.flatMap(c => c.subjects).filter((v, i, a) => a.indexOf(v) === i).sort()
const years = collections.flatMap(c => c.years).filter((v, i, a) => a.indexOf(v) === i).sort()
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Suche & Qualitätstest</h2>
<p className="text-sm text-slate-500">
Testen Sie die semantische Suche und bewerten Sie die Ergebnisqualität
</p>
</div>
{/* Search Form */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Suchanfrage
</label>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. Analyse eines Gedichts von Rilke"
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div className="flex flex-wrap gap-4">
<div className="w-48">
<label className="block text-sm font-medium text-slate-700 mb-2">
Fach
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Fächer</option>
{subjects.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="w-32">
<label className="block text-sm font-medium text-slate-700 mb-2">
Jahr
</label>
<select
value={year}
onChange={(e) => setYear(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Jahre</option>
{years.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="flex items-end">
<button
onClick={handleSearch}
disabled={searching || !query.trim()}
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{searching ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)}
Suchen
</button>
</div>
</div>
</div>
</div>
{/* Results */}
{results.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-700">
{results.length} Ergebnisse
</h3>
{latency && (
<span className="text-sm text-slate-500">
Latenz: {latency}ms
</span>
)}
</div>
{results.map((result, index) => (
<div
key={result.id}
className="bg-white rounded-lg border border-slate-200 p-4"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<span className="text-lg font-bold text-slate-400">#{index + 1}</span>
<span className="px-2 py-1 bg-primary-100 text-primary-700 text-sm rounded font-mono">
Score: {result.score.toFixed(3)}
</span>
</div>
<div className="flex gap-2">
{result.year && (
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">
{result.year}
</span>
)}
{result.subject && (
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">
{result.subject}
</span>
)}
{result.niveau && (
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">
{result.niveau}
</span>
)}
</div>
</div>
<p className="text-sm text-slate-700 leading-relaxed">
{result.text}
</p>
{/* Rating */}
<div className="mt-4 pt-4 border-t border-slate-100 flex items-center gap-4">
<span className="text-sm text-slate-500">Relevanz:</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((starRating) => (
<button
key={starRating}
onClick={() => submitRating(result.id, starRating)}
className={`p-1 transition-colors ${
(ratings[result.id] || 0) >= starRating
? 'text-yellow-500'
: 'text-slate-300 hover:text-yellow-400'
}`}
title={`${starRating} Sterne`}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
))}
</div>
{ratings[result.id] && (
<span className="text-xs text-green-600">Bewertet</span>
)}
</div>
</div>
))}
</div>
)}
{results.length === 0 && query && !searching && (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<p className="text-slate-500">Keine Ergebnisse gefunden</p>
</div>
)}
</div>
)
}
// ============================================================================
// Metrics Tab
// ============================================================================
function MetricsTab({ collections }: { collections: Collection[] }) {
const [metrics, setMetrics] = useState({
precision: 0,
recall: 0,
mrr: 0,
avgLatency: 0,
totalRatings: 0,
errorRate: 0,
scoreDistribution: { '0.9+': 0, '0.7-0.9': 0, '0.5-0.7': 0, '<0.5': 0 },
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchMetrics = async () => {
try {
const res = await fetch(`${API_BASE}/api/v1/admin/rag/metrics`)
if (res.ok) {
const data = await res.json()
setMetrics({
precision: data.precision_at_5 || 0,
recall: data.recall_at_10 || 0,
mrr: data.mrr || 0,
avgLatency: data.avg_latency_ms || 0,
totalRatings: data.total_ratings || 0,
errorRate: data.error_rate || 0,
scoreDistribution: data.score_distribution || {},
})
}
} catch (err) {
console.error('Failed to fetch metrics:', err)
} finally {
setLoading(false)
}
}
fetchMetrics()
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">RAG Qualitätsmetriken</h2>
<p className="text-sm text-slate-500">
Übersicht über Retrieval-Performance und Nutzerbewertungen
</p>
</div>
{/* Metric Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<MetricCard
title="Precision@5"
value={`${(metrics.precision * 100).toFixed(0)}%`}
change="+5%"
positive
/>
<MetricCard
title="Recall@10"
value={`${(metrics.recall * 100).toFixed(0)}%`}
change="+3%"
positive
/>
<MetricCard
title="MRR"
value={metrics.mrr.toFixed(2)}
change="-2%"
positive={false}
/>
<MetricCard
title="Avg. Latenz"
value={`${metrics.avgLatency}ms`}
/>
<MetricCard
title="Bewertungen"
value={metrics.totalRatings.toString()}
/>
<MetricCard
title="Fehlerrate"
value={`${metrics.errorRate}%`}
/>
</div>
{/* Score Distribution */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Score-Verteilung</h3>
{loading ? (
<div className="flex justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<div className="space-y-3">
<ScoreBar label="0.9+" percent={metrics.scoreDistribution['0.9+'] || 0} color="bg-green-500" />
<ScoreBar label="0.7-0.9" percent={metrics.scoreDistribution['0.7-0.9'] || 0} color="bg-green-400" />
<ScoreBar label="0.5-0.7" percent={metrics.scoreDistribution['0.5-0.7'] || 0} color="bg-yellow-400" />
<ScoreBar label="<0.5" percent={metrics.scoreDistribution['<0.5'] || 0} color="bg-red-400" />
</div>
)}
</div>
{/* Export */}
<div className="flex gap-4">
<button className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">
Export CSV
</button>
<button className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50">
Detailbericht
</button>
</div>
</div>
)
}
function MetricCard({
title,
value,
change,
positive
}: {
title: string
value: string
change?: string
positive?: boolean
}) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-4">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<div className="flex items-end gap-2">
<p className="text-2xl font-bold text-slate-900">{value}</p>
{change && (
<span className={`text-sm font-medium ${positive ? 'text-green-600' : 'text-red-600'}`}>
{change}
</span>
)}
</div>
</div>
)
}
function ScoreBar({ label, percent, color }: { label: string; percent: number; color: string }) {
return (
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600 w-16">{label}</span>
<div className="flex-1 h-4 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${color} transition-all`}
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-sm text-slate-500 w-12 text-right">{percent}%</span>
</div>
)
}