feat(qa): recital detection, review split, duplicate comparison
Some checks failed
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) Failing after 42s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Some checks failed
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) Failing after 42s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Add _detect_recital() to QA pipeline — flags controls where source_original_text contains Erwägungsgrund markers instead of article text (28% of controls with source text affected). - Recital detection via regex + phrase matching in QA validation - 10 new tests (TestRecitalDetection), 81 total - ReviewCompare component for side-by-side duplicate comparison - Review mode split: Duplikat-Verdacht vs Rule-3-ohne-Anchor tabs - MkDocs: recital detection documentation - Detection script for bulk analysis (scripts/find_recital_controls.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
'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)
|
||||
// =============================================================================
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './components/helpers'
|
||||
import { ControlForm } from './components/ControlForm'
|
||||
import { ControlDetail } from './components/ControlDetail'
|
||||
import { ReviewCompare } from './components/ReviewCompare'
|
||||
import { GeneratorModal } from './components/GeneratorModal'
|
||||
|
||||
// =============================================================================
|
||||
@@ -71,6 +72,9 @@ export default function ControlLibraryPage() {
|
||||
const [reviewIndex, setReviewIndex] = useState(0)
|
||||
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
|
||||
const [reviewCount, setReviewCount] = useState(0)
|
||||
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
|
||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||
|
||||
// Debounce search
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -303,20 +307,47 @@ export default function ControlLibraryPage() {
|
||||
const enterReviewMode = async () => {
|
||||
// Load review items from backend
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=200`)
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||
if (res.ok) {
|
||||
const items = await res.json()
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
setReviewItems(items)
|
||||
// Split into duplicate suspects vs rule 3 without anchor
|
||||
const dupes = items.filter(c =>
|
||||
c.generation_metadata?.similar_controls &&
|
||||
Array.isArray(c.generation_metadata.similar_controls) &&
|
||||
(c.generation_metadata.similar_controls as unknown[]).length > 0
|
||||
)
|
||||
const rule3 = items.filter(c =>
|
||||
!c.generation_metadata?.similar_controls ||
|
||||
!Array.isArray(c.generation_metadata.similar_controls) ||
|
||||
(c.generation_metadata.similar_controls as unknown[]).length === 0
|
||||
)
|
||||
setReviewDuplicates(dupes)
|
||||
setReviewRule3(rule3)
|
||||
// Start with duplicates tab if any, otherwise rule3
|
||||
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
|
||||
const startItems = startTab === 'duplicates' ? dupes : rule3
|
||||
setReviewTab(startTab)
|
||||
setReviewItems(startItems)
|
||||
setReviewMode(true)
|
||||
setReviewIndex(0)
|
||||
setSelectedControl(items[0])
|
||||
setSelectedControl(startItems[0])
|
||||
setMode('detail')
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
|
||||
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
|
||||
setReviewTab(tab)
|
||||
setReviewItems(items)
|
||||
setReviewIndex(0)
|
||||
if (items.length > 0) {
|
||||
setSelectedControl(items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (loading && controls.length === 0) {
|
||||
return (
|
||||
@@ -363,28 +394,89 @@ export default function ControlLibraryPage() {
|
||||
|
||||
// DETAIL MODE
|
||||
if (mode === 'detail' && selectedControl) {
|
||||
const isDuplicateReview = reviewMode && reviewTab === 'duplicates'
|
||||
|
||||
// Review tab bar (shown above the detail/compare view in review mode)
|
||||
const reviewTabBar = reviewMode ? (
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-2 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => switchReviewTab('duplicates')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${
|
||||
reviewTab === 'duplicates'
|
||||
? 'bg-amber-100 text-amber-800 border border-amber-300'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Duplikat-Verdacht ({reviewDuplicates.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchReviewTab('rule3')}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg font-medium ${
|
||||
reviewTab === 'rule3'
|
||||
? 'bg-purple-100 text-purple-800 border border-purple-300'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Rule 3 ohne Anchor ({reviewRule3.length})
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
if (isDuplicateReview) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{reviewTabBar}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ReviewCompare
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||||
onReview={handleReview}
|
||||
onEdit={() => setMode('edit')}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
onReviewPrev={() => {
|
||||
const idx = Math.max(0, reviewIndex - 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
onReviewNext={() => {
|
||||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||||
onEdit={() => setMode('edit')}
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onRefresh={fullReload}
|
||||
reviewMode={reviewMode}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
onReviewPrev={() => {
|
||||
const idx = Math.max(0, reviewIndex - 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
onReviewNext={() => {
|
||||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
{reviewTabBar}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ControlDetail
|
||||
ctrl={selectedControl}
|
||||
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
||||
onEdit={() => setMode('edit')}
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onRefresh={fullReload}
|
||||
reviewMode={reviewMode}
|
||||
reviewIndex={reviewIndex}
|
||||
reviewTotal={reviewItems.length}
|
||||
onReviewPrev={() => {
|
||||
const idx = Math.max(0, reviewIndex - 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
onReviewNext={() => {
|
||||
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
||||
setReviewIndex(idx)
|
||||
setSelectedControl(reviewItems[idx])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user