feat: AI Act Decision Tree — Zwei-Achsen-Klassifikation (GPAI + High-Risk)

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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-29 10:14:09 +02:00
parent 712fa8cb74
commit bc75b4455d
11 changed files with 2016 additions and 94 deletions

View File

@@ -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<string, DecisionTreeAnswer>
high_risk_result: string
gpai_result: GPAIClassification
combined_obligations: string[]
applicable_articles: string[]
created_at: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
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<string, { label: string; color: string; bg: string; border: string }> = {
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<DecisionTreeDefinition | null>(null)
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
const [currentIdx, setCurrentIdx] = useState(0)
const [systemName, setSystemName] = useState('')
const [systemDescription, setSystemDescription] = useState('')
const [result, setResult] = useState<DecisionTreeResult | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
</div>
)
}
if (error && !definition) {
return (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<p className="text-red-700">{error}</p>
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
</div>
)
}
// =========================================================================
// INTRO PHASE
// =========================================================================
if (phase === 'intro') {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
<p className="text-sm text-gray-500 mb-6">
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 5156).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z" />
</svg>
<span className="font-medium text-orange-700">Achse 1: High-Risk</span>
</div>
<p className="text-sm text-orange-600">7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)</p>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
<span className="font-medium text-blue-700">Achse 2: GPAI</span>
</div>
<p className="text-sm text-blue-600">5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 5156)</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Systems *</label>
<input
type="text"
value={systemName}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung (optional)</label>
<textarea
value={systemDescription}
onChange={e => setSystemDescription(e.target.value)}
placeholder="Kurze Beschreibung des KI-Systems und seines Einsatzzwecks..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setPhase('questions')}
disabled={!systemName.trim()}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
systemName.trim()
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Klassifizierung starten
</button>
</div>
</div>
</div>
)
}
// =========================================================================
// RESULT PHASE
// =========================================================================
if (phase === 'result' && result) {
const riskConfig = RISK_LEVEL_CONFIG[result.high_risk_result] || RISK_LEVEL_CONFIG.not_applicable
const gpaiConfig = GPAI_CONFIG[result.gpai_result.gpai_category] || GPAI_CONFIG.none
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Klassifizierungsergebnis: {result.system_name}</h3>
<button
onClick={handleReset}
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Neue Klassifizierung
</button>
</div>
{/* Two-Axis Result Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className={`p-5 rounded-xl border-2 ${riskConfig.border} ${riskConfig.bg}`}>
<div className="text-sm font-medium text-gray-500 mb-1">Achse 1: High-Risk (Anhang III)</div>
<div className={`text-xl font-bold ${riskConfig.color}`}>{riskConfig.label}</div>
</div>
<div className={`p-5 rounded-xl border-2 ${gpaiConfig.border} ${gpaiConfig.bg}`}>
<div className="text-sm font-medium text-gray-500 mb-1">Achse 2: GPAI (Art. 5156)</div>
<div className={`text-xl font-bold ${gpaiConfig.color}`}>{gpaiConfig.label}</div>
{result.gpai_result.is_systemic_risk && (
<div className="mt-1 text-xs text-purple-600 font-medium">Systemisches Risiko</div>
)}
</div>
</div>
</div>
{/* Applicable Articles */}
{result.applicable_articles && result.applicable_articles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">Anwendbare Artikel</h4>
<div className="flex flex-wrap gap-2">
{result.applicable_articles.map(art => (
<span key={art} className="px-3 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-200">
{art}
</span>
))}
</div>
</div>
)}
{/* Combined Obligations */}
{result.combined_obligations && result.combined_obligations.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">
Pflichten ({result.combined_obligations.length})
</h4>
<div className="space-y-2">
{result.combined_obligations.map((obl, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<svg className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-gray-700">{obl}</span>
</div>
))}
</div>
</div>
)}
{/* GPAI-specific obligations */}
{result.gpai_result.is_gpai && result.gpai_result.obligations.length > 0 && (
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
<h4 className="text-sm font-semibold text-blue-900 mb-3">
GPAI-spezifische Pflichten ({result.gpai_result.obligations.length})
</h4>
<div className="space-y-2">
{result.gpai_result.obligations.map((obl, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<svg className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
<span className="text-blue-800">{obl}</span>
</div>
))}
</div>
</div>
)}
{/* Answer Summary */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">Ihre Antworten</h4>
<div className="space-y-2">
{definition?.questions.map(q => {
const answer = result.answers[q.id]
if (!answer) return null
return (
<div key={q.id} className="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0">
<span className="text-xs font-mono text-gray-400 w-8">{q.id}</span>
<span className="flex-1 text-gray-600">{q.question}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
answer.value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{answer.value ? 'Ja' : 'Nein'}
</span>
</div>
)
})}
</div>
</div>
</div>
)
}
// =========================================================================
// QUESTIONS PHASE
// =========================================================================
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">
{systemName} Frage {currentIdx + 1} von {totalVisible}
</span>
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
currentQuestion?.axis === 'high_risk'
? 'bg-orange-100 text-orange-700'
: 'bg-blue-100 text-blue-700'
}`}>
{currentQuestion?.axis === 'high_risk' ? 'High-Risk' : 'GPAI'}
</span>
</div>
{/* Dual progress bar */}
<div className="flex gap-2">
<div className="flex-1">
<div className="text-[10px] text-orange-600 mb-1 font-medium">
Achse 1: High-Risk ({highRiskQuestions.filter(q => answers[q.id] !== undefined).length}/{highRiskQuestions.length})
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 rounded-full transition-all"
style={{ width: `${highRiskQuestions.length ? (highRiskQuestions.filter(q => answers[q.id] !== undefined).length / highRiskQuestions.length) * 100 : 0}%` }}
/>
</div>
</div>
<div className="flex-1">
<div className="text-[10px] text-blue-600 mb-1 font-medium">
Achse 2: GPAI ({gpaiQuestions.filter(q => answers[q.id] !== undefined).length}/{gpaiQuestions.length})
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${gpaiQuestions.length ? (gpaiQuestions.filter(q => answers[q.id] !== undefined).length / gpaiQuestions.length) * 100 : 0}%` }}
/>
</div>
</div>
</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">&times;</button>
</div>
)}
{/* Current Question */}
{currentQuestion && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start gap-3 mb-4">
<span className="px-2 py-1 text-xs font-mono bg-gray-100 text-gray-500 rounded">{currentQuestion.id}</span>
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">{currentQuestion.article_ref}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">{currentQuestion.question}</h3>
<p className="text-sm text-gray-500 mb-6">{currentQuestion.description}</p>
{/* Answer buttons */}
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleAnswer(true)}
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
answers[currentQuestion.id]?.value === true
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-200 hover:border-green-300 hover:bg-green-50/50 text-gray-700'
}`}
>
<svg className="w-8 h-8 mx-auto mb-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Ja
</button>
<button
onClick={() => handleAnswer(false)}
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
answers[currentQuestion.id]?.value === false
? 'border-gray-500 bg-gray-50 text-gray-700'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 text-gray-700'
}`}
>
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Nein
</button>
</div>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={currentIdx === 0 ? () => setPhase('intro') : handleBack}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Zurück
</button>
<div className="flex items-center gap-1">
{visibleQuestions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentIdx(i)}
className={`w-2.5 h-2.5 rounded-full transition-colors ${
i === currentIdx
? q.axis === 'high_risk' ? 'bg-orange-500' : 'bg-blue-500'
: answers[q.id] !== undefined
? 'bg-green-400'
: 'bg-gray-200'
}`}
title={`${q.id}: ${q.question}`}
/>
))}
</div>
{allAnswered ? (
<button
onClick={handleSubmit}
disabled={saving}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
saving
? 'bg-purple-300 text-white cursor-wait'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{saving ? (
<span className="flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Auswertung...
</span>
) : (
'Auswerten'
)}
</button>
) : (
<button
onClick={() => setCurrentIdx(prev => Math.min(prev + 1, totalVisible - 1))}
disabled={currentIdx >= totalVisible - 1}
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-30"
>
Weiter
</button>
)}
</div>
</div>
)
}