@@ -0,0 +1,674 @@
'use client'
/**
* DSFA Document Manager
*
* Manages DSFA-related sources and documents for the RAG pipeline.
* Features:
* - View all registered DSFA sources with license info
* - Upload new documents
* - Trigger re-indexing
* - View corpus statistics
*/
import { useState , useEffect } from 'react'
import Link from 'next/link'
import {
ArrowLeft ,
RefreshCw ,
Upload ,
FileText ,
Database ,
Scale ,
ExternalLink ,
ChevronDown ,
ChevronUp ,
Search ,
Filter ,
CheckCircle ,
Clock ,
AlertCircle ,
BookOpen
} from 'lucide-react'
import {
DSFASource ,
DSFACorpusStats ,
DSFASourceStats ,
DSFALicenseCode ,
DSFA_LICENSE_LABELS ,
DSFA_DOCUMENT_TYPE_LABELS
} from '@/lib/sdk/types'
// ============================================================================
// TYPES
// ============================================================================
interface APIError {
message : string
status? : number
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
const API_BASE = process . env . NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
async function fetchSources ( ) : Promise < DSFASource [ ] > {
try {
const response = await fetch ( ` ${ API_BASE } /api/v1/dsfa-rag/sources ` )
if ( ! response . ok ) throw new Error ( 'Failed to fetch sources' )
return await response . json ( )
} catch {
// Return mock data for demo
return MOCK_SOURCES
}
}
async function fetchStats ( ) : Promise < DSFACorpusStats > {
try {
const response = await fetch ( ` ${ API_BASE } /api/v1/dsfa-rag/stats ` )
if ( ! response . ok ) throw new Error ( 'Failed to fetch stats' )
return await response . json ( )
} catch {
return MOCK_STATS
}
}
async function initializeCorpus ( ) : Promise < { sources_registered : number } > {
const response = await fetch ( ` ${ API_BASE } /api/v1/dsfa-rag/init ` , {
method : 'POST' ,
} )
if ( ! response . ok ) throw new Error ( 'Failed to initialize corpus' )
return await response . json ( )
}
async function triggerIngestion ( sourceCode : string ) : Promise < void > {
const response = await fetch ( ` ${ API_BASE } /api/v1/dsfa-rag/sources/ ${ sourceCode } /ingest ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( { } ) ,
} )
if ( ! response . ok ) throw new Error ( 'Failed to trigger ingestion' )
}
// ============================================================================
// MOCK DATA
// ============================================================================
const MOCK_SOURCES : DSFASource [ ] = [
{
id : '1' ,
sourceCode : 'WP248' ,
name : 'WP248 rev.01 - Leitlinien zur DSFA' ,
fullName : 'Leitlinien zur Datenschutz-Folgenabschaetzung' ,
organization : 'Artikel-29-Datenschutzgruppe / EDPB' ,
sourceUrl : 'https://ec.europa.eu/newsroom/article29/items/611236/en' ,
licenseCode : 'EDPB-LICENSE' ,
licenseName : 'EDPB Document License' ,
attributionRequired : true ,
attributionText : 'Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)' ,
documentType : 'guideline' ,
language : 'de' ,
} ,
{
id : '2' ,
sourceCode : 'DSK_KP5' ,
name : 'Kurzpapier Nr. 5 - DSFA nach Art. 35 DS-GVO' ,
organization : 'Datenschutzkonferenz (DSK)' ,
sourceUrl : 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf' ,
licenseCode : 'DL-DE-BY-2.0' ,
licenseName : 'Datenlizenz DE – Namensnennung 2.0' ,
licenseUrl : 'https://www.govdata.de/dl-de/by-2-0' ,
attributionRequired : true ,
attributionText : 'Quelle: DSK Kurzpapier Nr. 5 (Stand: 2018)' ,
documentType : 'guideline' ,
language : 'de' ,
} ,
{
id : '3' ,
sourceCode : 'BFDI_MUSS_PUBLIC' ,
name : 'BfDI DSFA-Liste (oeffentlicher Bereich)' ,
organization : 'BfDI' ,
sourceUrl : 'https://www.bfdi.bund.de' ,
licenseCode : 'DL-DE-ZERO-2.0' ,
licenseName : 'Datenlizenz DE – Zero 2.0' ,
attributionRequired : false ,
attributionText : 'Quelle: BfDI, Liste gem. Art. 35 Abs. 4 DSGVO' ,
documentType : 'checklist' ,
language : 'de' ,
} ,
{
id : '4' ,
sourceCode : 'NI_MUSS_PRIVATE' ,
name : 'LfD NI DSFA-Liste (nicht-oeffentlich)' ,
organization : 'LfD Niedersachsen' ,
sourceUrl : 'https://www.lfd.niedersachsen.de/download/131098' ,
licenseCode : 'DL-DE-BY-2.0' ,
licenseName : 'Datenlizenz DE – Namensnennung 2.0' ,
attributionRequired : true ,
attributionText : 'Quelle: LfD Niedersachsen, DSFA-Muss-Liste' ,
documentType : 'checklist' ,
language : 'de' ,
} ,
]
const MOCK_STATS : DSFACorpusStats = {
sources : [
{
sourceId : '1' ,
sourceCode : 'WP248' ,
name : 'WP248 rev.01' ,
organization : 'EDPB' ,
licenseCode : 'EDPB-LICENSE' ,
documentType : 'guideline' ,
documentCount : 1 ,
chunkCount : 50 ,
lastIndexedAt : '2026-02-09T10:00:00Z' ,
} ,
{
sourceId : '2' ,
sourceCode : 'DSK_KP5' ,
name : 'DSK Kurzpapier Nr. 5' ,
organization : 'DSK' ,
licenseCode : 'DL-DE-BY-2.0' ,
documentType : 'guideline' ,
documentCount : 1 ,
chunkCount : 35 ,
lastIndexedAt : '2026-02-09T10:00:00Z' ,
} ,
] ,
totalSources : 45 ,
totalDocuments : 45 ,
totalChunks : 850 ,
qdrantCollection : 'bp_dsfa_corpus' ,
qdrantPointsCount : 850 ,
qdrantStatus : 'green' ,
}
// ============================================================================
// COMPONENTS
// ============================================================================
function LicenseBadge ( { licenseCode } : { licenseCode : DSFALicenseCode } ) {
const colorMap : Record < DSFALicenseCode , string > = {
'DL-DE-BY-2.0' : 'bg-blue-100 text-blue-700 border-blue-200' ,
'DL-DE-ZERO-2.0' : 'bg-gray-100 text-gray-700 border-gray-200' ,
'CC-BY-4.0' : 'bg-green-100 text-green-700 border-green-200' ,
'EDPB-LICENSE' : 'bg-purple-100 text-purple-700 border-purple-200' ,
'PUBLIC_DOMAIN' : 'bg-gray-100 text-gray-600 border-gray-200' ,
'PROPRIETARY' : 'bg-amber-100 text-amber-700 border-amber-200' ,
}
return (
< span className = { ` inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${ colorMap [ licenseCode ] || 'bg-gray-100 text-gray-700 border-gray-200' } ` } >
< Scale className = "w-3 h-3" / >
{ DSFA_LICENSE_LABELS [ licenseCode ] || licenseCode }
< / span >
)
}
function DocumentTypeBadge ( { type } : { type ? : string } ) {
if ( ! type ) return null
const colorMap : Record < string , string > = {
guideline : 'bg-indigo-100 text-indigo-700' ,
checklist : 'bg-emerald-100 text-emerald-700' ,
regulation : 'bg-red-100 text-red-700' ,
template : 'bg-orange-100 text-orange-700' ,
}
return (
< span className = { ` inline-flex items-center px-2 py-0.5 rounded text-xs ${ colorMap [ type ] || 'bg-gray-100 text-gray-700' } ` } >
{ DSFA_DOCUMENT_TYPE_LABELS [ type as keyof typeof DSFA_DOCUMENT_TYPE_LABELS ] || type }
< / span >
)
}
function StatusIndicator ( { status } : { status : string } ) {
const statusConfig : Record < string , { color : string ; icon : React.ReactNode ; label : string } > = {
green : { color : 'text-green-500' , icon : < CheckCircle className = "w-4 h-4" / > , label : 'Aktiv' } ,
yellow : { color : 'text-yellow-500' , icon : < Clock className = "w-4 h-4" / > , label : 'Ausstehend' } ,
red : { color : 'text-red-500' , icon : < AlertCircle className = "w-4 h-4" / > , label : 'Fehler' } ,
}
const config = statusConfig [ status ] || statusConfig . yellow
return (
< span className = { ` inline-flex items-center gap-1 ${ config . color } ` } >
{ config . icon }
< span className = "text-sm" > { config . label } < / span >
< / span >
)
}
function SourceCard ( {
source ,
stats ,
onIngest ,
isIngesting
} : {
source : DSFASource
stats? : DSFASourceStats
onIngest : ( ) = > void
isIngesting : boolean
} ) {
const [ isExpanded , setIsExpanded ] = useState ( false )
return (
< div className = "bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden" >
< div className = "p-4" >
< div className = "flex items-start justify-between" >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2 mb-1" >
< span className = "font-mono text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded" >
{ source . sourceCode }
< / span >
< DocumentTypeBadge type = { source . documentType } / >
< / div >
< h3 className = "font-semibold text-gray-900 dark:text-white truncate" >
{ source . name }
< / h3 >
{ source . organization && (
< p className = "text-sm text-gray-500 dark:text-gray-400" >
{ source . organization }
< / p >
) }
< / div >
< button
onClick = { ( ) = > setIsExpanded ( ! isExpanded ) }
className = "p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{ isExpanded ? (
< ChevronUp className = "w-5 h-5 text-gray-400" / >
) : (
< ChevronDown className = "w-5 h-5 text-gray-400" / >
) }
< / button >
< / div >
< div className = "flex items-center gap-4 mt-3" >
< LicenseBadge licenseCode = { source . licenseCode } / >
{ stats && (
< >
< span className = "text-sm text-gray-500" >
{ stats . documentCount } Dok .
< / span >
< span className = "text-sm text-gray-500" >
{ stats . chunkCount } Chunks
< / span >
< / >
) }
< / div >
{ source . attributionRequired && (
< div className = "mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 rounded text-xs text-amber-700 dark:text-amber-300" >
< strong > Attribution : < / strong > { source . attributionText }
< / div >
) }
< / div >
{ isExpanded && (
< div className = "border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900" >
< dl className = "grid grid-cols-2 gap-3 text-sm" >
{ source . sourceUrl && (
< >
< dt className = "text-gray-500" > Quelle : < / dt >
< dd >
< a
href = { source . sourceUrl }
target = "_blank"
rel = "noopener noreferrer"
className = "text-blue-600 hover:underline flex items-center gap-1"
>
Link < ExternalLink className = "w-3 h-3" / >
< / a >
< / dd >
< / >
) }
{ source . licenseUrl && (
< >
< dt className = "text-gray-500" > Lizenz - URL : < / dt >
< dd >
< a
href = { source . licenseUrl }
target = "_blank"
rel = "noopener noreferrer"
className = "text-blue-600 hover:underline flex items-center gap-1"
>
{ source . licenseName } < ExternalLink className = "w-3 h-3" / >
< / a >
< / dd >
< / >
) }
< dt className = "text-gray-500" > Sprache : < / dt >
< dd className = "uppercase" > { source . language } < / dd >
{ stats ? . lastIndexedAt && (
< >
< dt className = "text-gray-500" > Zuletzt indexiert : < / dt >
< dd > { new Date ( stats . lastIndexedAt ) . toLocaleString ( 'de-DE' ) } < / dd >
< / >
) }
< / dl >
< div className = "mt-4 flex gap-2" >
< button
onClick = { onIngest }
disabled = { isIngesting }
className = "px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{ isIngesting ? (
< RefreshCw className = "w-4 h-4 animate-spin" / >
) : (
< RefreshCw className = "w-4 h-4" / >
) }
Neu indexieren
< / button >
< / div >
< / div >
) }
< / div >
)
}
function StatsOverview ( { stats } : { stats : DSFACorpusStats } ) {
return (
< div className = "bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6" >
< div className = "flex items-center justify-between mb-4" >
< h2 className = "text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2" >
< Database className = "w-5 h-5" / >
Corpus - Statistik
< / h2 >
< StatusIndicator status = { stats . qdrantStatus } / >
< / div >
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4" >
< div className = "text-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg" >
< p className = "text-2xl font-bold text-blue-600 dark:text-blue-400" >
{ stats . totalSources }
< / p >
< p className = "text-sm text-gray-600 dark:text-gray-400" > Quellen < / p >
< / div >
< div className = "text-center p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg" >
< p className = "text-2xl font-bold text-emerald-600 dark:text-emerald-400" >
{ stats . totalDocuments }
< / p >
< p className = "text-sm text-gray-600 dark:text-gray-400" > Dokumente < / p >
< / div >
< div className = "text-center p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg" >
< p className = "text-2xl font-bold text-purple-600 dark:text-purple-400" >
{ stats . totalChunks . toLocaleString ( ) }
< / p >
< p className = "text-sm text-gray-600 dark:text-gray-400" > Chunks < / p >
< / div >
< div className = "text-center p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg" >
< p className = "text-2xl font-bold text-orange-600 dark:text-orange-400" >
{ stats . qdrantPointsCount . toLocaleString ( ) }
< / p >
< p className = "text-sm text-gray-600 dark:text-gray-400" > Vektoren < / p >
< / div >
< / div >
< div className = "mt-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg" >
< p className = "text-sm text-gray-600 dark:text-gray-400" >
< strong > Collection : < / strong > { ' ' }
< code className = "font-mono bg-gray-200 dark:bg-gray-700 px-1 rounded" >
{ stats . qdrantCollection }
< / code >
< / p >
< / div >
< / div >
)
}
// ============================================================================
// MAIN PAGE
// ============================================================================
export default function DSFADocumentManagerPage() {
const [ sources , setSources ] = useState < DSFASource [ ] > ( [ ] )
const [ stats , setStats ] = useState < DSFACorpusStats | null > ( null )
const [ isLoading , setIsLoading ] = useState ( true )
const [ error , setError ] = useState < string | null > ( null )
const [ searchQuery , setSearchQuery ] = useState ( '' )
const [ filterType , setFilterType ] = useState < string > ( 'all' )
const [ ingestingSource , setIngestingSource ] = useState < string | null > ( null )
const [ isInitializing , setIsInitializing ] = useState ( false )
useEffect ( ( ) = > {
async function loadData() {
setIsLoading ( true )
try {
const [ sourcesData , statsData ] = await Promise . all ( [
fetchSources ( ) ,
fetchStats ( ) ,
] )
setSources ( sourcesData )
setStats ( statsData )
setError ( null )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Failed to load data' )
setSources ( MOCK_SOURCES )
setStats ( MOCK_STATS )
} finally {
setIsLoading ( false )
}
}
loadData ( )
} , [ ] )
const handleInitialize = async ( ) = > {
setIsInitializing ( true )
try {
await initializeCorpus ( )
// Reload data
const [ sourcesData , statsData ] = await Promise . all ( [
fetchSources ( ) ,
fetchStats ( ) ,
] )
setSources ( sourcesData )
setStats ( statsData )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Failed to initialize' )
} finally {
setIsInitializing ( false )
}
}
const handleIngest = async ( sourceCode : string ) = > {
setIngestingSource ( sourceCode )
try {
await triggerIngestion ( sourceCode )
// Reload stats
const statsData = await fetchStats ( )
setStats ( statsData )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Failed to ingest' )
} finally {
setIngestingSource ( null )
}
}
// Filter sources
const filteredSources = sources . filter ( source = > {
const matchesSearch = searchQuery === '' ||
source . name . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) ) ||
source . sourceCode . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) ) ||
source . organization ? . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) )
const matchesType = filterType === 'all' || source . documentType === filterType
return matchesSearch && matchesType
} )
// Get stats by source code
const getStatsForSource = ( sourceCode : string ) : DSFASourceStats | undefined = > {
return stats ? . sources . find ( s = > s . sourceCode === sourceCode )
}
return (
< div className = "min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-8" >
< div className = "max-w-6xl mx-auto" >
{ /* Header */ }
< div className = "mb-8" >
< Link
href = "/ai/rag-pipeline"
className = "inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4"
>
< ArrowLeft className = "w-4 h-4" / >
Zurueck zur RAG - Pipeline
< / Link >
< div className = "flex justify-between items-start" >
< div >
< h1 className = "text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3" >
< BookOpen className = "w-8 h-8 text-blue-600" / >
DSFA - Quellen Manager
< / h1 >
< p className = "text-gray-500 dark:text-gray-400 mt-1" >
Verwalten Sie DSFA - Guidance Dokumente mit vollstaendiger Lizenzattribution
< / p >
< / div >
< div className = "flex gap-2" >
< button
onClick = { handleInitialize }
disabled = { isInitializing }
className = "px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{ isInitializing ? (
< RefreshCw className = "w-4 h-4 animate-spin" / >
) : (
< Database className = "w-4 h-4" / >
) }
Initialisieren
< / button >
< button className = "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2" >
< Upload className = "w-4 h-4" / >
Dokument hochladen
< / button >
< / div >
< / div >
< / div >
{ /* Error Banner */ }
{ error && (
< div className = "mb-6 p-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-xl" >
< div className = "flex items-center gap-3" >
< AlertCircle className = "w-5 h-5 text-red-500" / >
< span className = "text-red-800 dark:text-red-200" > { error } < / span >
< button
onClick = { ( ) = > setError ( null ) }
className = "ml-auto text-red-600 hover:text-red-800"
>
& times ;
< / button >
< / div >
< / div >
) }
{ /* Stats Overview */ }
{ stats && < StatsOverview stats = { stats } / > }
{ /* Search & Filter */ }
< div className = "mt-6 flex gap-4" >
< div className = "flex-1 relative" >
< Search className = "absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" / >
< input
type = "text"
placeholder = "Quellen durchsuchen..."
value = { searchQuery }
onChange = { ( e ) = > setSearchQuery ( e . target . value ) }
className = "w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
/ >
< / div >
< div className = "relative" >
< Filter className = "absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" / >
< select
value = { filterType }
onChange = { ( e ) = > setFilterType ( e . target . value ) }
className = "pl-9 pr-8 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 appearance-none"
>
< option value = "all" > Alle Typen < / option >
< option value = "guideline" > Leitlinien < / option >
< option value = "checklist" > Prueflisten < / option >
< option value = "regulation" > Verordnungen < / option >
< / select >
< / div >
< / div >
{ /* Sources List */ }
< div className = "mt-6" >
< div className = "flex items-center justify-between mb-4" >
< h2 className = "text-lg font-semibold text-gray-900 dark:text-white" >
Registrierte Quellen ( { filteredSources . length } )
< / h2 >
< / div >
{ isLoading ? (
< div className = "flex items-center justify-center py-20" >
< div className = "text-center" >
< div className = "w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mx-auto mb-4" / >
< p className = "text-gray-500 dark:text-gray-400" > Lade Quellen . . . < / p >
< / div >
< / div >
) : filteredSources . length === 0 ? (
< div className = "text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700" >
< FileText className = "w-12 h-12 text-gray-400 mx-auto mb-4" / >
< p className = "text-gray-500 dark:text-gray-400" >
{ searchQuery || filterType !== 'all'
? 'Keine Quellen gefunden'
: 'Noch keine Quellen registriert' }
< / p >
{ ! searchQuery && filterType === 'all' && (
< button
onClick = { handleInitialize }
className = "mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Quellen initialisieren
< / button >
) }
< / div >
) : (
< div className = "grid gap-4" >
{ filteredSources . map ( source = > (
< SourceCard
key = { source . id }
source = { source }
stats = { getStatsForSource ( source . sourceCode ) }
onIngest = { ( ) = > handleIngest ( source . sourceCode ) }
isIngesting = { ingestingSource === source . sourceCode }
/ >
) ) }
< / div >
) }
< / div >
{ /* Info Box */ }
< div className = "mt-8 p-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl" >
< h3 className = "font-semibold text-blue-900 dark:text-blue-100 mb-2" >
Ueber die Lizenzattribution
< / h3 >
< p className = "text-sm text-blue-800 dark:text-blue-200 mb-4" >
Alle DSFA - Quellen werden mit vollstaendiger Lizenzinformation gespeichert .
Bei der Nutzung der RAG - Suche werden automatisch die korrekten Attributionen angezeigt .
< / p >
< div className = "grid grid-cols-2 md:grid-cols-3 gap-3 text-sm" >
< div className = "flex items-center gap-2" >
< LicenseBadge licenseCode = "DL-DE-BY-2.0" / >
< span className = "text-blue-700 dark:text-blue-300" > Namensnennung < / span >
< / div >
< div className = "flex items-center gap-2" >
< LicenseBadge licenseCode = "DL-DE-ZERO-2.0" / >
< span className = "text-blue-700 dark:text-blue-300" > Keine Attribution < / span >
< / div >
< div className = "flex items-center gap-2" >
< LicenseBadge licenseCode = "CC-BY-4.0" / >
< span className = "text-blue-700 dark:text-blue-300" > CC Attribution < / span >
< / div >
< / div >
< / div >
< / div >
< / div >
)
}