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>
142 lines
7.4 KiB
TypeScript
142 lines
7.4 KiB
TypeScript
'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>
|
|
)
|
|
}
|