From bc75b4455d9ecbf37c3b16c0a9b6177b9fccf836 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sun, 29 Mar 2026 10:14:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20Act=20Decision=20Tree=20=E2=80=94?= =?UTF-8?q?=20Zwei-Achsen-Klassifikation=20(GPAI=20+=20High-Risk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interaktiver 12-Fragen-Entscheidungsbaum für die AI Act Klassifikation auf zwei Achsen: High-Risk (Anhang III, Q1-Q7) und GPAI (Art. 51-56, Q8-Q12). Deterministische Auswertung ohne LLM. Backend (Go): - Neue Structs: GPAIClassification, DecisionTreeAnswer, DecisionTreeResult - Decision Tree Engine mit BuildDecisionTreeDefinition() und EvaluateDecisionTree() - Store-Methoden für CRUD der Ergebnisse - API-Endpoints: GET/POST /decision-tree, GET/DELETE /decision-tree/results - 12 Unit Tests (alle bestanden) Frontend (Next.js): - DecisionTreeWizard: Wizard-UI mit Ja/Nein-Fragen, Dual-Progress-Bar, Ergebnis-Ansicht - AI Act Page refactored: Tabs (Übersicht | Entscheidungsbaum | Ergebnisse) - Proxy-Route für decision-tree Endpoints Migration 083: ai_act_decision_tree_results Tabelle Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ucca/decision-tree/[[...path]]/route.ts | 57 ++ .../api/sdk/v1/ucca/decision-tree/route.ts | 36 ++ admin-compliance/app/sdk/ai-act/page.tsx | 388 +++++++++--- .../sdk/ai-act/DecisionTreeWizard.tsx | 554 ++++++++++++++++++ ai-compliance-sdk/cmd/server/main.go | 10 + .../internal/api/handlers/ucca_handlers.go | 108 ++++ .../internal/ucca/decision_tree_engine.go | 325 ++++++++++ .../ucca/decision_tree_engine_test.go | 420 +++++++++++++ ai-compliance-sdk/internal/ucca/models.go | 70 +++ ai-compliance-sdk/internal/ucca/store.go | 122 ++++ .../migrations/083_ai_act_decision_tree.sql | 20 + 11 files changed, 2016 insertions(+), 94 deletions(-) create mode 100644 admin-compliance/app/api/sdk/v1/ucca/decision-tree/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts create mode 100644 admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx create mode 100644 ai-compliance-sdk/internal/ucca/decision_tree_engine.go create mode 100644 ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go create mode 100644 backend-compliance/migrations/083_ai_act_decision_tree.sql diff --git a/admin-compliance/app/api/sdk/v1/ucca/decision-tree/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/ucca/decision-tree/[[...path]]/route.ts new file mode 100644 index 0000000..ddbb733 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/ucca/decision-tree/[[...path]]/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' + +const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090' +const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + +/** + * Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/... + */ +async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params + const subPath = path ? path.join('/') : '' + const search = request.nextUrl.search || '' + const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}` + + const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT + + try { + const headers: Record = { + 'X-Tenant-ID': tenantID, + } + + const fetchOptions: RequestInit = { + method: request.method, + headers, + } + + if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') { + const body = await request.json() + headers['Content-Type'] = 'application/json' + fetchOptions.body = JSON.stringify(body) + } + + const response = await fetch(targetUrl, fetchOptions) + + if (!response.ok) { + const errorText = await response.text() + console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText) + return NextResponse.json( + { error: 'Backend error', details: errorText }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data, { status: response.status }) + } catch (error) { + console.error('Decision tree proxy connection error:', error) + return NextResponse.json( + { error: 'Failed to connect to AI compliance backend' }, + { status: 503 } + ) + } +} + +export const GET = proxyRequest +export const POST = proxyRequest +export const DELETE = proxyRequest diff --git a/admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts b/admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts new file mode 100644 index 0000000..d8b1c33 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server' + +const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090' +const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' + +/** + * Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree + * Returns the decision tree definition (questions, structure) + */ +export async function GET(request: NextRequest) { + const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT + + try { + const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, { + headers: { 'X-Tenant-ID': tenantID }, + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Decision tree GET error:', errorText) + return NextResponse.json( + { error: 'Backend error', details: errorText }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Decision tree proxy error:', error) + return NextResponse.json( + { error: 'Failed to connect to AI compliance backend' }, + { status: 503 } + ) + } +} diff --git a/admin-compliance/app/sdk/ai-act/page.tsx b/admin-compliance/app/sdk/ai-act/page.tsx index 0572ac7..8627b9b 100644 --- a/admin-compliance/app/sdk/ai-act/page.tsx +++ b/admin-compliance/app/sdk/ai-act/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react' import { useSDK } from '@/lib/sdk' import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader' +import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard' // ============================================================================= // TYPES @@ -21,6 +22,8 @@ interface AISystem { assessmentResult: Record | null } +type TabId = 'overview' | 'decision-tree' | 'results' + // ============================================================================= // LOADING SKELETON // ============================================================================= @@ -306,12 +309,178 @@ function AISystemCard({ ) } +// ============================================================================= +// SAVED RESULTS TAB +// ============================================================================= + +interface SavedResult { + id: string + system_name: string + system_description?: string + high_risk_result: string + gpai_result: { gpai_category: string; is_systemic_risk: boolean } + combined_obligations: string[] + created_at: string +} + +function SavedResultsTab() { + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const load = async () => { + try { + const res = await fetch('/api/sdk/v1/ucca/decision-tree/results') + if (res.ok) { + const data = await res.json() + setResults(data.results || []) + } + } catch { + // Ignore + } finally { + setLoading(false) + } + } + load() + }, []) + + const handleDelete = async (id: string) => { + if (!confirm('Ergebnis wirklich löschen?')) return + try { + const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' }) + if (res.ok) { + setResults(prev => prev.filter(r => r.id !== id)) + } + } catch { + // Ignore + } + } + + const riskLabels: Record = { + unacceptable: 'Unzulässig', + high_risk: 'Hochrisiko', + limited_risk: 'Begrenztes Risiko', + minimal_risk: 'Minimales Risiko', + not_applicable: 'Nicht anwendbar', + } + + const riskColors: Record = { + unacceptable: 'bg-red-100 text-red-700', + high_risk: 'bg-orange-100 text-orange-700', + limited_risk: 'bg-yellow-100 text-yellow-700', + minimal_risk: 'bg-green-100 text-green-700', + not_applicable: 'bg-gray-100 text-gray-500', + } + + const gpaiLabels: Record = { + none: 'Kein GPAI', + standard: 'GPAI Standard', + systemic: 'GPAI Systemisch', + } + + const gpaiColors: Record = { + none: 'bg-gray-100 text-gray-500', + standard: 'bg-blue-100 text-blue-700', + systemic: 'bg-purple-100 text-purple-700', + } + + if (loading) { + return + } + + if (results.length === 0) { + return ( +
+
+ + + +
+

Keine Ergebnisse vorhanden

+

Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.

+
+ ) + } + + return ( +
+ {results.map(r => ( +
+
+
+

{r.system_name}

+ {r.system_description && ( +

{r.system_description}

+ )} +
+ + {riskLabels[r.high_risk_result] || r.high_risk_result} + + + {gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'} + + {r.gpai_result?.is_systemic_risk && ( + Systemisch + )} +
+
+ {r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')} +
+
+ +
+
+ ))} +
+ ) +} + +// ============================================================================= +// TABS +// ============================================================================= + +const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [ + { + id: 'overview', + label: 'Übersicht', + icon: ( + + + + ), + }, + { + id: 'decision-tree', + label: 'Entscheidungsbaum', + icon: ( + + + + ), + }, + { + id: 'results', + label: 'Ergebnisse', + icon: ( + + + + ), + }, +] + // ============================================================================= // MAIN PAGE // ============================================================================= export default function AIActPage() { const { state } = useSDK() + const [activeTab, setActiveTab] = useState('overview') const [systems, setSystems] = useState([]) const [filter, setFilter] = useState('all') const [showAddForm, setShowAddForm] = useState(false) @@ -354,7 +523,6 @@ export default function AIActPage() { const handleAddSystem = async (data: Omit) => { setError(null) if (editingSystem) { - // Edit existing system via PUT try { const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, { method: 'PUT', @@ -380,14 +548,12 @@ export default function AIActPage() { setError('Speichern fehlgeschlagen') } } catch { - // Fallback: update locally setSystems(prev => prev.map(s => s.id === editingSystem.id ? { ...s, ...data } : s )) } setEditingSystem(null) } else { - // Create new system via POST try { const res = await fetch('/api/sdk/v1/compliance/ai/systems', { method: 'POST', @@ -415,7 +581,6 @@ export default function AIActPage() { setError('Registrierung fehlgeschlagen') } } catch { - // Fallback: add locally const newSystem: AISystem = { ...data, id: `ai-${Date.now()}`, @@ -503,17 +668,37 @@ export default function AIActPage() { explanation={stepInfo.explanation} tips={stepInfo.tips} > - + {activeTab === 'overview' && ( + + )} + {/* Tabs */} +
+ {TABS.map(tab => ( + + ))} +
+ {/* Error Banner */} {error && (
@@ -522,90 +707,105 @@ export default function AIActPage() {
)} - {/* Add/Edit System Form */} - {showAddForm && ( - { setShowAddForm(false); setEditingSystem(null) }} - initialData={editingSystem} - /> - )} - - {/* Stats */} -
-
-
KI-Systeme gesamt
-
{systems.length}
-
-
-
Hochrisiko
-
{highRiskCount}
-
-
-
Konform
-
{compliantCount}
-
-
-
Nicht klassifiziert
-
{unclassifiedCount}
-
-
- - {/* Risk Pyramid */} - - - {/* Filter */} -
- Filter: - {['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => ( - - ))} -
- - {/* Loading */} - {loading && } - - {/* AI Systems List */} - {!loading && ( -
- {filteredSystems.map(system => ( - handleAssess(system.id)} - onEdit={() => handleEdit(system)} - onDelete={() => handleDelete(system.id)} - assessing={assessingId === system.id} + {/* Tab: Overview */} + {activeTab === 'overview' && ( + <> + {/* Add/Edit System Form */} + {showAddForm && ( + { setShowAddForm(false); setEditingSystem(null) }} + initialData={editingSystem} /> - ))} -
+ )} + + {/* Stats */} +
+
+
KI-Systeme gesamt
+
{systems.length}
+
+
+
Hochrisiko
+
{highRiskCount}
+
+
+
Konform
+
{compliantCount}
+
+
+
Nicht klassifiziert
+
{unclassifiedCount}
+
+
+ + {/* Risk Pyramid */} + + + {/* Filter */} +
+ Filter: + {['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => ( + + ))} +
+ + {/* Loading */} + {loading && } + + {/* AI Systems List */} + {!loading && ( +
+ {filteredSystems.map(system => ( + handleAssess(system.id)} + onEdit={() => handleEdit(system)} + onDelete={() => handleDelete(system.id)} + assessing={assessingId === system.id} + /> + ))} +
+ )} + + {!loading && filteredSystems.length === 0 && ( +
+
+ + + +
+

Keine KI-Systeme gefunden

+

Passen Sie den Filter an oder registrieren Sie ein neues KI-System.

+
+ )} + )} - {!loading && filteredSystems.length === 0 && ( -
-
- - - -
-

Keine KI-Systeme gefunden

-

Passen Sie den Filter an oder registrieren Sie ein neues KI-System.

-
+ {/* Tab: Decision Tree */} + {activeTab === 'decision-tree' && ( + + )} + + {/* Tab: Results */} + {activeTab === 'results' && ( + )} ) diff --git a/admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx b/admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx new file mode 100644 index 0000000..c00310d --- /dev/null +++ b/admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx @@ -0,0 +1,554 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' + +// ============================================================================= +// TYPES +// ============================================================================= + +interface DecisionTreeQuestion { + id: string + axis: 'high_risk' | 'gpai' + question: string + description: string + article_ref: string + skip_if?: string +} + +interface DecisionTreeDefinition { + id: string + name: string + version: string + questions: DecisionTreeQuestion[] +} + +interface DecisionTreeAnswer { + question_id: string + value: boolean + note?: string +} + +interface GPAIClassification { + is_gpai: boolean + is_systemic_risk: boolean + gpai_category: 'none' | 'standard' | 'systemic' + applicable_articles: string[] + obligations: string[] +} + +interface DecisionTreeResult { + id: string + tenant_id: string + system_name: string + system_description?: string + answers: Record + high_risk_result: string + gpai_result: GPAIClassification + combined_obligations: string[] + applicable_articles: string[] + created_at: string +} + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +const RISK_LEVEL_CONFIG: Record = { + unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' }, + high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' }, + limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' }, + minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' }, + not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' }, +} + +const GPAI_CONFIG: Record = { + none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' }, + standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' }, + systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' }, +} + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +export default function DecisionTreeWizard() { + const [definition, setDefinition] = useState(null) + const [answers, setAnswers] = useState>({}) + const [currentIdx, setCurrentIdx] = useState(0) + const [systemName, setSystemName] = useState('') + const [systemDescription, setSystemDescription] = useState('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro') + + // Load decision tree definition + useEffect(() => { + const load = async () => { + try { + const res = await fetch('/api/sdk/v1/ucca/decision-tree') + if (res.ok) { + const data = await res.json() + setDefinition(data) + } else { + setError('Entscheidungsbaum konnte nicht geladen werden') + } + } catch { + setError('Verbindung zum Backend fehlgeschlagen') + } finally { + setLoading(false) + } + } + load() + }, []) + + // Get visible questions (respecting skip logic) + const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => { + if (!definition) return [] + return definition.questions.filter(q => { + if (!q.skip_if) return true + // Skip this question if the gate question was answered "no" + const gateAnswer = answers[q.skip_if] + if (gateAnswer && !gateAnswer.value) return false + return true + }) + }, [definition, answers]) + + const visibleQuestions = getVisibleQuestions() + const currentQuestion = visibleQuestions[currentIdx] + const totalVisible = visibleQuestions.length + + const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk') + const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai') + + const handleAnswer = (value: boolean) => { + if (!currentQuestion) return + setAnswers(prev => ({ + ...prev, + [currentQuestion.id]: { + question_id: currentQuestion.id, + value, + }, + })) + + // Auto-advance + if (currentIdx < totalVisible - 1) { + setCurrentIdx(prev => prev + 1) + } + } + + const handleBack = () => { + if (currentIdx > 0) { + setCurrentIdx(prev => prev - 1) + } + } + + const handleSubmit = async () => { + setSaving(true) + setError(null) + + try { + const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system_name: systemName, + system_description: systemDescription, + answers, + }), + }) + + if (res.ok) { + const data = await res.json() + setResult(data) + setPhase('result') + } else { + const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' })) + setError(err.error || 'Auswertung fehlgeschlagen') + } + } catch { + setError('Verbindung zum Backend fehlgeschlagen') + } finally { + setSaving(false) + } + } + + const handleReset = () => { + setAnswers({}) + setCurrentIdx(0) + setSystemName('') + setSystemDescription('') + setResult(null) + setPhase('intro') + setError(null) + } + + const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined) + + if (loading) { + return ( +
+
+

Entscheidungsbaum wird geladen...

+
+ ) + } + + if (error && !definition) { + return ( +
+

{error}

+

Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.

+
+ ) + } + + // ========================================================================= + // INTRO PHASE + // ========================================================================= + if (phase === 'intro') { + return ( +
+
+

AI Act Entscheidungsbaum

+

+ Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen: + High-Risk (Anhang III) und GPAI (Art. 51–56). +

+ +
+
+
+ + + + Achse 1: High-Risk +
+

7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)

+
+
+
+ + + + Achse 2: GPAI +
+

5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 51–56)

+
+
+ +
+
+ + setSystemName(e.target.value)} + placeholder="z.B. Dokumenten-Analyse-KI, Chatbot-Service, Code-Assistent" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+
+ +