Implement full evidence integrity pipeline to prevent compliance theater: - Confidence levels (E0-E4), truth status tracking, assertion engine - Four-Eyes approval workflow, audit trail, reject endpoint - Evidence distribution dashboard, LLM audit routes - Traceability matrix (backend endpoint + Compliance Hub UI tab) - Anti-fake badges, control status machine, normative patterns - 2 migrations, 4 test suites, MkDocs documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
469 lines
19 KiB
TypeScript
469 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface Assertion {
|
|
id: string
|
|
tenant_id: string | null
|
|
entity_type: string
|
|
entity_id: string
|
|
sentence_text: string
|
|
sentence_index: number
|
|
assertion_type: string // 'assertion' | 'fact' | 'rationale'
|
|
evidence_ids: string[]
|
|
confidence: number
|
|
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
|
|
verified_by: string | null
|
|
verified_at: string | null
|
|
created_at: string | null
|
|
updated_at: string | null
|
|
}
|
|
|
|
interface AssertionSummary {
|
|
total_assertions: number
|
|
total_facts: number
|
|
total_rationale: number
|
|
unverified_count: number
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONSTANTS
|
|
// =============================================================================
|
|
|
|
const TIER_COLORS: Record<string, string> = {
|
|
pflicht: 'bg-red-100 text-red-700',
|
|
empfehlung: 'bg-yellow-100 text-yellow-700',
|
|
kann: 'bg-blue-100 text-blue-700',
|
|
}
|
|
|
|
const TIER_LABELS: Record<string, string> = {
|
|
pflicht: 'Pflicht',
|
|
empfehlung: 'Empfehlung',
|
|
kann: 'Kann',
|
|
}
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
assertion: 'bg-orange-100 text-orange-700',
|
|
fact: 'bg-green-100 text-green-700',
|
|
rationale: 'bg-purple-100 text-purple-700',
|
|
}
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
assertion: 'Behauptung',
|
|
fact: 'Fakt',
|
|
rationale: 'Begruendung',
|
|
}
|
|
|
|
const API_BASE = '/api/sdk/v1/compliance'
|
|
|
|
type TabKey = 'overview' | 'list' | 'extract'
|
|
|
|
// =============================================================================
|
|
// ASSERTION CARD
|
|
// =============================================================================
|
|
|
|
function AssertionCard({
|
|
assertion,
|
|
onVerify,
|
|
}: {
|
|
assertion: Assertion
|
|
onVerify: (id: string) => void
|
|
}) {
|
|
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
|
|
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
|
|
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
|
|
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
|
|
{tierLabel}
|
|
</span>
|
|
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
|
|
{typeLabel}
|
|
</span>
|
|
{assertion.entity_type && (
|
|
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
|
|
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
|
|
</span>
|
|
)}
|
|
{assertion.confidence > 0 && (
|
|
<span className="text-xs text-gray-400">
|
|
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-900 leading-relaxed">
|
|
“{assertion.sentence_text}”
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
|
|
{assertion.verified_by && (
|
|
<span className="text-green-600">
|
|
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
|
|
</span>
|
|
)}
|
|
{assertion.evidence_ids.length > 0 && (
|
|
<span>
|
|
{assertion.evidence_ids.length} Evidence verknuepft
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{assertion.assertion_type !== 'fact' && (
|
|
<button
|
|
onClick={() => onVerify(assertion.id)}
|
|
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
|
|
>
|
|
Als Fakt pruefen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function AssertionsPage() {
|
|
const [activeTab, setActiveTab] = useState<TabKey>('overview')
|
|
const [summary, setSummary] = useState<AssertionSummary | null>(null)
|
|
const [assertions, setAssertions] = useState<Assertion[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Filters
|
|
const [filterEntityType, setFilterEntityType] = useState('')
|
|
const [filterAssertionType, setFilterAssertionType] = useState('')
|
|
|
|
// Extract tab
|
|
const [extractText, setExtractText] = useState('')
|
|
const [extractEntityType, setExtractEntityType] = useState('control')
|
|
const [extractEntityId, setExtractEntityId] = useState('')
|
|
const [extracting, setExtracting] = useState(false)
|
|
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
|
|
|
|
// Verify dialog
|
|
const [verifyingId, setVerifyingId] = useState<string | null>(null)
|
|
const [verifyEmail, setVerifyEmail] = useState('')
|
|
|
|
useEffect(() => {
|
|
loadSummary()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'list') loadAssertions()
|
|
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const loadSummary = async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/assertions/summary`)
|
|
if (res.ok) setSummary(await res.json())
|
|
} catch { /* silent */ }
|
|
finally { setLoading(false) }
|
|
}
|
|
|
|
const loadAssertions = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
if (filterEntityType) params.set('entity_type', filterEntityType)
|
|
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
|
|
params.set('limit', '200')
|
|
|
|
const res = await fetch(`${API_BASE}/assertions?${params}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setAssertions(data.assertions || [])
|
|
}
|
|
} catch {
|
|
setError('Assertions konnten nicht geladen werden')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleExtract = async () => {
|
|
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
|
|
setExtracting(true)
|
|
setError(null)
|
|
setExtractedAssertions([])
|
|
try {
|
|
const res = await fetch(`${API_BASE}/assertions/extract`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
text: extractText,
|
|
entity_type: extractEntityType || 'control',
|
|
entity_id: extractEntityId || undefined,
|
|
}),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
|
|
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
|
}
|
|
const data = await res.json()
|
|
setExtractedAssertions(data.assertions || [])
|
|
// Refresh summary
|
|
loadSummary()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
|
|
} finally {
|
|
setExtracting(false)
|
|
}
|
|
}
|
|
|
|
const handleVerify = async (assertionId: string) => {
|
|
setVerifyingId(assertionId)
|
|
}
|
|
|
|
const submitVerify = async () => {
|
|
if (!verifyingId || !verifyEmail.trim()) return
|
|
try {
|
|
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
|
|
method: 'POST',
|
|
})
|
|
if (res.ok) {
|
|
setVerifyingId(null)
|
|
setVerifyEmail('')
|
|
loadAssertions()
|
|
loadSummary()
|
|
} else {
|
|
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
|
|
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
|
|
}
|
|
} catch {
|
|
setError('Netzwerkfehler')
|
|
}
|
|
}
|
|
|
|
const tabs: { key: TabKey; label: string }[] = [
|
|
{ key: 'overview', label: 'Uebersicht' },
|
|
{ key: 'list', label: 'Assertion-Liste' },
|
|
{ key: 'extract', label: 'Extraktion' },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
|
|
<p className="text-slate-500 mt-1">
|
|
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white rounded-xl shadow-sm border">
|
|
<div className="flex border-b">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={`px-6 py-3 text-sm font-medium transition-colors ${
|
|
activeTab === tab.key
|
|
? 'text-purple-600 border-b-2 border-purple-600'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
|
<span>{error}</span>
|
|
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ============================================================ */}
|
|
{/* TAB: Uebersicht */}
|
|
{/* ============================================================ */}
|
|
{activeTab === 'overview' && (
|
|
<>
|
|
{loading ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : summary ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="text-sm text-gray-500">Gesamt Assertions</div>
|
|
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-green-200 p-6">
|
|
<div className="text-sm text-green-600">Verifizierte Fakten</div>
|
|
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
|
<div className="text-sm text-purple-600">Begruendungen</div>
|
|
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
|
<div className="text-sm text-orange-600">Unverifizizt</div>
|
|
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ============================================================ */}
|
|
{/* TAB: Assertion-Liste */}
|
|
{/* ============================================================ */}
|
|
{activeTab === 'list' && (
|
|
<>
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
|
|
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
|
|
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
|
<option value="">Alle</option>
|
|
<option value="control">Control</option>
|
|
<option value="evidence">Evidence</option>
|
|
<option value="requirement">Requirement</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
|
|
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
|
|
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
|
<option value="">Alle</option>
|
|
<option value="assertion">Behauptung</option>
|
|
<option value="fact">Fakt</option>
|
|
<option value="rationale">Begruendung</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : assertions.length === 0 ? (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<p className="text-gray-500">Keine Assertions gefunden.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
|
|
{assertions.map(a => (
|
|
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ============================================================ */}
|
|
{/* TAB: Extraktion */}
|
|
{/* ============================================================ */}
|
|
{activeTab === 'extract' && (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
|
|
<p className="text-sm text-gray-500 mb-4">
|
|
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
|
|
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
|
<option value="control">Control</option>
|
|
<option value="evidence">Evidence</option>
|
|
<option value="requirement">Requirement</option>
|
|
<option value="policy">Policy</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
|
|
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
|
|
placeholder="z.B. GOV-001 oder UUID"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
|
|
<textarea
|
|
value={extractText}
|
|
onChange={e => setExtractText(e.target.value)}
|
|
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
|
|
rows={6}
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleExtract}
|
|
disabled={extracting || !extractText.trim()}
|
|
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
|
|
extracting || !extractText.trim()
|
|
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
: 'bg-purple-600 text-white hover:bg-purple-700'
|
|
}`}
|
|
>
|
|
{extracting ? 'Extrahiere...' : 'Extrahieren'}
|
|
</button>
|
|
|
|
{/* Extracted results */}
|
|
{extractedAssertions.length > 0 && (
|
|
<div className="mt-6">
|
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
|
|
<div className="space-y-3">
|
|
{extractedAssertions.map(a => (
|
|
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Verify Dialog */}
|
|
{verifyingId && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
|
|
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
|
|
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
|
|
placeholder="auditor@unternehmen.de"
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
Abbrechen
|
|
</button>
|
|
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
|
|
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
|
Verifizieren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|