'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: ( ), }, { id: 'upload', name: 'Upload', icon: ( ), }, { id: 'ingestion', name: 'Ingestion', icon: ( ), }, { id: 'search', name: 'Suche & Test', icon: ( ), }, { id: 'metrics', name: 'Metriken', icon: ( ), }, ] // Main Component export default function RAGAdminPage() { const [activeTab, setActiveTab] = useState('collections') const [collections, setCollections] = useState([]) const [ingestionStatus, setIngestionStatus] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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 ( {/* Error Banner */} {error && (
{error}
)} {/* Tab Navigation */}
{/* Tab Content */} {activeTab === 'collections' && ( )} {activeTab === 'upload' && ( )} {activeTab === 'ingestion' && ( )} {activeTab === 'search' && ( )} {activeTab === 'metrics' && ( )}
) } // ============================================================================ // Collections Tab // ============================================================================ function CollectionsTab({ collections, loading, onRefresh }: { collections: Collection[] loading: boolean onRefresh: () => void }) { return (
{/* Header */}

RAG Sammlungen

Verwaltung der indexierten Dokumentensammlungen

{/* Loading State */} {loading && (
)} {/* Collections Grid */} {!loading && (
{collections.length === 0 ? (

Keine Sammlungen vorhanden

Starten Sie die Ingestion oder laden Sie Dokumente hoch.

) : ( collections.map((col) => ( )) )} {/* Placeholder for future collections */}
Neue Sammlung (demnächst)
)}
) } 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 (

{collection.displayName}

{collection.name}

{statusLabels[collection.status]}

Chunks

{collection.chunkCount.toLocaleString()}

Jahre

{collection.years.length > 0 ? `${Math.min(...collection.years)}-${Math.max(...collection.years)}` : '-' }

Fächer

{collection.subjects.length}

Bundesland

{collection.bundesland}

{collection.subjects.length > 0 && (
{collection.subjects.slice(0, 8).map((subject) => ( {subject} ))} {collection.subjects.length > 8 && ( +{collection.subjects.length - 8} weitere )}
)}
) } // ============================================================================ // Upload Tab // ============================================================================ function UploadTab({ onUploadComplete }: { onUploadComplete: () => void }) { const [isDragging, setIsDragging] = useState(false) const [files, setFiles] = useState([]) 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) => { 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 (

Dokumente hochladen

ZIP-Archive oder einzelne PDFs hochladen. ZIPs werden automatisch entpackt.

{/* Collection Selector */}
{/* Drop Zone */}
{ 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' } `} >

ZIP-Datei oder Ordner hierher ziehen

oder

Unterstützt: .zip, .pdf

{/* File Queue */} {files.length > 0 && (

Upload-Queue ({files.length})

{files.map((file, index) => (

{file.name}

{(file.size / 1024 / 1024).toFixed(2)} MB

))}
)}
) } // ============================================================================ // 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 (

Ingestion Status

Übersicht über laufende und vergangene Indexierungsvorgänge

{/* Current Status */}
{status?.running ? (
) : (
)} {status?.running ? 'Indexierung läuft...' : 'Bereit'}
{status && (

Letzte Ausführung

{status.lastRun ? new Date(status.lastRun).toLocaleString('de-DE') : '-' }

Dokumente

{status.documentsIndexed ?? '-'}

Chunks

{status.chunksCreated ?? '-'}

Fehler

0 ? 'text-red-600' : 'text-slate-900'}`}> {status.errors.length}

)} {status?.errors && status.errors.length > 0 && (

Fehler

    {status.errors.slice(0, 5).map((error, i) => (
  • {error}
  • ))} {status.errors.length > 5 && (
  • ... und {status.errors.length - 5} weitere
  • )}
)}
) } // ============================================================================ // Search Tab // ============================================================================ function SearchTab({ collections }: { collections: Collection[] }) { const [query, setQuery] = useState('') const [subject, setSubject] = useState('') const [year, setYear] = useState('') const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const [latency, setLatency] = useState(null) const [ratings, setRatings] = useState>({}) 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 (

RAG Suche & Qualitätstest

Testen Sie die semantische Suche und bewerten Sie die Ergebnisqualität

{/* Search Form */}
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" />
{/* Results */} {results.length > 0 && (

{results.length} Ergebnisse

{latency && ( Latenz: {latency}ms )}
{results.map((result, index) => (
#{index + 1} Score: {result.score.toFixed(3)}
{result.year && ( {result.year} )} {result.subject && ( {result.subject} )} {result.niveau && ( {result.niveau} )}

{result.text}

{/* Rating */}
Relevanz:
{[1, 2, 3, 4, 5].map((starRating) => ( ))}
{ratings[result.id] && ( Bewertet )}
))}
)} {results.length === 0 && query && !searching && (

Keine Ergebnisse gefunden

)}
) } // ============================================================================ // 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 (

RAG Qualitätsmetriken

Übersicht über Retrieval-Performance und Nutzerbewertungen

{/* Metric Cards */}
{/* Score Distribution */}

Score-Verteilung

{loading ? (
) : (
)}
{/* Export */}
) } function MetricCard({ title, value, change, positive }: { title: string value: string change?: string positive?: boolean }) { return (

{title}

{value}

{change && ( {change} )}
) } function ScoreBar({ label, percent, color }: { label: string; percent: number; color: string }) { return (
{label}
{percent}%
) }