diff --git a/admin-compliance/app/api/sdk/drafting-engine/draft/route.ts b/admin-compliance/app/api/sdk/drafting-engine/draft/route.ts index b1293ff..272a015 100644 --- a/admin-compliance/app/api/sdk/drafting-engine/draft/route.ts +++ b/admin-compliance/app/api/sdk/drafting-engine/draft/route.ts @@ -591,12 +591,43 @@ async function handleV2Draft(body: Record): Promise {/* fire-and-forget */}) + } catch { + // LLM audit persistence failure should not block the response + } + return NextResponse.json({ draft, constraintCheck, tokensUsed: Math.round(totalTokens), pipelineVersion: 'v2', auditTrail, + truthLabel, }) } diff --git a/admin-compliance/app/api/sdk/drafting-engine/validate/route.ts b/admin-compliance/app/api/sdk/drafting-engine/validate/route.ts index 7d07f33..e440f6e 100644 --- a/admin-compliance/app/api/sdk/drafting-engine/validate/route.ts +++ b/admin-compliance/app/api/sdk/drafting-engine/validate/route.ts @@ -14,6 +14,76 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434' const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b' +/** + * Anti-Fake-Evidence: Verbotene Formulierungen + * + * Flags formulations that falsely claim compliance without evidence. + * Only allowed when: control_status=pass AND confidence >= E2 AND + * truth_status in (validated_internal, accepted_by_auditor). + */ +interface EvidenceContext { + controlStatus?: string + confidenceLevel?: string + truthStatus?: string +} + +const FORBIDDEN_PATTERNS: Array<{ + pattern: RegExp + label: string + safeAlternative: string +}> = [ + { pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' }, + { pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' }, + { pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' }, + { pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' }, + { pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' }, + { pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' }, + { pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' }, +] + +const CONFIDENCE_ORDER: Record = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 } +const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor']) + +function checkForbiddenFormulations( + content: string, + evidenceContext?: EvidenceContext, +): ValidationFinding[] { + const findings: ValidationFinding[] = [] + + if (!content) return findings + + // If evidence context shows sufficient proof, allow the formulations + if (evidenceContext) { + const { controlStatus, confidenceLevel, truthStatus } = evidenceContext + const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0 + if ( + controlStatus === 'pass' && + confLevel >= CONFIDENCE_ORDER.E2 && + VALID_TRUTH_STATUSES.has(truthStatus ?? '') + ) { + return findings // Formulations are backed by real evidence + } + } + + for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) { + // Reset regex state for global patterns + pattern.lastIndex = 0 + if (pattern.test(content)) { + findings.push({ + id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`, + severity: 'error', + category: 'forbidden_formulation' as ValidationFinding['category'], + title: `Verbotene Formulierung: "${label}"`, + description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`, + documentType: 'vvt' as ScopeDocumentType, + suggestion: `Verwende stattdessen: "${safeAlternative}"`, + }) + } + } + + return findings +} + /** * Stufe 1: Deterministische Pruefung */ @@ -221,10 +291,18 @@ export async function POST(request: NextRequest) { // LLM unavailable, continue with deterministic results only } + // --------------------------------------------------------------- + // Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence) + // --------------------------------------------------------------- + const forbiddenFindings = checkForbiddenFormulations( + draftContent || '', + validationContext.evidenceContext, + ) + // --------------------------------------------------------------- // Combine results // --------------------------------------------------------------- - const allFindings = [...deterministicFindings, ...llmFindings] + const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings] const errors = allFindings.filter(f => f.severity === 'error') const warnings = allFindings.filter(f => f.severity === 'warning') const suggestions = allFindings.filter(f => f.severity === 'suggestion') diff --git a/admin-compliance/app/sdk/assertions/page.tsx b/admin-compliance/app/sdk/assertions/page.tsx new file mode 100644 index 0000000..d1fa6cf --- /dev/null +++ b/admin-compliance/app/sdk/assertions/page.tsx @@ -0,0 +1,468 @@ +'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 = { + 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 = { + pflicht: 'Pflicht', + empfehlung: 'Empfehlung', + kann: 'Kann', +} + +const TYPE_COLORS: Record = { + 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 = { + 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 ( +
+
+
+
+ + {tierLabel} + + + {typeLabel} + + {assertion.entity_type && ( + + {assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'} + + )} + {assertion.confidence > 0 && ( + + Konfidenz: {(assertion.confidence * 100).toFixed(0)}% + + )} +
+

+ “{assertion.sentence_text}” +

+
+ {assertion.verified_by && ( + + Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'} + + )} + {assertion.evidence_ids.length > 0 && ( + + {assertion.evidence_ids.length} Evidence verknuepft + + )} +
+
+
+ {assertion.assertion_type !== 'fact' && ( + + )} +
+
+
+ ) +} + +// ============================================================================= +// MAIN PAGE +// ============================================================================= + +export default function AssertionsPage() { + const [activeTab, setActiveTab] = useState('overview') + const [summary, setSummary] = useState(null) + const [assertions, setAssertions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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([]) + + // Verify dialog + const [verifyingId, setVerifyingId] = useState(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 ( +
+ {/* Header */} +
+

Assertions

+

+ Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren. +

+
+ + {/* Tabs */} +
+
+ {tabs.map(tab => ( + + ))} +
+
+ + {/* Error */} + {error && ( +
+ {error} + +
+ )} + + {/* ============================================================ */} + {/* TAB: Uebersicht */} + {/* ============================================================ */} + {activeTab === 'overview' && ( + <> + {loading ? ( +
+
+
+ ) : summary ? ( +
+
+
Gesamt Assertions
+
{summary.total_assertions}
+
+
+
Verifizierte Fakten
+
{summary.total_facts}
+
+
+
Begruendungen
+
{summary.total_rationale}
+
+
+
Unverifizizt
+
{summary.unverified_count}
+
+
+ ) : ( +
+

Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.

+
+ )} + + )} + + {/* ============================================================ */} + {/* TAB: Assertion-Liste */} + {/* ============================================================ */} + {activeTab === 'list' && ( + <> + {/* Filters */} +
+
+ + +
+
+ + +
+
+ + {loading ? ( +
+
+
+ ) : assertions.length === 0 ? ( +
+

Keine Assertions gefunden.

+
+ ) : ( +
+

{assertions.length} Assertions

+ {assertions.map(a => ( + + ))} +
+ )} + + )} + + {/* ============================================================ */} + {/* TAB: Extraktion */} + {/* ============================================================ */} + {activeTab === 'extract' && ( +
+

Assertions aus Text extrahieren

+

+ Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen. +

+ +
+
+ + +
+
+ + 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" /> +
+
+ +
+ +