Files
breakpilot-compliance/admin-compliance/components/sdk/dsfa/SourceAttribution.tsx
Benjamin Boenisch 4435e7ea0a Initial commit: breakpilot-compliance - Compliance SDK Platform
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>
2026-02-11 23:47:28 +01:00

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