feat: EU AI Database Registration (Art. 49) — Backend + Frontend
Backend (Go): - DB Migration 023: ai_system_registrations Tabelle - RegistrationStore: CRUD + Status-Management + Export-JSON - RegistrationHandlers: 7 Endpoints (Create, List, Get, Update, Status, Prefill, Export) - Routes in main.go: /sdk/v1/ai-registration/* Frontend (Next.js): - 6-Step Wizard: Anbieter → System → Klassifikation → Konformitaet → Trainingsdaten → Pruefung - System-Karten mit Status-Badges (Entwurf/Bereit/Eingereicht/Registriert) - JSON-Export fuer EU-Datenbank-Submission - Status-Workflow: draft → ready → submitted → registered - API Proxy Routes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const body = await request.json()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const body = await request.json()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Registration {
|
||||||
|
id: string
|
||||||
|
system_name: string
|
||||||
|
system_version: string
|
||||||
|
risk_classification: string
|
||||||
|
gpai_classification: string
|
||||||
|
registration_status: string
|
||||||
|
eu_database_id: string
|
||||||
|
provider_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||||
|
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||||
|
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||||
|
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||||
|
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||||
|
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||||
|
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||||
|
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||||
|
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
|
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM = {
|
||||||
|
system_name: '',
|
||||||
|
system_version: '1.0',
|
||||||
|
system_description: '',
|
||||||
|
intended_purpose: '',
|
||||||
|
provider_name: '',
|
||||||
|
provider_legal_form: '',
|
||||||
|
provider_address: '',
|
||||||
|
provider_country: 'DE',
|
||||||
|
eu_representative_name: '',
|
||||||
|
eu_representative_contact: '',
|
||||||
|
risk_classification: 'not_classified',
|
||||||
|
annex_iii_category: '',
|
||||||
|
gpai_classification: 'none',
|
||||||
|
conformity_assessment_type: 'internal',
|
||||||
|
notified_body_name: '',
|
||||||
|
notified_body_id: '',
|
||||||
|
ce_marking: false,
|
||||||
|
training_data_summary: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIRegistrationPage() {
|
||||||
|
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showWizard, setShowWizard] = useState(false)
|
||||||
|
const [wizardStep, setWizardStep] = useState(1)
|
||||||
|
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => { loadRegistrations() }, [])
|
||||||
|
|
||||||
|
async function loadRegistrations() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json()
|
||||||
|
setRegistrations(data.registrations || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Laden')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
setShowWizard(false)
|
||||||
|
setForm({ ...INITIAL_FORM })
|
||||||
|
setWizardStep(1)
|
||||||
|
loadRegistrations()
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
setError(data.error || 'Fehler beim Erstellen')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport(id: string) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||||
|
if (resp.ok) {
|
||||||
|
const reg = await resp.json()
|
||||||
|
// Build export JSON client-side
|
||||||
|
const exportData = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
submission_type: 'ai_system_registration',
|
||||||
|
regulation: 'EU AI Act (EU) 2024/1689',
|
||||||
|
article: 'Art. 49',
|
||||||
|
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||||
|
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||||
|
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||||
|
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Export fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(id: string, status: string) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
loadRegistrations()
|
||||||
|
} catch {
|
||||||
|
setError('Status-Aenderung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||||
|
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||||
|
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||||
|
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||||
|
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||||
|
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Neue Registrierung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||||
|
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||||
|
const count = registrations.filter(r => r.registration_status === status).length
|
||||||
|
const style = STATUS_STYLES[status]
|
||||||
|
return (
|
||||||
|
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||||
|
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||||
|
<div className="text-sm text-gray-600">{style.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registrations List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||||
|
) : registrations.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||||
|
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{registrations.map(reg => {
|
||||||
|
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||||
|
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||||
|
return (
|
||||||
|
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||||
|
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||||
|
{reg.gpai_classification !== 'none' && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||||
|
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||||
|
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
JSON Export
|
||||||
|
</button>
|
||||||
|
{reg.registration_status === 'draft' && (
|
||||||
|
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
Bereit markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{reg.registration_status === 'ready' && (
|
||||||
|
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||||
|
Als eingereicht markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wizard Modal */}
|
||||||
|
{showWizard && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||||
|
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||||
|
</div>
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STEPS.map(step => (
|
||||||
|
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||||
|
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||||
|
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||||
|
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Step 1: Provider */}
|
||||||
|
{wizardStep === 1 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||||
|
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||||
|
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||||
|
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||||
|
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||||
|
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="DE">Deutschland</option>
|
||||||
|
<option value="AT">Oesterreich</option>
|
||||||
|
<option value="CH">Schweiz</option>
|
||||||
|
<option value="OTHER">Anderes Land</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||||
|
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: System */}
|
||||||
|
{wizardStep === 2 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||||
|
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||||
|
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||||
|
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||||
|
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Classification */}
|
||||||
|
{wizardStep === 3 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||||
|
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||||
|
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||||
|
<option value="minimal_risk">Minimal Risk</option>
|
||||||
|
<option value="limited_risk">Limited Risk</option>
|
||||||
|
<option value="high_risk">High Risk</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.risk_classification === 'high_risk' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||||
|
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="">Bitte waehlen...</option>
|
||||||
|
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||||
|
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||||
|
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||||
|
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||||
|
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||||
|
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||||
|
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||||
|
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||||
|
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="none">Kein GPAI</option>
|
||||||
|
<option value="standard">GPAI (Standard)</option>
|
||||||
|
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||||
|
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Conformity */}
|
||||||
|
{wizardStep === 4 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||||
|
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="not_required">Nicht erforderlich</option>
|
||||||
|
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||||
|
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.conformity_assessment_type === 'third_party' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||||
|
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border 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">Notified Body ID</label>
|
||||||
|
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Training Data */}
|
||||||
|
{wizardStep === 5 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||||
|
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||||
|
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Review */}
|
||||||
|
{wizardStep === 6 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||||
|
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||||
|
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||||
|
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||||
|
</div>
|
||||||
|
{form.intended_purpose && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||||
|
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="p-6 border-t flex justify-between">
|
||||||
|
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||||
|
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
|
</button>
|
||||||
|
{wizardStep < 6 ? (
|
||||||
|
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||||
|
disabled={wizardStep === 2 && !form.system_name}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -104,6 +104,8 @@ func main() {
|
|||||||
auditHandlers := handlers.NewAuditHandlers(auditStore, exporter)
|
auditHandlers := handlers.NewAuditHandlers(auditStore, exporter)
|
||||||
uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry)
|
uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry)
|
||||||
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
|
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
|
||||||
|
registrationStore := ucca.NewRegistrationStore(pool)
|
||||||
|
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
|
||||||
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
||||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||||
@@ -284,6 +286,18 @@ func main() {
|
|||||||
obligationsHandlers.RegisterRoutes(uccaRoutes)
|
obligationsHandlers.RegisterRoutes(uccaRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Registration routes - EU AI Database (Art. 49)
|
||||||
|
regRoutes := v1.Group("/ai-registration")
|
||||||
|
{
|
||||||
|
regRoutes.POST("", registrationHandlers.Create)
|
||||||
|
regRoutes.GET("", registrationHandlers.List)
|
||||||
|
regRoutes.GET("/:id", registrationHandlers.Get)
|
||||||
|
regRoutes.PUT("/:id", registrationHandlers.Update)
|
||||||
|
regRoutes.PATCH("/:id/status", registrationHandlers.UpdateStatus)
|
||||||
|
regRoutes.POST("/prefill/:assessment_id", registrationHandlers.Prefill)
|
||||||
|
regRoutes.GET("/:id/export", registrationHandlers.Export)
|
||||||
|
}
|
||||||
|
|
||||||
// RAG routes - Legal Corpus Search & Versioning
|
// RAG routes - Legal Corpus Search & Versioning
|
||||||
ragRoutes := v1.Group("/rag")
|
ragRoutes := v1.Group("/rag")
|
||||||
{
|
{
|
||||||
|
|||||||
225
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
225
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistrationHandlers handles EU AI Database registration endpoints
|
||||||
|
type RegistrationHandlers struct {
|
||||||
|
store *ucca.RegistrationStore
|
||||||
|
uccaStore *ucca.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistrationHandlers creates new registration handlers
|
||||||
|
func NewRegistrationHandlers(store *ucca.RegistrationStore, uccaStore *ucca.Store) *RegistrationHandlers {
|
||||||
|
return &RegistrationHandlers{store: store, uccaStore: uccaStore}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new registration
|
||||||
|
func (h *RegistrationHandlers) Create(c *gin.Context) {
|
||||||
|
var reg ucca.AIRegistration
|
||||||
|
if err := c.ShouldBindJSON(®); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||||
|
if tenantID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reg.TenantID = tenantID
|
||||||
|
|
||||||
|
if reg.SystemName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "system_name required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.Create(c.Request.Context(), ®); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registration: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List lists all registrations for the tenant
|
||||||
|
func (h *RegistrationHandlers) List(c *gin.Context) {
|
||||||
|
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||||
|
if tenantID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations, err := h.store.List(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registrations: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if registrations == nil {
|
||||||
|
registrations = []ucca.AIRegistration{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"registrations": registrations, "total": len(registrations)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single registration
|
||||||
|
func (h *RegistrationHandlers) Get(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := h.store.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a registration
|
||||||
|
func (h *RegistrationHandlers) Update(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := h.store.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates ucca.AIRegistration
|
||||||
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge updates into existing
|
||||||
|
updates.ID = existing.ID
|
||||||
|
updates.TenantID = existing.TenantID
|
||||||
|
updates.CreatedAt = existing.CreatedAt
|
||||||
|
|
||||||
|
if err := h.store.Update(c.Request.Context(), &updates); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus changes the registration status
|
||||||
|
func (h *RegistrationHandlers) UpdateStatus(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
SubmittedBy string `json:"submitted_by"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validStatuses := map[string]bool{
|
||||||
|
"draft": true, "ready": true, "submitted": true,
|
||||||
|
"registered": true, "update_required": true, "withdrawn": true,
|
||||||
|
}
|
||||||
|
if !validStatuses[body.Status] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status. Valid: draft, ready, submitted, registered, update_required, withdrawn"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.UpdateStatus(c.Request.Context(), id, body.Status, body.SubmittedBy); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id, "status": body.Status})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill creates a registration pre-filled from a UCCA assessment
|
||||||
|
func (h *RegistrationHandlers) Prefill(c *gin.Context) {
|
||||||
|
assessmentID, err := uuid.Parse(c.Param("assessment_id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||||
|
if tenantID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load UCCA assessment
|
||||||
|
assessment, err := h.uccaStore.GetAssessment(c.Request.Context(), assessmentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract intake data
|
||||||
|
var intake ucca.UseCaseIntake
|
||||||
|
if assessment.Intake != nil {
|
||||||
|
json.Unmarshal(assessment.Intake, &intake)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill registration from assessment
|
||||||
|
reg := ucca.AIRegistration{
|
||||||
|
TenantID: tenantID,
|
||||||
|
SystemName: intake.Title,
|
||||||
|
SystemDescription: intake.UseCaseText,
|
||||||
|
IntendedPurpose: intake.UseCaseText,
|
||||||
|
RiskClassification: string(assessment.RiskLevel),
|
||||||
|
GPAIClassification: "none",
|
||||||
|
RegistrationStatus: "draft",
|
||||||
|
UCCAAssessmentID: &assessmentID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map domain to readable text
|
||||||
|
if intake.Domain != "" {
|
||||||
|
reg.IntendedPurpose = string(intake.Domain) + ": " + intake.UseCaseText
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, reg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export generates the EU AI Database submission JSON
|
||||||
|
func (h *RegistrationHandlers) Export(c *gin.Context) {
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := h.store.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON := h.store.BuildExportJSON(reg)
|
||||||
|
|
||||||
|
// Save export data to DB
|
||||||
|
reg.ExportData = exportJSON
|
||||||
|
h.store.Update(c.Request.Context(), reg)
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=eu_ai_registration_"+reg.SystemName+".json")
|
||||||
|
c.Data(http.StatusOK, "application/json", exportJSON)
|
||||||
|
}
|
||||||
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIRegistration represents an EU AI Database registration entry
|
||||||
|
type AIRegistration struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
|
||||||
|
// System
|
||||||
|
SystemName string `json:"system_name"`
|
||||||
|
SystemVersion string `json:"system_version,omitempty"`
|
||||||
|
SystemDescription string `json:"system_description,omitempty"`
|
||||||
|
IntendedPurpose string `json:"intended_purpose,omitempty"`
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
ProviderName string `json:"provider_name,omitempty"`
|
||||||
|
ProviderLegalForm string `json:"provider_legal_form,omitempty"`
|
||||||
|
ProviderAddress string `json:"provider_address,omitempty"`
|
||||||
|
ProviderCountry string `json:"provider_country,omitempty"`
|
||||||
|
EURepresentativeName string `json:"eu_representative_name,omitempty"`
|
||||||
|
EURepresentativeContact string `json:"eu_representative_contact,omitempty"`
|
||||||
|
|
||||||
|
// Classification
|
||||||
|
RiskClassification string `json:"risk_classification"`
|
||||||
|
AnnexIIICategory string `json:"annex_iii_category,omitempty"`
|
||||||
|
GPAIClassification string `json:"gpai_classification"`
|
||||||
|
|
||||||
|
// Conformity
|
||||||
|
ConformityAssessmentType string `json:"conformity_assessment_type,omitempty"`
|
||||||
|
NotifiedBodyName string `json:"notified_body_name,omitempty"`
|
||||||
|
NotifiedBodyID string `json:"notified_body_id,omitempty"`
|
||||||
|
CEMarking bool `json:"ce_marking"`
|
||||||
|
|
||||||
|
// Training data
|
||||||
|
TrainingDataCategories json.RawMessage `json:"training_data_categories,omitempty"`
|
||||||
|
TrainingDataSummary string `json:"training_data_summary,omitempty"`
|
||||||
|
|
||||||
|
// Status
|
||||||
|
RegistrationStatus string `json:"registration_status"`
|
||||||
|
EUDatabaseID string `json:"eu_database_id,omitempty"`
|
||||||
|
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||||
|
LastUpdateDate *time.Time `json:"last_update_date,omitempty"`
|
||||||
|
|
||||||
|
// Links
|
||||||
|
UCCAAssessmentID *uuid.UUID `json:"ucca_assessment_id,omitempty"`
|
||||||
|
DecisionTreeResultID *uuid.UUID `json:"decision_tree_result_id,omitempty"`
|
||||||
|
|
||||||
|
// Export
|
||||||
|
ExportData json.RawMessage `json:"export_data,omitempty"`
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
SubmittedBy string `json:"submitted_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationStore handles AI registration persistence
|
||||||
|
type RegistrationStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistrationStore creates a new registration store
|
||||||
|
func NewRegistrationStore(pool *pgxpool.Pool) *RegistrationStore {
|
||||||
|
return &RegistrationStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new registration
|
||||||
|
func (s *RegistrationStore) Create(ctx context.Context, r *AIRegistration) error {
|
||||||
|
r.ID = uuid.New()
|
||||||
|
r.CreatedAt = time.Now()
|
||||||
|
r.UpdatedAt = time.Now()
|
||||||
|
if r.RegistrationStatus == "" {
|
||||||
|
r.RegistrationStatus = "draft"
|
||||||
|
}
|
||||||
|
if r.RiskClassification == "" {
|
||||||
|
r.RiskClassification = "not_classified"
|
||||||
|
}
|
||||||
|
if r.GPAIClassification == "" {
|
||||||
|
r.GPAIClassification = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO ai_system_registrations (
|
||||||
|
id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||||
|
provider_name, provider_legal_form, provider_address, provider_country,
|
||||||
|
eu_representative_name, eu_representative_contact,
|
||||||
|
risk_classification, annex_iii_category, gpai_classification,
|
||||||
|
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||||
|
training_data_categories, training_data_summary,
|
||||||
|
registration_status, ucca_assessment_id, decision_tree_result_id,
|
||||||
|
created_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||||
|
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25
|
||||||
|
)`,
|
||||||
|
r.ID, r.TenantID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||||
|
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||||
|
r.EURepresentativeName, r.EURepresentativeContact,
|
||||||
|
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||||
|
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||||
|
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||||
|
r.RegistrationStatus, r.UCCAAssessmentID, r.DecisionTreeResultID,
|
||||||
|
r.CreatedBy,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all registrations for a tenant
|
||||||
|
func (s *RegistrationStore) List(ctx context.Context, tenantID uuid.UUID) ([]AIRegistration, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||||
|
provider_name, provider_legal_form, provider_address, provider_country,
|
||||||
|
eu_representative_name, eu_representative_contact,
|
||||||
|
risk_classification, annex_iii_category, gpai_classification,
|
||||||
|
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||||
|
training_data_categories, training_data_summary,
|
||||||
|
registration_status, eu_database_id, registration_date, last_update_date,
|
||||||
|
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||||
|
created_at, updated_at, created_by, submitted_by
|
||||||
|
FROM ai_system_registrations
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var registrations []AIRegistration
|
||||||
|
for rows.Next() {
|
||||||
|
var r AIRegistration
|
||||||
|
err := rows.Scan(
|
||||||
|
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||||
|
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||||
|
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||||
|
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||||
|
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||||
|
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||||
|
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||||
|
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registrations = append(registrations, r)
|
||||||
|
}
|
||||||
|
return registrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a registration by ID
|
||||||
|
func (s *RegistrationStore) GetByID(ctx context.Context, id uuid.UUID) (*AIRegistration, error) {
|
||||||
|
var r AIRegistration
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||||
|
provider_name, provider_legal_form, provider_address, provider_country,
|
||||||
|
eu_representative_name, eu_representative_contact,
|
||||||
|
risk_classification, annex_iii_category, gpai_classification,
|
||||||
|
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||||
|
training_data_categories, training_data_summary,
|
||||||
|
registration_status, eu_database_id, registration_date, last_update_date,
|
||||||
|
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||||
|
created_at, updated_at, created_by, submitted_by
|
||||||
|
FROM ai_system_registrations
|
||||||
|
WHERE id = $1`,
|
||||||
|
id,
|
||||||
|
).Scan(
|
||||||
|
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||||
|
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||||
|
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||||
|
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||||
|
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||||
|
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||||
|
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||||
|
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a registration
|
||||||
|
func (s *RegistrationStore) Update(ctx context.Context, r *AIRegistration) error {
|
||||||
|
r.UpdatedAt = time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE ai_system_registrations SET
|
||||||
|
system_name = $2, system_version = $3, system_description = $4, intended_purpose = $5,
|
||||||
|
provider_name = $6, provider_legal_form = $7, provider_address = $8, provider_country = $9,
|
||||||
|
eu_representative_name = $10, eu_representative_contact = $11,
|
||||||
|
risk_classification = $12, annex_iii_category = $13, gpai_classification = $14,
|
||||||
|
conformity_assessment_type = $15, notified_body_name = $16, notified_body_id = $17, ce_marking = $18,
|
||||||
|
training_data_categories = $19, training_data_summary = $20,
|
||||||
|
registration_status = $21, eu_database_id = $22,
|
||||||
|
export_data = $23, updated_at = $24, submitted_by = $25
|
||||||
|
WHERE id = $1`,
|
||||||
|
r.ID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||||
|
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||||
|
r.EURepresentativeName, r.EURepresentativeContact,
|
||||||
|
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||||
|
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||||
|
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||||
|
r.RegistrationStatus, r.EUDatabaseID,
|
||||||
|
r.ExportData, r.UpdatedAt, r.SubmittedBy,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus changes only the registration status
|
||||||
|
func (s *RegistrationStore) UpdateStatus(ctx context.Context, id uuid.UUID, status string, submittedBy string) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE ai_system_registrations
|
||||||
|
SET registration_status = $2, submitted_by = $3, updated_at = $4,
|
||||||
|
registration_date = CASE WHEN $2 = 'submitted' THEN $4 ELSE registration_date END,
|
||||||
|
last_update_date = $4
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, status, submittedBy, now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildExportJSON creates the EU AI Database submission JSON
|
||||||
|
func (s *RegistrationStore) BuildExportJSON(r *AIRegistration) json.RawMessage {
|
||||||
|
export := map[string]interface{}{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"submission_type": "ai_system_registration",
|
||||||
|
"regulation": "EU AI Act (EU) 2024/1689",
|
||||||
|
"article": "Art. 49",
|
||||||
|
"provider": map[string]interface{}{
|
||||||
|
"name": r.ProviderName,
|
||||||
|
"legal_form": r.ProviderLegalForm,
|
||||||
|
"address": r.ProviderAddress,
|
||||||
|
"country": r.ProviderCountry,
|
||||||
|
"eu_representative": r.EURepresentativeName,
|
||||||
|
"eu_rep_contact": r.EURepresentativeContact,
|
||||||
|
},
|
||||||
|
"system": map[string]interface{}{
|
||||||
|
"name": r.SystemName,
|
||||||
|
"version": r.SystemVersion,
|
||||||
|
"description": r.SystemDescription,
|
||||||
|
"purpose": r.IntendedPurpose,
|
||||||
|
},
|
||||||
|
"classification": map[string]interface{}{
|
||||||
|
"risk_level": r.RiskClassification,
|
||||||
|
"annex_iii_category": r.AnnexIIICategory,
|
||||||
|
"gpai": r.GPAIClassification,
|
||||||
|
},
|
||||||
|
"conformity": map[string]interface{}{
|
||||||
|
"assessment_type": r.ConformityAssessmentType,
|
||||||
|
"notified_body": r.NotifiedBodyName,
|
||||||
|
"notified_body_id": r.NotifiedBodyID,
|
||||||
|
"ce_marking": r.CEMarking,
|
||||||
|
},
|
||||||
|
"training_data": map[string]interface{}{
|
||||||
|
"categories": r.TrainingDataCategories,
|
||||||
|
"summary": r.TrainingDataSummary,
|
||||||
|
},
|
||||||
|
"status": r.RegistrationStatus,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(export)
|
||||||
|
return data
|
||||||
|
}
|
||||||
65
ai-compliance-sdk/migrations/023_ai_registration_schema.sql
Normal file
65
ai-compliance-sdk/migrations/023_ai_registration_schema.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
-- Migration 023: AI System Registration Schema (Art. 49 AI Act)
|
||||||
|
-- Tracks EU AI Database registrations for High-Risk AI systems
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_system_registrations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- System identification
|
||||||
|
system_name VARCHAR(500) NOT NULL,
|
||||||
|
system_version VARCHAR(100),
|
||||||
|
system_description TEXT,
|
||||||
|
intended_purpose TEXT,
|
||||||
|
|
||||||
|
-- Provider info
|
||||||
|
provider_name VARCHAR(500),
|
||||||
|
provider_legal_form VARCHAR(200),
|
||||||
|
provider_address TEXT,
|
||||||
|
provider_country VARCHAR(10),
|
||||||
|
eu_representative_name VARCHAR(500),
|
||||||
|
eu_representative_contact TEXT,
|
||||||
|
|
||||||
|
-- Classification
|
||||||
|
risk_classification VARCHAR(50) DEFAULT 'not_classified',
|
||||||
|
-- CHECK (risk_classification IN ('not_classified', 'minimal_risk', 'limited_risk', 'high_risk', 'unacceptable'))
|
||||||
|
annex_iii_category VARCHAR(200),
|
||||||
|
gpai_classification VARCHAR(50) DEFAULT 'none',
|
||||||
|
-- CHECK (gpai_classification IN ('none', 'standard', 'systemic'))
|
||||||
|
|
||||||
|
-- Conformity
|
||||||
|
conformity_assessment_type VARCHAR(50),
|
||||||
|
-- CHECK (conformity_assessment_type IN ('internal', 'third_party', 'not_required'))
|
||||||
|
notified_body_name VARCHAR(500),
|
||||||
|
notified_body_id VARCHAR(100),
|
||||||
|
ce_marking BOOLEAN DEFAULT false,
|
||||||
|
|
||||||
|
-- Training data
|
||||||
|
training_data_categories JSONB DEFAULT '[]'::jsonb,
|
||||||
|
training_data_summary TEXT,
|
||||||
|
|
||||||
|
-- Registration status
|
||||||
|
registration_status VARCHAR(50) DEFAULT 'draft',
|
||||||
|
-- CHECK (registration_status IN ('draft', 'ready', 'submitted', 'registered', 'update_required', 'withdrawn'))
|
||||||
|
eu_database_id VARCHAR(200),
|
||||||
|
registration_date TIMESTAMPTZ,
|
||||||
|
last_update_date TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Links to other assessments
|
||||||
|
ucca_assessment_id UUID,
|
||||||
|
decision_tree_result_id UUID,
|
||||||
|
|
||||||
|
-- Export data (cached JSON for EU submission)
|
||||||
|
export_data JSONB,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(200),
|
||||||
|
submitted_by VARCHAR(200)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_tenant ON ai_system_registrations (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_status ON ai_system_registrations (registration_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_classification ON ai_system_registrations (risk_classification);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_ucca ON ai_system_registrations (ucca_assessment_id);
|
||||||
Reference in New Issue
Block a user