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>
224 lines
7.1 KiB
TypeScript
224 lines
7.1 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* AehnlicheDokumente - RAG-based similar documents panel
|
|
* Shows documents with similar content based on vector similarity
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
|
|
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
|
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
|
|
import { FAECHER } from '@/lib/education/abitur-docs-types'
|
|
|
|
interface AehnlicheDokumenteProps {
|
|
documentId: string
|
|
onSelectDocument: (doc: AbiturDokument) => void
|
|
limit?: number
|
|
}
|
|
|
|
export function AehnlicheDokumente({
|
|
documentId,
|
|
onSelectDocument,
|
|
limit = 5
|
|
}: AehnlicheDokumenteProps) {
|
|
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
const fetchSimilarDocuments = async () => {
|
|
if (!documentId) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
|
|
|
|
if (!res.ok) {
|
|
// Use mock data if endpoint not available
|
|
setSimilarDocs(getMockSimilarDocuments(documentId))
|
|
return
|
|
}
|
|
|
|
const data = await res.json()
|
|
setSimilarDocs(data.similar || [])
|
|
} catch (err) {
|
|
console.log('Similar docs fetch failed, using mock data')
|
|
setSimilarDocs(getMockSimilarDocuments(documentId))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
fetchSimilarDocuments()
|
|
}, [documentId, limit])
|
|
|
|
const handleRefresh = () => {
|
|
setLoading(true)
|
|
// Re-trigger the effect
|
|
setSimilarDocs([])
|
|
setTimeout(() => {
|
|
setSimilarDocs(getMockSimilarDocuments(documentId))
|
|
setLoading(false)
|
|
}, 500)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-8">
|
|
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
|
|
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="text-center py-8">
|
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
|
<p className="text-sm text-red-600 mb-3">{error}</p>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (similarDocs.length === 0) {
|
|
return (
|
|
<div className="text-center py-8">
|
|
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
|
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
|
title="Aktualisieren"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{similarDocs.map((doc) => (
|
|
<SimilarDocumentCard
|
|
key={doc.id}
|
|
document={doc}
|
|
onSelect={() => {
|
|
// Convert SimilarDocument to AbiturDokument for selection
|
|
// In production, this would fetch the full document
|
|
onSelectDocument(doc as unknown as AbiturDokument)
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-400 text-center pt-2">
|
|
Basierend auf semantischer Aehnlichkeit (RAG)
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SimilarDocumentCard({
|
|
document,
|
|
onSelect
|
|
}: {
|
|
document: SimilarDocument
|
|
onSelect: () => void
|
|
}) {
|
|
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
|
const similarityPercent = Math.round(document.similarity_score * 100)
|
|
|
|
return (
|
|
<button
|
|
onClick={onSelect}
|
|
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
|
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
|
|
>
|
|
{/* Icon */}
|
|
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
|
|
group-hover:bg-blue-100 transition-colors">
|
|
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
|
|
{fachLabel} {document.jahr}
|
|
</div>
|
|
<div className="text-sm text-slate-500 flex items-center gap-2">
|
|
<span>{document.niveau}</span>
|
|
<span>|</span>
|
|
<span>Aufgabe {document.aufgaben_nummer}</span>
|
|
{document.typ === 'erwartungshorizont' && (
|
|
<>
|
|
<span>|</span>
|
|
<span className="text-orange-600">EWH</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Similarity Score */}
|
|
<div className="flex-shrink-0">
|
|
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
similarityPercent >= 80
|
|
? 'bg-green-100 text-green-700'
|
|
: similarityPercent >= 60
|
|
? 'bg-yellow-100 text-yellow-700'
|
|
: 'bg-slate-100 text-slate-600'
|
|
}`}>
|
|
{similarityPercent}%
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// Mock data generator for development
|
|
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
|
|
// Generate consistent mock data based on document ID
|
|
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
|
|
const faecher = ['deutsch', 'englisch']
|
|
const jahre = [2021, 2022, 2023, 2024, 2025]
|
|
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
|
const nummern = ['I', 'II', 'III']
|
|
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
|
|
|
const docs: SimilarDocument[] = []
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
|
|
docs.push({
|
|
id: `similar-${documentId}-${i}`,
|
|
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
|
|
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
|
|
fach: faecher[idx % faecher.length],
|
|
jahr: jahre[(idx + i) % jahre.length],
|
|
niveau: niveaus[idx % niveaus.length],
|
|
typ: typen[(idx + i) % typen.length],
|
|
aufgaben_nummer: nummern[(idx + i) % nummern.length]
|
|
})
|
|
}
|
|
|
|
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
|
|
}
|