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 39s
CI/CD / test-python-backend-compliance (push) Successful in 32s
CI/CD / test-python-document-crawler (push) Successful in 20s
CI/CD / test-python-dsms-gateway (push) Successful in 16s
CI/CD / validate-canonical-controls (push) Successful in 9s
CI/CD / Deploy (push) Successful in 4s
863 v1-Controls (manuell geschrieben, ohne Rechtsgrundlage) werden als "Eigenentwicklung" gekennzeichnet und automatisch mit regulatorischen Controls (DSGVO, NIS2, OWASP etc.) per Embedding-Similarity abgeglichen. Backend: - Migration 080: v1_control_matches Tabelle (Cross-Reference) - v1_enrichment.py: Batch-Matching via BGE-M3 + Qdrant (Threshold 0.75) - 3 neue API-Endpoints: enrich-v1-matches, v1-matches, v1-enrichment-stats - 6 Tests (dry-run, execution, matches, pagination, detection) Frontend: - Orange "Eigenentwicklung"-Badge statt grauem "v1" (wenn kein Source) - "Regulatorische Abdeckung"-Sektion im ControlDetail mit Match-Karten - Side-by-Side V1CompareView (Eigenentwicklung vs. regulatorisch gedeckt) - Prev/Next Navigation durch alle Matches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import {
|
|
ArrowLeft, CheckCircle2, Trash2, Pencil, SkipForward,
|
|
ChevronLeft, Scale, BookOpen, ExternalLink, AlertTriangle,
|
|
FileText, Clock,
|
|
} from 'lucide-react'
|
|
import {
|
|
CanonicalControl, BACKEND_URL,
|
|
SeverityBadge, StateBadge, LicenseRuleBadge, CategoryBadge, TargetAudienceBadge,
|
|
} from './helpers'
|
|
|
|
// =============================================================================
|
|
// Compact Control Panel (used on both sides of the comparison)
|
|
// =============================================================================
|
|
|
|
export function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
|
return (
|
|
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
|
|
{/* Panel Header */}
|
|
<div className={`sticky top-0 z-10 px-4 py-3 border-b ${highlight ? 'bg-yellow-100 border-yellow-200' : 'bg-gray-50 border-gray-200'}`}>
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{label}</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
|
<SeverityBadge severity={ctrl.severity} />
|
|
<StateBadge state={ctrl.release_state} />
|
|
<LicenseRuleBadge rule={ctrl.license_rule} />
|
|
<CategoryBadge category={ctrl.category} />
|
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-gray-900 mt-1 leading-snug">{ctrl.title}</h3>
|
|
</div>
|
|
|
|
{/* Panel Content */}
|
|
<div className="p-4 space-y-4 text-sm">
|
|
{/* Objective */}
|
|
<section>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ziel</h4>
|
|
<p className="text-gray-700 leading-relaxed">{ctrl.objective}</p>
|
|
</section>
|
|
|
|
{/* Rationale */}
|
|
{ctrl.rationale && (
|
|
<section>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Begruendung</h4>
|
|
<p className="text-gray-700 leading-relaxed">{ctrl.rationale}</p>
|
|
</section>
|
|
)}
|
|
|
|
{/* Source Citation (Rule 1+2) */}
|
|
{ctrl.source_citation && (
|
|
<section className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
|
<span className="text-xs font-semibold text-blue-900">Gesetzliche Grundlage</span>
|
|
</div>
|
|
{ctrl.source_citation.source && (
|
|
<p className="text-xs text-blue-800">
|
|
{ctrl.source_citation.source}
|
|
{ctrl.source_citation.article && ` — ${ctrl.source_citation.article}`}
|
|
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Requirements */}
|
|
{ctrl.requirements.length > 0 && (
|
|
<section>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Anforderungen</h4>
|
|
<ol className="list-decimal list-inside space-y-1">
|
|
{ctrl.requirements.map((r, i) => (
|
|
<li key={i} className="text-gray-700 text-xs leading-relaxed">{r}</li>
|
|
))}
|
|
</ol>
|
|
</section>
|
|
)}
|
|
|
|
{/* Test Procedure */}
|
|
{ctrl.test_procedure.length > 0 && (
|
|
<section>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Pruefverfahren</h4>
|
|
<ol className="list-decimal list-inside space-y-1">
|
|
{ctrl.test_procedure.map((s, i) => (
|
|
<li key={i} className="text-gray-700 text-xs leading-relaxed">{s}</li>
|
|
))}
|
|
</ol>
|
|
</section>
|
|
)}
|
|
|
|
{/* Open Anchors */}
|
|
{ctrl.open_anchors.length > 0 && (
|
|
<section className="bg-green-50 border border-green-200 rounded-lg p-3">
|
|
<div className="flex items-center gap-1.5 mb-2">
|
|
<BookOpen className="w-3.5 h-3.5 text-green-700" />
|
|
<span className="text-xs font-semibold text-green-900">Referenzen ({ctrl.open_anchors.length})</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{ctrl.open_anchors.map((a, i) => (
|
|
<div key={i} className="flex items-center gap-1.5 text-xs">
|
|
<ExternalLink className="w-3 h-3 text-green-600 flex-shrink-0" />
|
|
<span className="font-medium text-green-800">{a.framework}</span>
|
|
<span className="text-green-700">{a.ref}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Tags */}
|
|
{ctrl.tags.length > 0 && (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{ctrl.tags.map(t => (
|
|
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ReviewCompare — Side-by-Side Duplicate Comparison
|
|
// =============================================================================
|
|
|
|
interface ReviewCompareProps {
|
|
ctrl: CanonicalControl
|
|
onBack: () => void
|
|
onReview: (controlId: string, action: string) => void
|
|
onEdit: () => void
|
|
reviewIndex: number
|
|
reviewTotal: number
|
|
onReviewPrev: () => void
|
|
onReviewNext: () => void
|
|
}
|
|
|
|
export function ReviewCompare({
|
|
ctrl,
|
|
onBack,
|
|
onReview,
|
|
onEdit,
|
|
reviewIndex,
|
|
reviewTotal,
|
|
onReviewPrev,
|
|
onReviewNext,
|
|
}: ReviewCompareProps) {
|
|
const [suspectedDuplicate, setSuspectedDuplicate] = useState<CanonicalControl | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [similarity, setSimilarity] = useState<number | null>(null)
|
|
|
|
// Load the suspected duplicate from generation_metadata.similar_controls
|
|
useEffect(() => {
|
|
const loadDuplicate = async () => {
|
|
const similarControls = ctrl.generation_metadata?.similar_controls as Array<{ control_id: string; title: string; similarity: number }> | undefined
|
|
if (!similarControls || similarControls.length === 0) {
|
|
setSuspectedDuplicate(null)
|
|
setSimilarity(null)
|
|
return
|
|
}
|
|
|
|
const suspect = similarControls[0]
|
|
setSimilarity(suspect.similarity)
|
|
setLoading(true)
|
|
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(suspect.control_id)}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSuspectedDuplicate(data)
|
|
} else {
|
|
setSuspectedDuplicate(null)
|
|
}
|
|
} catch {
|
|
setSuspectedDuplicate(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
loadDuplicate()
|
|
}, [ctrl.control_id, ctrl.generation_metadata])
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</button>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
|
<span className="text-sm font-semibold text-gray-900">Duplikat-Vergleich</span>
|
|
{similarity !== null && (
|
|
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
|
|
{(similarity * 100).toFixed(1)}% Aehnlichkeit
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Navigation */}
|
|
<div className="flex items-center gap-1 mr-3">
|
|
<button onClick={onReviewPrev} disabled={reviewIndex === 0} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<span className="text-xs text-gray-500 font-medium">{reviewIndex + 1} / {reviewTotal}</span>
|
|
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
|
|
<SkipForward className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<button
|
|
onClick={() => onReview(ctrl.control_id, 'approve')}
|
|
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
|
>
|
|
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />Behalten
|
|
</button>
|
|
<button
|
|
onClick={() => onReview(ctrl.control_id, 'reject')}
|
|
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Duplikat
|
|
</button>
|
|
<button
|
|
onClick={onEdit}
|
|
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5 inline mr-1" />Bearbeiten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Side-by-Side Panels */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Left: Control to review */}
|
|
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
|
<ControlPanel ctrl={ctrl} label="Zu pruefen" highlight />
|
|
</div>
|
|
|
|
{/* Right: Suspected duplicate */}
|
|
<div className="w-1/2 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
|
</div>
|
|
) : suspectedDuplicate ? (
|
|
<ControlPanel ctrl={suspectedDuplicate} label="Bestehendes Control (Verdacht)" />
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
|
Kein Duplikat-Kandidat gefunden
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|