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>
1064 lines
38 KiB
TypeScript
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>
|
|
)
|
|
}
|