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:
@@ -108,6 +108,19 @@ export async function GET(request: NextRequest) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'provenance': {
|
||||||
|
const provId = searchParams.get('id')
|
||||||
|
if (!provId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'atomic-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
|
||||||
|
break
|
||||||
|
|
||||||
case 'similar': {
|
case 'similar': {
|
||||||
const simControlId = searchParams.get('id')
|
const simControlId = searchParams.get('id')
|
||||||
if (!simControlId) {
|
if (!simControlId) {
|
||||||
|
|||||||
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Atom, Search, ChevronRight, ChevronLeft, Filter,
|
||||||
|
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
|
||||||
|
Clock, RefreshCw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
CanonicalControl, BACKEND_URL,
|
||||||
|
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
|
||||||
|
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
|
||||||
|
CATEGORY_OPTIONS,
|
||||||
|
} from '../control-library/components/helpers'
|
||||||
|
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface AtomicStats {
|
||||||
|
total_active: number
|
||||||
|
total_duplicate: number
|
||||||
|
by_domain: Array<{ domain: string; count: number }>
|
||||||
|
by_regulation: Array<{ regulation: string; count: number }>
|
||||||
|
avg_regulation_coverage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ATOMIC CONTROLS PAGE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export default function AtomicControlsPage() {
|
||||||
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||||
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
|
const [stats, setStats] = useState<AtomicStats | null>(null)
|
||||||
|
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
const [severityFilter, setSeverityFilter] = useState<string>('')
|
||||||
|
const [domainFilter, setDomainFilter] = useState<string>('')
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||||
|
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
|
||||||
|
// Mode
|
||||||
|
const [mode, setMode] = useState<'list' | 'detail'>('list')
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||||
|
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
|
||||||
|
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
// Build query params
|
||||||
|
const buildParams = useCallback((extra?: Record<string, string>) => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
p.set('control_type', 'atomic')
|
||||||
|
// Exclude duplicates — show only active masters
|
||||||
|
if (!extra?.release_state) {
|
||||||
|
// Don't filter by state for count queries that already have it
|
||||||
|
}
|
||||||
|
if (severityFilter) p.set('severity', severityFilter)
|
||||||
|
if (domainFilter) p.set('domain', domainFilter)
|
||||||
|
if (categoryFilter) p.set('category', categoryFilter)
|
||||||
|
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||||
|
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||||
|
return p.toString()
|
||||||
|
}, [severityFilter, domainFilter, categoryFilter, debouncedSearch])
|
||||||
|
|
||||||
|
// Load stats
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
|
||||||
|
if (res.ok) setStats(await res.json())
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load controls page
|
||||||
|
const loadControls = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
|
||||||
|
const sortOrder = sortBy === 'newest' ? 'desc' : 'asc'
|
||||||
|
const offset = (currentPage - 1) * PAGE_SIZE
|
||||||
|
|
||||||
|
const qs = buildParams({
|
||||||
|
sort: sortField,
|
||||||
|
order: sortOrder,
|
||||||
|
limit: String(PAGE_SIZE),
|
||||||
|
offset: String(offset),
|
||||||
|
})
|
||||||
|
|
||||||
|
const countQs = buildParams()
|
||||||
|
|
||||||
|
const [ctrlRes, countRes] = await Promise.all([
|
||||||
|
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
||||||
|
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||||
|
if (countRes.ok) {
|
||||||
|
const data = await countRes.json()
|
||||||
|
setTotalCount(data.total || 0)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [buildParams, sortBy, currentPage])
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => { loadStats() }, [loadStats])
|
||||||
|
useEffect(() => { loadControls() }, [loadControls])
|
||||||
|
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
if (loading && controls.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DETAIL MODE
|
||||||
|
if (mode === 'detail' && selectedControl) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ControlDetail
|
||||||
|
ctrl={selectedControl}
|
||||||
|
onBack={() => { setMode('list'); setSelectedControl(null) }}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
onReview={() => {}}
|
||||||
|
onNavigateToControl={async (controlId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSelectedControl(data)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// LIST VIEW
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Atom className="w-6 h-6 text-violet-600" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Deduplizierte atomare Controls mit Herkunftsnachweis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { loadControls(); loadStats() }}
|
||||||
|
className="p-2 text-gray-400 hover:text-violet-600"
|
||||||
|
title="Aktualisieren"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||||
|
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
|
||||||
|
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
|
||||||
|
<div className="text-xs text-violet-500">Master Controls</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||||
|
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
|
||||||
|
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
|
||||||
|
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
|
||||||
|
<div className="text-xs text-indigo-500">Regulierungen</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||||
|
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
|
||||||
|
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Atomare Controls durchsuchen (ID, Titel, Objective)..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Filter className="w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={domainFilter}
|
||||||
|
onChange={e => setDomainFilter(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
>
|
||||||
|
<option value="">Domain</option>
|
||||||
|
{stats?.by_domain.map(d => (
|
||||||
|
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={severityFilter}
|
||||||
|
onChange={e => setSeverityFilter(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
>
|
||||||
|
<option value="">Schweregrad</option>
|
||||||
|
<option value="critical">Kritisch</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
<option value="medium">Mittel</option>
|
||||||
|
<option value="low">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={e => setCategoryFilter(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
>
|
||||||
|
<option value="">Kategorie</option>
|
||||||
|
{CATEGORY_OPTIONS.map(c => (
|
||||||
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-gray-300 mx-1">|</span>
|
||||||
|
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
>
|
||||||
|
<option value="id">Sortierung: ID</option>
|
||||||
|
<option value="newest">Neueste zuerst</option>
|
||||||
|
<option value="oldest">Aelteste zuerst</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Header */}
|
||||||
|
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
{totalCount} Controls gefunden
|
||||||
|
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
|
||||||
|
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
|
||||||
|
</span>
|
||||||
|
<span>Seite {currentPage} von {totalPages}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{controls.map((ctrl) => (
|
||||||
|
<button
|
||||||
|
key={ctrl.control_id}
|
||||||
|
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
||||||
|
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-300 hover:shadow-sm transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||||
|
<SeverityBadge severity={ctrl.severity} />
|
||||||
|
<StateBadge state={ctrl.release_state} />
|
||||||
|
<CategoryBadge category={ctrl.category} />
|
||||||
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||||
|
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
|
||||||
|
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{ctrl.source_citation?.source && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-blue-600">
|
||||||
|
{ctrl.source_citation.source}
|
||||||
|
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{ctrl.parent_control_id && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Clock className="w-3 h-3 text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-400" title={ctrl.created_at}>
|
||||||
|
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{controls.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">
|
||||||
|
Keine atomaren Controls gefunden.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
|
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
||||||
|
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
||||||
|
acc.push(p)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
.map((p, i) =>
|
||||||
|
p === 'dots' ? (
|
||||||
|
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setCurrentPage(p as number)}
|
||||||
|
className={`w-8 h-8 text-sm rounded-lg ${
|
||||||
|
currentPage === p
|
||||||
|
? 'bg-violet-600 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronsRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
||||||
ObligationTypeBadge, GenerationStrategyBadge,
|
ObligationTypeBadge, GenerationStrategyBadge,
|
||||||
|
ExtractionMethodBadge, RegulationCountBadge,
|
||||||
VERIFICATION_METHODS, CATEGORY_OPTIONS,
|
VERIFICATION_METHODS, CATEGORY_OPTIONS,
|
||||||
|
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
|
|
||||||
interface SimilarControl {
|
interface SimilarControl {
|
||||||
@@ -54,6 +56,13 @@ interface TraceabilityData {
|
|||||||
decomposition_method: string
|
decomposition_method: string
|
||||||
}>
|
}>
|
||||||
source_count: number
|
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 {
|
interface ControlDetailProps {
|
||||||
@@ -63,6 +72,7 @@ interface ControlDetailProps {
|
|||||||
onDelete: (controlId: string) => void
|
onDelete: (controlId: string) => void
|
||||||
onReview: (controlId: string, action: string) => void
|
onReview: (controlId: string, action: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
|
onNavigateToControl?: (controlId: string) => void
|
||||||
// Review mode navigation
|
// Review mode navigation
|
||||||
reviewMode?: boolean
|
reviewMode?: boolean
|
||||||
reviewIndex?: number
|
reviewIndex?: number
|
||||||
@@ -78,6 +88,7 @@ export function ControlDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onReview,
|
onReview,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onNavigateToControl,
|
||||||
reviewMode,
|
reviewMode,
|
||||||
reviewIndex = 0,
|
reviewIndex = 0,
|
||||||
reviewTotal = 0,
|
reviewTotal = 0,
|
||||||
@@ -94,7 +105,11 @@ export function ControlDetail({
|
|||||||
const loadTraceability = useCallback(async () => {
|
const loadTraceability = useCallback(async () => {
|
||||||
setLoadingTrace(true)
|
setLoadingTrace(true)
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
setTraceability(await res.json())
|
setTraceability(await res.json())
|
||||||
}
|
}
|
||||||
@@ -296,6 +311,11 @@ export function ControlDetail({
|
|||||||
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
|
||||||
</h3>
|
</h3>
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
<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>}
|
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -329,9 +349,18 @@ export function ControlDetail({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-violet-600 mt-1">
|
<p className="text-xs text-violet-600 mt-1">
|
||||||
via{' '}
|
via{' '}
|
||||||
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
|
{onNavigateToControl ? (
|
||||||
{link.parent_control_id}
|
<button
|
||||||
</span>
|
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 && (
|
{link.parent_title && (
|
||||||
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
<span className="text-violet-500 ml-1">— {link.parent_title}</span>
|
||||||
)}
|
)}
|
||||||
@@ -378,6 +407,100 @@ export function ControlDetail({
|
|||||||
</section>
|
</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) */}
|
{/* Child controls (rich controls that have atomic children) */}
|
||||||
{traceability && traceability.children.length > 0 && (
|
{traceability && traceability.children.length > 0 && (
|
||||||
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
<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">
|
<div className="space-y-1.5">
|
||||||
{traceability.children.map((child) => (
|
{traceability.children.map((child) => (
|
||||||
<div key={child.control_id} className="flex items-center gap-2 text-sm">
|
<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>
|
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
|
||||||
<SeverityBadge severity={child.severity} />
|
<SeverityBadge severity={child.severity} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -304,3 +304,61 @@ export function ObligationTypeBadge({ type }: { type: string | null | undefined
|
|||||||
export function getDomain(controlId: string): string {
|
export function getDomain(controlId: string): string {
|
||||||
return controlId.split('-')[0] || ''
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -463,6 +463,16 @@ export default function ControlLibraryPage() {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onReview={handleReview}
|
onReview={handleReview}
|
||||||
onRefresh={fullReload}
|
onRefresh={fullReload}
|
||||||
|
onNavigateToControl={async (controlId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setSelectedControl(data)
|
||||||
|
setMode('detail')
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
reviewMode={reviewMode}
|
reviewMode={reviewMode}
|
||||||
reviewIndex={reviewIndex}
|
reviewIndex={reviewIndex}
|
||||||
reviewTotal={reviewItems.length}
|
reviewTotal={reviewItems.length}
|
||||||
|
|||||||
@@ -920,6 +920,20 @@ export const SDK_STEPS: SDKStep[] = [
|
|||||||
prerequisiteSteps: [],
|
prerequisiteSteps: [],
|
||||||
isOptional: true,
|
isOptional: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'atomic-controls',
|
||||||
|
seq: 4925,
|
||||||
|
phase: 2,
|
||||||
|
package: 'betrieb',
|
||||||
|
order: 11.5,
|
||||||
|
name: 'Atomare Controls',
|
||||||
|
nameShort: 'Atomar',
|
||||||
|
description: 'Deduplizierte atomare Controls mit Herkunftsnachweis',
|
||||||
|
url: '/sdk/atomic-controls',
|
||||||
|
checkpointId: 'CP-ATOM',
|
||||||
|
prerequisiteSteps: [],
|
||||||
|
isOptional: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'control-provenance',
|
id: 'control-provenance',
|
||||||
seq: 4950,
|
seq: 4950,
|
||||||
|
|||||||
@@ -473,6 +473,61 @@ async def controls_meta():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/controls/atomic-stats")
|
||||||
|
async def atomic_stats():
|
||||||
|
"""Return aggregated statistics for atomic controls (masters only)."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
total_active = db.execute(text("""
|
||||||
|
SELECT count(*) FROM canonical_controls
|
||||||
|
WHERE decomposition_method = 'pass0b'
|
||||||
|
AND release_state NOT IN ('duplicate', 'deprecated', 'rejected')
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
total_duplicate = db.execute(text("""
|
||||||
|
SELECT count(*) FROM canonical_controls
|
||||||
|
WHERE decomposition_method = 'pass0b'
|
||||||
|
AND release_state = 'duplicate'
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
by_domain = db.execute(text("""
|
||||||
|
SELECT UPPER(SPLIT_PART(control_id, '-', 1)) AS domain, count(*) AS cnt
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE decomposition_method = 'pass0b'
|
||||||
|
AND release_state NOT IN ('duplicate', 'deprecated', 'rejected')
|
||||||
|
GROUP BY domain ORDER BY cnt DESC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
by_regulation = db.execute(text("""
|
||||||
|
SELECT cpl.source_regulation AS regulation, count(DISTINCT cc.id) AS cnt
|
||||||
|
FROM canonical_controls cc
|
||||||
|
JOIN control_parent_links cpl ON cpl.control_uuid = cc.id
|
||||||
|
WHERE cc.decomposition_method = 'pass0b'
|
||||||
|
AND cc.release_state NOT IN ('duplicate', 'deprecated', 'rejected')
|
||||||
|
AND cpl.source_regulation IS NOT NULL
|
||||||
|
GROUP BY cpl.source_regulation ORDER BY cnt DESC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
avg_coverage = db.execute(text("""
|
||||||
|
SELECT COALESCE(AVG(reg_count), 0)
|
||||||
|
FROM (
|
||||||
|
SELECT cc.id, count(DISTINCT cpl.source_regulation) AS reg_count
|
||||||
|
FROM canonical_controls cc
|
||||||
|
LEFT JOIN control_parent_links cpl ON cpl.control_uuid = cc.id
|
||||||
|
WHERE cc.decomposition_method = 'pass0b'
|
||||||
|
AND cc.release_state NOT IN ('duplicate', 'deprecated', 'rejected')
|
||||||
|
GROUP BY cc.id
|
||||||
|
) sub
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_active": total_active,
|
||||||
|
"total_duplicate": total_duplicate,
|
||||||
|
"by_domain": [{"domain": r[0], "count": r[1]} for r in by_domain],
|
||||||
|
"by_regulation": [{"regulation": r[0], "count": r[1]} for r in by_regulation],
|
||||||
|
"avg_regulation_coverage": round(float(avg_coverage), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/controls/{control_id}")
|
@router.get("/controls/{control_id}")
|
||||||
async def get_control(control_id: str):
|
async def get_control(control_id: str):
|
||||||
"""Get a single canonical control by its control_id (e.g. AUTH-001)."""
|
"""Get a single canonical control by its control_id (e.g. AUTH-001)."""
|
||||||
@@ -620,6 +675,239 @@ async def get_control_traceability(control_id: str):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/controls/{control_id}/provenance")
|
||||||
|
async def get_control_provenance(control_id: str):
|
||||||
|
"""Get full provenance chain for a control — extends traceability with
|
||||||
|
obligations, document references, merged duplicates, and regulations summary.
|
||||||
|
"""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
ctrl = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, control_id, title, parent_control_uuid,
|
||||||
|
decomposition_method, source_citation
|
||||||
|
FROM canonical_controls WHERE control_id = :cid
|
||||||
|
"""),
|
||||||
|
{"cid": control_id.upper()},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not ctrl:
|
||||||
|
raise HTTPException(status_code=404, detail="Control not found")
|
||||||
|
|
||||||
|
ctrl_uuid = str(ctrl.id)
|
||||||
|
is_atomic = ctrl.decomposition_method == "pass0b"
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"control_id": ctrl.control_id,
|
||||||
|
"title": ctrl.title,
|
||||||
|
"is_atomic": is_atomic,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Parent links (same as traceability) ---
|
||||||
|
parent_links = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT cpl.parent_control_uuid, cpl.link_type,
|
||||||
|
cpl.confidence, cpl.source_regulation,
|
||||||
|
cpl.source_article, cpl.obligation_candidate_id,
|
||||||
|
cc.control_id AS parent_control_id,
|
||||||
|
cc.title AS parent_title,
|
||||||
|
cc.source_citation AS parent_citation,
|
||||||
|
oc.obligation_text, oc.action, oc.object,
|
||||||
|
oc.normative_strength
|
||||||
|
FROM control_parent_links cpl
|
||||||
|
JOIN canonical_controls cc ON cc.id = cpl.parent_control_uuid
|
||||||
|
LEFT JOIN obligation_candidates oc ON oc.id = cpl.obligation_candidate_id
|
||||||
|
WHERE cpl.control_uuid = CAST(:uid AS uuid)
|
||||||
|
ORDER BY cpl.source_regulation, cpl.source_article
|
||||||
|
"""),
|
||||||
|
{"uid": ctrl_uuid},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result["parent_links"] = [
|
||||||
|
{
|
||||||
|
"parent_control_id": pl.parent_control_id,
|
||||||
|
"parent_title": pl.parent_title,
|
||||||
|
"link_type": pl.link_type,
|
||||||
|
"confidence": float(pl.confidence) if pl.confidence else 1.0,
|
||||||
|
"source_regulation": pl.source_regulation,
|
||||||
|
"source_article": pl.source_article,
|
||||||
|
"parent_citation": pl.parent_citation,
|
||||||
|
"obligation": {
|
||||||
|
"text": pl.obligation_text,
|
||||||
|
"action": pl.action,
|
||||||
|
"object": pl.object,
|
||||||
|
"normative_strength": pl.normative_strength,
|
||||||
|
} if pl.obligation_text else None,
|
||||||
|
}
|
||||||
|
for pl in parent_links
|
||||||
|
]
|
||||||
|
|
||||||
|
# Legacy 1:1 parent (backwards compat)
|
||||||
|
if ctrl.parent_control_uuid:
|
||||||
|
parent_uuids_in_links = {
|
||||||
|
str(pl.parent_control_uuid) for pl in parent_links
|
||||||
|
}
|
||||||
|
parent_uuid_str = str(ctrl.parent_control_uuid)
|
||||||
|
if parent_uuid_str not in parent_uuids_in_links:
|
||||||
|
legacy = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT control_id, title, source_citation
|
||||||
|
FROM canonical_controls WHERE id = CAST(:uid AS uuid)
|
||||||
|
"""),
|
||||||
|
{"uid": parent_uuid_str},
|
||||||
|
).fetchone()
|
||||||
|
if legacy:
|
||||||
|
result["parent_links"].insert(0, {
|
||||||
|
"parent_control_id": legacy.control_id,
|
||||||
|
"parent_title": legacy.title,
|
||||||
|
"link_type": "decomposition",
|
||||||
|
"confidence": 1.0,
|
||||||
|
"source_regulation": None,
|
||||||
|
"source_article": None,
|
||||||
|
"parent_citation": legacy.source_citation,
|
||||||
|
"obligation": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Children ---
|
||||||
|
children = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT control_id, title, category, severity,
|
||||||
|
decomposition_method
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE parent_control_uuid = CAST(:uid AS uuid)
|
||||||
|
ORDER BY control_id
|
||||||
|
"""),
|
||||||
|
{"uid": ctrl_uuid},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result["children"] = [
|
||||||
|
{
|
||||||
|
"control_id": ch.control_id,
|
||||||
|
"title": ch.title,
|
||||||
|
"category": ch.category,
|
||||||
|
"severity": ch.severity,
|
||||||
|
"decomposition_method": ch.decomposition_method,
|
||||||
|
}
|
||||||
|
for ch in children
|
||||||
|
]
|
||||||
|
|
||||||
|
# Source count
|
||||||
|
regs = set()
|
||||||
|
for pl in result["parent_links"]:
|
||||||
|
if pl.get("source_regulation"):
|
||||||
|
regs.add(pl["source_regulation"])
|
||||||
|
result["source_count"] = len(regs)
|
||||||
|
|
||||||
|
# --- Obligations (for Rich Controls) ---
|
||||||
|
obligations = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT candidate_id, obligation_text, action, object,
|
||||||
|
normative_strength, release_state
|
||||||
|
FROM obligation_candidates
|
||||||
|
WHERE parent_control_uuid = CAST(:uid AS uuid)
|
||||||
|
AND release_state NOT IN ('rejected', 'merged')
|
||||||
|
ORDER BY candidate_id
|
||||||
|
"""),
|
||||||
|
{"uid": ctrl_uuid},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result["obligations"] = [
|
||||||
|
{
|
||||||
|
"candidate_id": ob.candidate_id,
|
||||||
|
"obligation_text": ob.obligation_text,
|
||||||
|
"action": ob.action,
|
||||||
|
"object": ob.object,
|
||||||
|
"normative_strength": ob.normative_strength,
|
||||||
|
"release_state": ob.release_state,
|
||||||
|
}
|
||||||
|
for ob in obligations
|
||||||
|
]
|
||||||
|
result["obligation_count"] = len(obligations)
|
||||||
|
|
||||||
|
# --- Document References ---
|
||||||
|
doc_refs = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT DISTINCT oe.regulation_code, oe.article, oe.paragraph,
|
||||||
|
oe.extraction_method, oe.confidence
|
||||||
|
FROM obligation_extractions oe
|
||||||
|
WHERE oe.control_uuid = CAST(:uid AS uuid)
|
||||||
|
OR oe.obligation_id IN (
|
||||||
|
SELECT oc.candidate_id FROM obligation_candidates oc
|
||||||
|
JOIN control_parent_links cpl ON cpl.obligation_candidate_id = oc.id
|
||||||
|
WHERE cpl.control_uuid = CAST(:uid AS uuid)
|
||||||
|
)
|
||||||
|
ORDER BY oe.regulation_code, oe.article
|
||||||
|
"""),
|
||||||
|
{"uid": ctrl_uuid},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result["document_references"] = [
|
||||||
|
{
|
||||||
|
"regulation_code": dr.regulation_code,
|
||||||
|
"article": dr.article,
|
||||||
|
"paragraph": dr.paragraph,
|
||||||
|
"extraction_method": dr.extraction_method,
|
||||||
|
"confidence": float(dr.confidence) if dr.confidence else None,
|
||||||
|
}
|
||||||
|
for dr in doc_refs
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Merged Duplicates ---
|
||||||
|
merged = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT cc.control_id, cc.title,
|
||||||
|
(SELECT cpl.source_regulation FROM control_parent_links cpl
|
||||||
|
WHERE cpl.control_uuid = cc.id LIMIT 1) AS source_regulation
|
||||||
|
FROM canonical_controls cc
|
||||||
|
WHERE cc.merged_into_uuid = CAST(:uid AS uuid)
|
||||||
|
AND cc.release_state = 'duplicate'
|
||||||
|
ORDER BY cc.control_id
|
||||||
|
"""),
|
||||||
|
{"uid": ctrl_uuid},
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result["merged_duplicates"] = [
|
||||||
|
{
|
||||||
|
"control_id": m.control_id,
|
||||||
|
"title": m.title,
|
||||||
|
"source_regulation": m.source_regulation,
|
||||||
|
}
|
||||||
|
for m in merged
|
||||||
|
]
|
||||||
|
result["merged_duplicates_count"] = len(merged)
|
||||||
|
|
||||||
|
# --- Regulations Summary (aggregated from parent_links + doc_refs) ---
|
||||||
|
reg_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for pl in result["parent_links"]:
|
||||||
|
reg = pl.get("source_regulation")
|
||||||
|
if not reg:
|
||||||
|
continue
|
||||||
|
if reg not in reg_map:
|
||||||
|
reg_map[reg] = {"articles": set(), "link_types": set()}
|
||||||
|
if pl.get("source_article"):
|
||||||
|
reg_map[reg]["articles"].add(pl["source_article"])
|
||||||
|
reg_map[reg]["link_types"].add(pl.get("link_type", "decomposition"))
|
||||||
|
|
||||||
|
for dr in result["document_references"]:
|
||||||
|
reg = dr.get("regulation_code")
|
||||||
|
if not reg:
|
||||||
|
continue
|
||||||
|
if reg not in reg_map:
|
||||||
|
reg_map[reg] = {"articles": set(), "link_types": set()}
|
||||||
|
if dr.get("article"):
|
||||||
|
reg_map[reg]["articles"].add(dr["article"])
|
||||||
|
|
||||||
|
result["regulations_summary"] = [
|
||||||
|
{
|
||||||
|
"regulation_code": reg,
|
||||||
|
"articles": sorted(info["articles"]),
|
||||||
|
"link_types": sorted(info["link_types"]),
|
||||||
|
}
|
||||||
|
for reg, info in sorted(reg_map.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CONTROL CRUD (CREATE / UPDATE / DELETE)
|
# CONTROL CRUD (CREATE / UPDATE / DELETE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
277
backend-compliance/tests/test_provenance_endpoint.py
Normal file
277
backend-compliance/tests/test_provenance_endpoint.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""Tests for provenance and atomic-stats endpoints.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GET /v1/canonical/controls/{control_id}/provenance
|
||||||
|
- GET /v1/canonical/controls/atomic-stats
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from compliance.api.canonical_control_routes import (
|
||||||
|
get_control_provenance,
|
||||||
|
atomic_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HELPERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _mock_row(**kwargs):
|
||||||
|
"""Create a mock DB row with attribute access."""
|
||||||
|
obj = MagicMock()
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(obj, k, v)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_db_execute(return_values):
|
||||||
|
"""Return a mock that cycles through return values for sequential .execute() calls."""
|
||||||
|
mock_db = MagicMock()
|
||||||
|
results = iter(return_values)
|
||||||
|
|
||||||
|
def execute_side_effect(*args, **kwargs):
|
||||||
|
result = next(results)
|
||||||
|
mock_result = MagicMock()
|
||||||
|
if isinstance(result, list):
|
||||||
|
mock_result.fetchall.return_value = result
|
||||||
|
mock_result.fetchone.return_value = result[0] if result else None
|
||||||
|
elif isinstance(result, int):
|
||||||
|
mock_result.scalar.return_value = result
|
||||||
|
elif result is None:
|
||||||
|
mock_result.fetchone.return_value = None
|
||||||
|
mock_result.fetchall.return_value = []
|
||||||
|
mock_result.scalar.return_value = 0
|
||||||
|
else:
|
||||||
|
mock_result.fetchone.return_value = result
|
||||||
|
mock_result.fetchall.return_value = [result]
|
||||||
|
return mock_result
|
||||||
|
|
||||||
|
mock_db.execute.side_effect = execute_side_effect
|
||||||
|
return mock_db
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PROVENANCE ENDPOINT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestProvenanceEndpoint:
|
||||||
|
"""Tests for GET /controls/{control_id}/provenance."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provenance_not_found(self):
|
||||||
|
"""404 when control doesn't exist."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
mock_db = _mock_db_execute([None])
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_control_provenance("NONEXISTENT-999")
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provenance_atomic_control(self):
|
||||||
|
"""Atomic control returns document_references, parent_links, merged_duplicates."""
|
||||||
|
import uuid
|
||||||
|
ctrl_id = uuid.uuid4()
|
||||||
|
|
||||||
|
ctrl_row = _mock_row(
|
||||||
|
id=ctrl_id,
|
||||||
|
control_id="SEC-042",
|
||||||
|
title="Test Atomic Control",
|
||||||
|
parent_control_uuid=None,
|
||||||
|
decomposition_method="pass0b",
|
||||||
|
source_citation=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
parent_link = _mock_row(
|
||||||
|
parent_control_uuid=uuid.uuid4(),
|
||||||
|
parent_control_id="DATA-005",
|
||||||
|
parent_title="Parent Control",
|
||||||
|
link_type="decomposition",
|
||||||
|
confidence=0.95,
|
||||||
|
source_regulation="DSGVO",
|
||||||
|
source_article="Art. 32",
|
||||||
|
parent_citation=None,
|
||||||
|
obligation_text="Must encrypt",
|
||||||
|
action="encrypt",
|
||||||
|
object="personal data",
|
||||||
|
normative_strength="must",
|
||||||
|
obligation_candidate_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
child_row = _mock_row(
|
||||||
|
control_id="SEC-042a",
|
||||||
|
title="Child",
|
||||||
|
category="encryption",
|
||||||
|
severity="high",
|
||||||
|
decomposition_method="pass0b",
|
||||||
|
)
|
||||||
|
|
||||||
|
obligation_row = _mock_row(
|
||||||
|
candidate_id="OBL-SEC-042-001",
|
||||||
|
obligation_text="Test obligation",
|
||||||
|
action="encrypt",
|
||||||
|
object="data at rest",
|
||||||
|
normative_strength="must",
|
||||||
|
release_state="composed",
|
||||||
|
)
|
||||||
|
|
||||||
|
doc_ref = _mock_row(
|
||||||
|
regulation_code="DSGVO",
|
||||||
|
article="Art. 32",
|
||||||
|
paragraph="Abs. 1 lit. a",
|
||||||
|
extraction_method="llm_extracted",
|
||||||
|
confidence=0.92,
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = _mock_row(
|
||||||
|
control_id="SEC-099",
|
||||||
|
title="Encryption at rest (NIS2)",
|
||||||
|
source_regulation="NIS2",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db = _mock_db_execute([
|
||||||
|
ctrl_row, # control lookup
|
||||||
|
[parent_link], # parent_links
|
||||||
|
[], # children
|
||||||
|
[obligation_row], # obligations
|
||||||
|
[doc_ref], # document_references
|
||||||
|
[merged], # merged_duplicates
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
result = await get_control_provenance("SEC-042")
|
||||||
|
|
||||||
|
assert result["control_id"] == "SEC-042"
|
||||||
|
assert result["is_atomic"] is True
|
||||||
|
assert len(result["parent_links"]) == 1
|
||||||
|
assert result["parent_links"][0]["parent_control_id"] == "DATA-005"
|
||||||
|
assert result["obligation_count"] == 1
|
||||||
|
assert len(result["document_references"]) == 1
|
||||||
|
assert result["document_references"][0]["regulation_code"] == "DSGVO"
|
||||||
|
assert len(result["merged_duplicates"]) == 1
|
||||||
|
assert result["merged_duplicates"][0]["control_id"] == "SEC-099"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provenance_rich_control(self):
|
||||||
|
"""Rich control returns obligations list and children."""
|
||||||
|
import uuid
|
||||||
|
ctrl_id = uuid.uuid4()
|
||||||
|
|
||||||
|
ctrl_row = _mock_row(
|
||||||
|
id=ctrl_id,
|
||||||
|
control_id="DATA-005",
|
||||||
|
title="Rich Control",
|
||||||
|
parent_control_uuid=None,
|
||||||
|
decomposition_method=None,
|
||||||
|
source_citation={"source": "DSGVO"},
|
||||||
|
)
|
||||||
|
|
||||||
|
obligation_row = _mock_row(
|
||||||
|
candidate_id="OBL-DATA-005-001",
|
||||||
|
obligation_text="Encrypt personal data",
|
||||||
|
action="encrypt",
|
||||||
|
object="personal data",
|
||||||
|
normative_strength="must",
|
||||||
|
release_state="composed",
|
||||||
|
)
|
||||||
|
|
||||||
|
child_row = _mock_row(
|
||||||
|
control_id="SEC-042",
|
||||||
|
title="Child Atomic",
|
||||||
|
category="encryption",
|
||||||
|
severity="high",
|
||||||
|
decomposition_method="pass0b",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db = _mock_db_execute([
|
||||||
|
ctrl_row, # control lookup
|
||||||
|
[], # parent_links
|
||||||
|
[child_row], # children
|
||||||
|
[obligation_row], # obligations
|
||||||
|
[], # document_references
|
||||||
|
[], # merged_duplicates
|
||||||
|
])
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=mock_db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
result = await get_control_provenance("DATA-005")
|
||||||
|
|
||||||
|
assert result["control_id"] == "DATA-005"
|
||||||
|
assert result["is_atomic"] is False
|
||||||
|
assert result["obligation_count"] == 1
|
||||||
|
assert result["obligations"][0]["candidate_id"] == "OBL-DATA-005-001"
|
||||||
|
assert len(result["children"]) == 1
|
||||||
|
assert result["children"][0]["control_id"] == "SEC-042"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ATOMIC STATS ENDPOINT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAtomicStatsEndpoint:
|
||||||
|
"""Tests for GET /controls/atomic-stats."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_atomic_stats_response_shape(self):
|
||||||
|
"""Stats endpoint returns expected aggregation fields."""
|
||||||
|
mock_db = _mock_db_execute([
|
||||||
|
18234, # total_active
|
||||||
|
67000, # total_duplicate
|
||||||
|
[ # by_domain
|
||||||
|
_mock_row(**{"__getitem__": lambda s, i: ["SEC", 4200][i]}),
|
||||||
|
],
|
||||||
|
[ # by_regulation
|
||||||
|
_mock_row(**{"__getitem__": lambda s, i: ["DSGVO", 1200][i]}),
|
||||||
|
],
|
||||||
|
2.3, # avg_coverage
|
||||||
|
])
|
||||||
|
|
||||||
|
# Override __getitem__ for tuple-like access
|
||||||
|
domain_row = MagicMock()
|
||||||
|
domain_row.__getitem__ = lambda s, i: ["SEC", 4200][i]
|
||||||
|
reg_row = MagicMock()
|
||||||
|
reg_row.__getitem__ = lambda s, i: ["DSGVO", 1200][i]
|
||||||
|
|
||||||
|
mock_db2 = MagicMock()
|
||||||
|
call_count = [0]
|
||||||
|
responses = [18234, 67000, [domain_row], [reg_row], 2.3]
|
||||||
|
|
||||||
|
def execute_side(*args, **kwargs):
|
||||||
|
idx = call_count[0]
|
||||||
|
call_count[0] += 1
|
||||||
|
r = MagicMock()
|
||||||
|
val = responses[idx]
|
||||||
|
if isinstance(val, list):
|
||||||
|
r.fetchall.return_value = val
|
||||||
|
else:
|
||||||
|
r.scalar.return_value = val
|
||||||
|
return r
|
||||||
|
|
||||||
|
mock_db2.execute.side_effect = execute_side
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=mock_db2)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
result = await atomic_stats()
|
||||||
|
|
||||||
|
assert result["total_active"] == 18234
|
||||||
|
assert result["total_duplicate"] == 67000
|
||||||
|
assert len(result["by_domain"]) == 1
|
||||||
|
assert result["by_domain"][0]["domain"] == "SEC"
|
||||||
|
assert len(result["by_regulation"]) == 1
|
||||||
|
assert result["by_regulation"][0]["regulation"] == "DSGVO"
|
||||||
|
assert result["avg_regulation_coverage"] == 2.3
|
||||||
Reference in New Issue
Block a user