feat: Control-Detail Provenance + Atomare Controls Seite
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 41s
CI/CD / test-python-backend-compliance (push) Successful in 40s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 4s
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 41s
CI/CD / test-python-backend-compliance (push) Successful in 40s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 4s
Backend: provenance endpoint (obligations, doc refs, merged duplicates, regulations summary) + atomic-stats aggregation endpoint. Frontend: ControlDetail mit Provenance-Sektionen, klickbare Navigation, neue /sdk/atomic-controls Seite mit Stats-Bar und gefilterer Liste. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,9 @@ import {
|
||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
||||
ObligationTypeBadge, GenerationStrategyBadge,
|
||||
ExtractionMethodBadge, RegulationCountBadge,
|
||||
VERIFICATION_METHODS, CATEGORY_OPTIONS,
|
||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||
} from './helpers'
|
||||
|
||||
interface SimilarControl {
|
||||
@@ -54,6 +56,13 @@ interface TraceabilityData {
|
||||
decomposition_method: string
|
||||
}>
|
||||
source_count: number
|
||||
// Extended provenance fields
|
||||
obligations?: ObligationInfo[]
|
||||
obligation_count?: number
|
||||
document_references?: DocumentReference[]
|
||||
merged_duplicates?: MergedDuplicate[]
|
||||
merged_duplicates_count?: number
|
||||
regulations_summary?: RegulationSummary[]
|
||||
}
|
||||
|
||||
interface ControlDetailProps {
|
||||
@@ -63,6 +72,7 @@ interface ControlDetailProps {
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
onRefresh?: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
// Review mode navigation
|
||||
reviewMode?: boolean
|
||||
reviewIndex?: number
|
||||
@@ -78,6 +88,7 @@ export function ControlDetail({
|
||||
onDelete,
|
||||
onReview,
|
||||
onRefresh,
|
||||
onNavigateToControl,
|
||||
reviewMode,
|
||||
reviewIndex = 0,
|
||||
reviewTotal = 0,
|
||||
@@ -94,7 +105,11 @@ export function ControlDetail({
|
||||
const loadTraceability = useCallback(async () => {
|
||||
setLoadingTrace(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
// Try provenance first (extended data), fall back to traceability
|
||||
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
|
||||
if (!res.ok) {
|
||||
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
|
||||
}
|
||||
if (res.ok) {
|
||||
setTraceability(await res.json())
|
||||
}
|
||||
@@ -296,6 +311,11 @@ export function ControlDetail({
|
||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||
</h3>
|
||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||
{traceability.regulations_summary && traceability.regulations_summary.map(rs => (
|
||||
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
|
||||
{rs.regulation_code}
|
||||
</span>
|
||||
))}
|
||||
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -329,9 +349,18 @@ export function ControlDetail({
|
||||
</div>
|
||||
<p className="text-xs text-violet-600 mt-1">
|
||||
via{' '}
|
||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
||||
{link.parent_control_id}
|
||||
</span>
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(link.parent_control_id)}
|
||||
className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{link.parent_control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
||||
{link.parent_control_id}
|
||||
</span>
|
||||
)}
|
||||
{link.parent_title && (
|
||||
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
||||
)}
|
||||
@@ -378,6 +407,100 @@ export function ControlDetail({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Document References (atomic controls) */}
|
||||
{traceability && traceability.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
|
||||
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-4 h-4 text-indigo-600" />
|
||||
<h3 className="text-sm font-semibold text-indigo-900">
|
||||
Original-Dokumente ({traceability.document_references.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.document_references.map((dr, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
|
||||
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
|
||||
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
|
||||
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<ExtractionMethodBadge method={dr.extraction_method} />
|
||||
{dr.confidence !== null && (
|
||||
<span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Obligations (rich controls) */}
|
||||
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Scale className="w-4 h-4 text-amber-600" />
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{traceability.obligations.map((ob) => (
|
||||
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
|
||||
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{ob.normative_strength === 'must' ? 'MUSS' :
|
||||
ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
|
||||
</span>
|
||||
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
|
||||
{ob.object && <span className="text-xs text-amber-500">→ {ob.object}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
{ob.obligation_text.slice(0, 300)}
|
||||
{ob.obligation_text.length > 300 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Merged Duplicates */}
|
||||
{traceability && traceability.merged_duplicates && traceability.merged_duplicates.length > 0 && (
|
||||
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitMerge className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{traceability.merged_duplicates.map((dup) => (
|
||||
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(dup.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{dup.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{dup.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
|
||||
{dup.source_regulation && (
|
||||
<span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Child controls (rich controls that have atomic children) */}
|
||||
{traceability && traceability.children.length > 0 && (
|
||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
@@ -390,7 +513,16 @@ export function ControlDetail({
|
||||
<div className="space-y-1.5">
|
||||
{traceability.children.map((child) => (
|
||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
|
||||
{onNavigateToControl ? (
|
||||
<button
|
||||
onClick={() => onNavigateToControl(child.control_id)}
|
||||
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline"
|
||||
>
|
||||
{child.control_id}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
|
||||
)}
|
||||
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||
<SeverityBadge severity={child.severity} />
|
||||
</div>
|
||||
|
||||
@@ -304,3 +304,61 @@ export function ObligationTypeBadge({ type }: { type: string | null | undefined
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationInfo {
|
||||
candidate_id: string
|
||||
obligation_text: string
|
||||
action: string | null
|
||||
object: string | null
|
||||
normative_strength: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export interface DocumentReference {
|
||||
regulation_code: string
|
||||
article: string | null
|
||||
paragraph: string | null
|
||||
extraction_method: string
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface MergedDuplicate {
|
||||
control_id: string
|
||||
title: string
|
||||
source_regulation: string | null
|
||||
}
|
||||
|
||||
export interface RegulationSummary {
|
||||
regulation_code: string
|
||||
articles: string[]
|
||||
link_types: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROVENANCE BADGES
|
||||
// =============================================================================
|
||||
|
||||
const EXTRACTION_METHOD_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||
exact_match: { bg: 'bg-green-100 text-green-700', label: 'Exakt' },
|
||||
embedding_match: { bg: 'bg-blue-100 text-blue-700', label: 'Embedding' },
|
||||
llm_extracted: { bg: 'bg-violet-100 text-violet-700', label: 'LLM' },
|
||||
inferred: { bg: 'bg-gray-100 text-gray-600', label: 'Abgeleitet' },
|
||||
}
|
||||
|
||||
export function ExtractionMethodBadge({ method }: { method: string }) {
|
||||
const config = EXTRACTION_METHOD_CONFIG[method] || EXTRACTION_METHOD_CONFIG.inferred
|
||||
return <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
}
|
||||
|
||||
export function RegulationCountBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">
|
||||
{count} {count === 1 ? 'Regulierung' : 'Regulierungen'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user