[split-required] Split website + studio-v2 monoliths (Phase 3 continued)
Website (14 monoliths split): - compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20) - quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11) - i18n.ts (1,173 → 8 language files) - unity-bridge (1,094 → 12), backlog (1,087 → 6) - training (1,066 → 8), rag (1,063 → 8) - Deleted index_original.ts (4,899 LOC dead backup) Studio-v2 (5 monoliths split): - meet/page.tsx (1,481 → 9), messages (1,166 → 9) - AlertsB2BContext.tsx (1,165 → 5 modules) - alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6) All existing imports preserved. Zero new TypeScript errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
website/app/admin/rag/_components/CollectionsTab.tsx
Normal file
123
website/app/admin/rag/_components/CollectionsTab.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import type { Collection } from './types'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollectionsTab({
|
||||
collections,
|
||||
loading,
|
||||
onRefresh,
|
||||
}: {
|
||||
collections: Collection[]
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!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} />)
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
98
website/app/admin/rag/_components/IngestionTab.tsx
Normal file
98
website/app/admin/rag/_components/IngestionTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { IngestionStatus } from './types'
|
||||
import { API_BASE } from './types'
|
||||
|
||||
export 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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
111
website/app/admin/rag/_components/MetricsTab.tsx
Normal file
111
website/app/admin/rag/_components/MetricsTab.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Collection } from './types'
|
||||
import { API_BASE } from './types'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
141
website/app/admin/rag/_components/SearchTab.tsx
Normal file
141
website/app/admin/rag/_components/SearchTab.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Collection, SearchResult } from './types'
|
||||
import { API_BASE } from './types'
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
126
website/app/admin/rag/_components/UploadTab.tsx
Normal file
126
website/app/admin/rag/_components/UploadTab.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { API_BASE } from './types'
|
||||
|
||||
export 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) {
|
||||
setFiles(prev => [...prev, ...Array.from(e.target.files!)])
|
||||
}
|
||||
}, [])
|
||||
|
||||
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')
|
||||
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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
49
website/app/admin/rag/_components/tabs.tsx
Normal file
49
website/app/admin/rag/_components/tabs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { TabId } from './types'
|
||||
|
||||
export 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>
|
||||
),
|
||||
},
|
||||
]
|
||||
35
website/app/admin/rag/_components/types.ts
Normal file
35
website/app/admin/rag/_components/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// API Base URL for klausur-service
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
export interface Collection {
|
||||
name: string
|
||||
displayName: string
|
||||
bundesland: string
|
||||
useCase: string
|
||||
documentCount: number
|
||||
chunkCount: number
|
||||
years: number[]
|
||||
subjects: string[]
|
||||
status: 'ready' | 'indexing' | 'empty'
|
||||
}
|
||||
|
||||
export interface IngestionStatus {
|
||||
running: boolean
|
||||
lastRun: string | null
|
||||
documentsIndexed: number | null
|
||||
chunksCreated: number | null
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
score: number
|
||||
text: string
|
||||
year: number | null
|
||||
subject: string | null
|
||||
niveau: string | null
|
||||
taskNumber: number | null
|
||||
}
|
||||
|
||||
// Tab definitions
|
||||
export type TabId = 'collections' | 'upload' | 'ingestion' | 'search' | 'metrics'
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user