Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
321 lines
8.7 KiB
TypeScript
321 lines
8.7 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { BookOpen, ExternalLink, Scale, ChevronDown, ChevronUp, Info } from 'lucide-react'
|
|
import {
|
|
DSFALicenseCode,
|
|
DSFA_LICENSE_LABELS,
|
|
SourceAttributionProps
|
|
} from '@/lib/sdk/types'
|
|
|
|
/**
|
|
* Get license badge color based on license type
|
|
*/
|
|
function getLicenseBadgeColor(licenseCode: DSFALicenseCode): string {
|
|
switch (licenseCode) {
|
|
case 'DL-DE-BY-2.0':
|
|
case 'DL-DE-ZERO-2.0':
|
|
return 'bg-blue-100 text-blue-700 border-blue-200'
|
|
case 'CC-BY-4.0':
|
|
return 'bg-green-100 text-green-700 border-green-200'
|
|
case 'EDPB-LICENSE':
|
|
return 'bg-purple-100 text-purple-700 border-purple-200'
|
|
case 'PUBLIC_DOMAIN':
|
|
return 'bg-gray-100 text-gray-700 border-gray-200'
|
|
case 'PROPRIETARY':
|
|
return 'bg-amber-100 text-amber-700 border-amber-200'
|
|
default:
|
|
return 'bg-slate-100 text-slate-700 border-slate-200'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get license URL based on license code
|
|
*/
|
|
function getLicenseUrl(licenseCode: DSFALicenseCode): string | null {
|
|
switch (licenseCode) {
|
|
case 'DL-DE-BY-2.0':
|
|
return 'https://www.govdata.de/dl-de/by-2-0'
|
|
case 'DL-DE-ZERO-2.0':
|
|
return 'https://www.govdata.de/dl-de/zero-2-0'
|
|
case 'CC-BY-4.0':
|
|
return 'https://creativecommons.org/licenses/by/4.0/'
|
|
case 'EDPB-LICENSE':
|
|
return 'https://edpb.europa.eu/about-edpb/legal-notice_en'
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* License badge component
|
|
*/
|
|
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
|
const licenseUrl = getLicenseUrl(licenseCode)
|
|
const colorClass = getLicenseBadgeColor(licenseCode)
|
|
const label = DSFA_LICENSE_LABELS[licenseCode] || licenseCode
|
|
|
|
if (licenseUrl) {
|
|
return (
|
|
<a
|
|
href={licenseUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass} hover:opacity-80 transition-opacity`}
|
|
>
|
|
<Scale className="w-3 h-3" />
|
|
{label}
|
|
<ExternalLink className="w-2.5 h-2.5" />
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass}`}>
|
|
<Scale className="w-3 h-3" />
|
|
{label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Single source item in the attribution list
|
|
*/
|
|
function SourceItem({
|
|
source,
|
|
index,
|
|
showScore
|
|
}: {
|
|
source: SourceAttributionProps['sources'][0]
|
|
index: number
|
|
showScore: boolean
|
|
}) {
|
|
return (
|
|
<li className="text-sm">
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-slate-400 font-mono text-xs mt-0.5 min-w-[1.5rem]">
|
|
{index + 1}.
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{source.sourceUrl ? (
|
|
<a
|
|
href={source.sourceUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 hover:text-blue-800 hover:underline font-medium truncate"
|
|
>
|
|
{source.sourceName}
|
|
</a>
|
|
) : (
|
|
<span className="text-slate-700 font-medium truncate">
|
|
{source.sourceName}
|
|
</span>
|
|
)}
|
|
{showScore && source.score !== undefined && (
|
|
<span className="text-xs text-slate-400 font-mono">
|
|
({(source.score * 100).toFixed(0)}%)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
|
|
{source.attributionText}
|
|
</p>
|
|
<div className="mt-1.5">
|
|
<LicenseBadge licenseCode={source.licenseCode} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Compact source badge for inline display
|
|
*/
|
|
function CompactSourceBadge({
|
|
source
|
|
}: {
|
|
source: SourceAttributionProps['sources'][0]
|
|
}) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600 border border-slate-200">
|
|
<BookOpen className="w-3 h-3" />
|
|
{source.sourceCode}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* SourceAttribution component - displays source/license information for DSFA RAG results
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <SourceAttribution
|
|
* sources={[
|
|
* {
|
|
* sourceCode: "WP248",
|
|
* sourceName: "WP248 rev.01 - Leitlinien zur DSFA",
|
|
* attributionText: "Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)",
|
|
* licenseCode: "EDPB-LICENSE",
|
|
* sourceUrl: "https://ec.europa.eu/newsroom/article29/items/611236/en",
|
|
* score: 0.87
|
|
* }
|
|
* ]}
|
|
* showScores
|
|
* />
|
|
* ```
|
|
*/
|
|
export function SourceAttribution({
|
|
sources,
|
|
compact = false,
|
|
showScores = false
|
|
}: SourceAttributionProps) {
|
|
const [isExpanded, setIsExpanded] = useState(!compact)
|
|
|
|
if (!sources || sources.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Compact mode - just show badges
|
|
if (compact && !isExpanded) {
|
|
return (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => setIsExpanded(true)}
|
|
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700"
|
|
>
|
|
<Info className="w-3 h-3" />
|
|
Quellen ({sources.length})
|
|
<ChevronDown className="w-3 h-3" />
|
|
</button>
|
|
{sources.slice(0, 3).map((source, i) => (
|
|
<CompactSourceBadge key={i} source={source} />
|
|
))}
|
|
{sources.length > 3 && (
|
|
<span className="text-xs text-slate-400">+{sources.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<BookOpen className="w-4 h-4" />
|
|
Quellen & Lizenzen
|
|
</h4>
|
|
{compact && (
|
|
<button
|
|
onClick={() => setIsExpanded(false)}
|
|
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1"
|
|
>
|
|
Einklappen
|
|
<ChevronUp className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<ul className="mt-3 space-y-3">
|
|
{sources.map((source, i) => (
|
|
<SourceItem
|
|
key={i}
|
|
source={source}
|
|
index={i}
|
|
showScore={showScores}
|
|
/>
|
|
))}
|
|
</ul>
|
|
|
|
{/* Aggregated license notice */}
|
|
{sources.length > 1 && (
|
|
<div className="mt-4 pt-3 border-t border-slate-200">
|
|
<p className="text-xs text-slate-500">
|
|
<strong>Hinweis:</strong> Die angezeigten Inhalte stammen aus {sources.length} verschiedenen Quellen
|
|
mit unterschiedlichen Lizenzen. Bitte beachten Sie die jeweiligen Attributionsanforderungen.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Inline source reference for use within text
|
|
*/
|
|
export function InlineSourceRef({
|
|
sourceCode,
|
|
sourceName,
|
|
sourceUrl
|
|
}: {
|
|
sourceCode: string
|
|
sourceName: string
|
|
sourceUrl?: string
|
|
}) {
|
|
if (sourceUrl) {
|
|
return (
|
|
<a
|
|
href={sourceUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-0.5 text-blue-600 hover:text-blue-800 text-sm"
|
|
title={sourceName}
|
|
>
|
|
[{sourceCode}]
|
|
<ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span className="text-slate-600 text-sm" title={sourceName}>
|
|
[{sourceCode}]
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Attribution footer for generated documents
|
|
*/
|
|
export function AttributionFooter({
|
|
sources,
|
|
generatedAt
|
|
}: {
|
|
sources: SourceAttributionProps['sources']
|
|
generatedAt?: Date
|
|
}) {
|
|
if (!sources || sources.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Group by license
|
|
const byLicense = sources.reduce((acc, source) => {
|
|
const key = source.licenseCode
|
|
if (!acc[key]) acc[key] = []
|
|
acc[key].push(source)
|
|
return acc
|
|
}, {} as Record<string, typeof sources>)
|
|
|
|
return (
|
|
<footer className="mt-8 pt-4 border-t border-slate-200 text-xs text-slate-500">
|
|
<h5 className="font-medium text-slate-600 mb-2">Quellennachweis</h5>
|
|
<ul className="space-y-1">
|
|
{Object.entries(byLicense).map(([licenseCode, licenseSources]) => (
|
|
<li key={licenseCode}>
|
|
<span className="font-medium">{DSFA_LICENSE_LABELS[licenseCode as DSFALicenseCode]}:</span>{' '}
|
|
{licenseSources.map(s => s.sourceName).join(', ')}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{generatedAt && (
|
|
<p className="mt-2 text-slate-400">
|
|
Generiert am {generatedAt.toLocaleDateString('de-DE')} um {generatedAt.toLocaleTimeString('de-DE')}
|
|
</p>
|
|
)}
|
|
</footer>
|
|
)
|
|
}
|
|
|
|
export default SourceAttribution
|