Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f528b8e7a9 | |||
| 98243044ca | |||
| fcef07aa16 | |||
| 0c7c70b1b1 | |||
| 16957cadfd | |||
| 3dfe0aa646 | |||
| 2e0f13b22c | |||
| 9a6c297cd6 | |||
| bb0c7d208c | |||
| 7b20e2b006 | |||
| 4ff06eca17 | |||
| 1c2fdf981d | |||
| a2205abea1 | |||
| ef7742cd44 | |||
| 3fe0fc853c | |||
| 8f2cc3b93b | |||
| 753b8f32c7 | |||
| 390d32a9cb | |||
| fc8b6445f3 | |||
| 717c31547a | |||
| 55a2cd4a3d | |||
| 6fcf7c13d7 | |||
| b1300ade3e | |||
| 5d53acf5dc | |||
| f8fd329059 | |||
| 1ac716261c | |||
| 01bf1463b8 | |||
| cc6f1489a3 | |||
| b47d351c73 | |||
| 5231490ccc | |||
| 824b1be6a4 | |||
| 062e827801 | |||
| f404226d6e | |||
| 8dfab4ba14 | |||
| 5c1a514b52 | |||
| e091bbc855 | |||
| ff4c359d46 | |||
| f169b13dbf | |||
| 42d0c7b1fc | |||
| 4fcb842a92 | |||
| 38d3d24121 | |||
| dd64e33e88 | |||
| 2f8269d115 | |||
| 532febe35c | |||
| 0a0863f31c | |||
| d892ad161f | |||
| 17153ccbe8 | |||
| 352d7112c9 | |||
| 0957254547 | |||
| f17608a956 | |||
| ce3df9f080 | |||
| 2da39e035d | |||
| 1989c410a9 | |||
| c55a6ab995 | |||
| bc75b4455d |
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Demo Data Clear API Endpoint
|
||||
*
|
||||
* Clears demo data from the storage (same mechanism as real customer data).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Shared store reference (same as seed endpoint)
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
||||
}
|
||||
|
||||
if (!global.demoStateStore) {
|
||||
global.demoStateStore = new Map()
|
||||
}
|
||||
|
||||
const stateStore = global.demoStateStore
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId = 'demo-tenant' } = body
|
||||
|
||||
const existed = stateStore.has(tenantId)
|
||||
stateStore.delete(tenantId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: existed
|
||||
? `Demo data cleared for tenant ${tenantId}`
|
||||
: `No data found for tenant ${tenantId}`,
|
||||
tenantId,
|
||||
existed,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to clear demo data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Also support POST for clearing (for clients that don't support DELETE)
|
||||
return DELETE(request)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Demo Data Seed API Endpoint
|
||||
*
|
||||
* This endpoint seeds demo data via the same storage mechanism as real customer data.
|
||||
* Demo data is NOT hardcoded - it goes through the normal API/database path.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { generateDemoState } from '@/lib/sdk/demo-data'
|
||||
|
||||
// In-memory store (same as state endpoint - will be replaced with PostgreSQL)
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
||||
}
|
||||
|
||||
if (!global.demoStateStore) {
|
||||
global.demoStateStore = new Map()
|
||||
}
|
||||
|
||||
const stateStore = global.demoStateStore
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId = 'demo-tenant', userId = 'demo-user' } = body
|
||||
|
||||
// Generate demo state using the seed data templates
|
||||
const demoState = generateDemoState(tenantId, userId)
|
||||
|
||||
// Store via the same mechanism as real data
|
||||
const storedState = {
|
||||
state: demoState,
|
||||
version: 1,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
stateStore.set(tenantId, storedState)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Demo data seeded for tenant ${tenantId}`,
|
||||
tenantId,
|
||||
version: 1,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to seed demo data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId') || 'demo-tenant'
|
||||
|
||||
const stored = stateStore.get(tenantId)
|
||||
|
||||
if (!stored) {
|
||||
return NextResponse.json({
|
||||
hasData: false,
|
||||
tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
hasData: true,
|
||||
tenantId,
|
||||
version: stored.version,
|
||||
updatedAt: stored.updatedAt,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
function buildUrl(request: NextRequest, params: { path?: string[] }) {
|
||||
const subPath = params.path?.join('/') || ''
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
return `${SDK_URL}/sdk/v1/maximizer/${subPath}${qs ? `?${qs}` : ''}`
|
||||
}
|
||||
|
||||
function forwardHeaders(request: NextRequest): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID
|
||||
headers['X-User-ID'] = request.headers.get('X-User-ID') || DEFAULT_USER_ID
|
||||
return headers
|
||||
}
|
||||
|
||||
async function proxy(request: NextRequest, params: { path?: string[] }, method: string) {
|
||||
try {
|
||||
const url = buildUrl(request, params)
|
||||
const init: RequestInit = { method, headers: forwardHeaders(request) }
|
||||
if (method !== 'GET' && method !== 'DELETE') {
|
||||
init.body = await request.text()
|
||||
}
|
||||
const response = await fetch(url, init)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Maximizer backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
if (response.status === 204) return new NextResponse(null, { status: 204 })
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Maximizer proxy error:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to Maximizer backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'POST')
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
let path: string
|
||||
switch (endpoint) {
|
||||
case 'controls':
|
||||
const domain = searchParams.get('domain') || ''
|
||||
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
|
||||
break
|
||||
case 'assessments':
|
||||
path = '/sdk/v1/payment-compliance/assessments'
|
||||
break
|
||||
default:
|
||||
path = '/sdk/v1/payment-compliance/controls'
|
||||
}
|
||||
|
||||
const resp = await fetch(`${SDK_URL}${path}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { 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/payment-compliance/assessments`, {
|
||||
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' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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/payment-compliance/tender/${id}`)
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'extract'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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/payment-compliance/tender`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const formData = await request.formData()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
body: formData,
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
const url = `${SDK_URL}/sdk/v1/regulatory-news${qs ? `?${qs}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'SDK error' }, { status: response.status })
|
||||
}
|
||||
return NextResponse.json(await response.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Connection failed' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
@@ -92,11 +92,17 @@ class PostgreSQLStateStore implements StateStore {
|
||||
private pool: Pool
|
||||
|
||||
constructor(connectionString: string) {
|
||||
// Strip sslmode from URL — pg driver overrides our ssl config if it's in the URL.
|
||||
// We handle SSL ourselves via the ssl option below.
|
||||
const cleanUrl = connectionString.replace(/[?&]sslmode=[^&]*/g, '').replace(/\?$/, '')
|
||||
const needsSsl = connectionString.includes('sslmode=require') || connectionString.includes('sslmode=verify')
|
||||
this.pool = new Pool({
|
||||
connectionString,
|
||||
connectionString: cleanUrl,
|
||||
max: 5,
|
||||
// Set search_path for compliance schema
|
||||
options: '-c search_path=compliance,core,public',
|
||||
// Accept self-signed certificates (Hetzner PostgreSQL)
|
||||
ssl: needsSsl ? { rejectUnauthorized: false } : false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/ucca/assess-enriched → Go Backend POST /sdk/v1/ucca/assess-enriched
|
||||
* Accepts { intake, company_profile? } and returns enriched assessment with obligations + hints.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assess-enriched`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('UCCA assess-enriched error:', errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'UCCA backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to call UCCA assess-enriched:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to UCCA backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/assessments/[id] → Go Backend GET /sdk/v1/ucca/assessments/:id
|
||||
@@ -16,9 +17,7 @@ export async function GET(
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -56,9 +55,7 @@ export async function PUT(
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -96,9 +93,7 @@ export async function DELETE(
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/assessments → Go Backend GET /sdk/v1/ucca/assessments
|
||||
@@ -22,9 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||
|
||||
interface Props {
|
||||
result: unknown
|
||||
@@ -35,6 +36,13 @@ export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props)
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
{r.result && r.assessment?.id && (
|
||||
<OptimizerUpsellCard
|
||||
feasibility={(r.result as { feasibility?: string }).feasibility || 'YES'}
|
||||
assessmentId={r.assessment.id}
|
||||
riskScore={(r.result as { risk_score?: number }).risk_score}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,116 @@ export interface AdvisoryForm {
|
||||
custom_data_types: string[]
|
||||
purposes: string[]
|
||||
automation: string
|
||||
// BetrVG / works council
|
||||
employee_monitoring: boolean
|
||||
hr_decision_support: boolean
|
||||
works_council_consulted: boolean
|
||||
// Domain-specific contexts (Annex III)
|
||||
hr_automated_screening: boolean
|
||||
hr_automated_rejection: boolean
|
||||
hr_candidate_ranking: boolean
|
||||
hr_bias_audits: boolean
|
||||
hr_agg_visible: boolean
|
||||
hr_human_review: boolean
|
||||
hr_performance_eval: boolean
|
||||
edu_grade_influence: boolean
|
||||
edu_exam_evaluation: boolean
|
||||
edu_student_selection: boolean
|
||||
edu_minors: boolean
|
||||
edu_teacher_review: boolean
|
||||
hc_diagnosis: boolean
|
||||
hc_treatment: boolean
|
||||
hc_triage: boolean
|
||||
hc_patient_data: boolean
|
||||
hc_medical_device: boolean
|
||||
hc_clinical_validation: boolean
|
||||
// Legal
|
||||
leg_legal_advice: boolean
|
||||
leg_court_prediction: boolean
|
||||
leg_client_confidential: boolean
|
||||
// Public Sector
|
||||
pub_admin_decision: boolean
|
||||
pub_benefit_allocation: boolean
|
||||
pub_transparency: boolean
|
||||
// Critical Infrastructure
|
||||
crit_grid_control: boolean
|
||||
crit_safety_critical: boolean
|
||||
crit_redundancy: boolean
|
||||
// Automotive
|
||||
auto_autonomous: boolean
|
||||
auto_safety: boolean
|
||||
auto_functional_safety: boolean
|
||||
// Retail
|
||||
ret_pricing: boolean
|
||||
ret_profiling: boolean
|
||||
ret_credit_scoring: boolean
|
||||
ret_dark_patterns: boolean
|
||||
// IT Security
|
||||
its_surveillance: boolean
|
||||
its_threat_detection: boolean
|
||||
its_data_retention: boolean
|
||||
// Logistics
|
||||
log_driver_tracking: boolean
|
||||
log_workload_scoring: boolean
|
||||
// Construction
|
||||
con_tenant_screening: boolean
|
||||
con_worker_safety: boolean
|
||||
// Marketing
|
||||
mkt_deepfake: boolean
|
||||
mkt_minors: boolean
|
||||
mkt_targeting: boolean
|
||||
mkt_labeled: boolean
|
||||
// Manufacturing
|
||||
mfg_machine_safety: boolean
|
||||
mfg_ce_required: boolean
|
||||
mfg_validated: boolean
|
||||
// Agriculture
|
||||
agr_pesticide: boolean
|
||||
agr_animal_welfare: boolean
|
||||
agr_environmental: boolean
|
||||
// Social Services
|
||||
soc_vulnerable: boolean
|
||||
soc_benefit: boolean
|
||||
soc_case_mgmt: boolean
|
||||
// Hospitality
|
||||
hos_guest_profiling: boolean
|
||||
hos_dynamic_pricing: boolean
|
||||
hos_review_manipulation: boolean
|
||||
// Insurance
|
||||
ins_risk_class: boolean
|
||||
ins_claims: boolean
|
||||
ins_premium: boolean
|
||||
ins_fraud: boolean
|
||||
// Investment
|
||||
inv_algo_trading: boolean
|
||||
inv_advice: boolean
|
||||
inv_robo: boolean
|
||||
// Defense
|
||||
def_dual_use: boolean
|
||||
def_export: boolean
|
||||
def_classified: boolean
|
||||
// Supply Chain
|
||||
sch_supplier: boolean
|
||||
sch_human_rights: boolean
|
||||
sch_environmental: boolean
|
||||
// Facility
|
||||
fac_access: boolean
|
||||
fac_occupancy: boolean
|
||||
fac_energy: boolean
|
||||
// Sports
|
||||
spo_athlete: boolean
|
||||
spo_fan: boolean
|
||||
spo_doping: boolean
|
||||
// Finance / Banking
|
||||
fin_credit_scoring: boolean
|
||||
fin_aml_kyc: boolean
|
||||
fin_algo_decisions: boolean
|
||||
fin_customer_profiling: boolean
|
||||
// General
|
||||
gen_affects_people: boolean
|
||||
gen_automated_decisions: boolean
|
||||
gen_sensitive_data: boolean
|
||||
// Hosting
|
||||
hosting_provider: string
|
||||
hosting_region: string
|
||||
model_usage: string[]
|
||||
|
||||
@@ -51,6 +51,71 @@ function AdvisoryBoardPageInner() {
|
||||
custom_data_types: [],
|
||||
purposes: [],
|
||||
automation: '',
|
||||
// BetrVG / works council
|
||||
employee_monitoring: false,
|
||||
hr_decision_support: false,
|
||||
works_council_consulted: false,
|
||||
// Domain-specific contexts (Annex III)
|
||||
hr_automated_screening: false,
|
||||
hr_automated_rejection: false,
|
||||
hr_candidate_ranking: false,
|
||||
hr_bias_audits: false,
|
||||
hr_agg_visible: false,
|
||||
hr_human_review: false,
|
||||
hr_performance_eval: false,
|
||||
edu_grade_influence: false,
|
||||
edu_exam_evaluation: false,
|
||||
edu_student_selection: false,
|
||||
edu_minors: false,
|
||||
edu_teacher_review: false,
|
||||
hc_diagnosis: false,
|
||||
hc_treatment: false,
|
||||
hc_triage: false,
|
||||
hc_patient_data: false,
|
||||
hc_medical_device: false,
|
||||
hc_clinical_validation: false,
|
||||
// Legal
|
||||
leg_legal_advice: false, leg_court_prediction: false, leg_client_confidential: false,
|
||||
// Public Sector
|
||||
pub_admin_decision: false, pub_benefit_allocation: false, pub_transparency: false,
|
||||
// Critical Infrastructure
|
||||
crit_grid_control: false, crit_safety_critical: false, crit_redundancy: false,
|
||||
// Automotive
|
||||
auto_autonomous: false, auto_safety: false, auto_functional_safety: false,
|
||||
// Retail
|
||||
ret_pricing: false, ret_profiling: false, ret_credit_scoring: false, ret_dark_patterns: false,
|
||||
// IT Security
|
||||
its_surveillance: false, its_threat_detection: false, its_data_retention: false,
|
||||
// Logistics
|
||||
log_driver_tracking: false, log_workload_scoring: false,
|
||||
// Construction
|
||||
con_tenant_screening: false, con_worker_safety: false,
|
||||
// Marketing
|
||||
mkt_deepfake: false, mkt_minors: false, mkt_targeting: false, mkt_labeled: false,
|
||||
// Manufacturing
|
||||
mfg_machine_safety: false, mfg_ce_required: false, mfg_validated: false,
|
||||
// Agriculture
|
||||
agr_pesticide: false, agr_animal_welfare: false, agr_environmental: false,
|
||||
// Social Services
|
||||
soc_vulnerable: false, soc_benefit: false, soc_case_mgmt: false,
|
||||
// Hospitality
|
||||
hos_guest_profiling: false, hos_dynamic_pricing: false, hos_review_manipulation: false,
|
||||
// Insurance
|
||||
ins_risk_class: false, ins_claims: false, ins_premium: false, ins_fraud: false,
|
||||
// Investment
|
||||
inv_algo_trading: false, inv_advice: false, inv_robo: false,
|
||||
// Defense
|
||||
def_dual_use: false, def_export: false, def_classified: false,
|
||||
// Supply Chain
|
||||
sch_supplier: false, sch_human_rights: false, sch_environmental: false,
|
||||
// Facility
|
||||
fac_access: false, fac_occupancy: false, fac_energy: false,
|
||||
// Sports
|
||||
spo_athlete: false, spo_fan: false, spo_doping: false,
|
||||
// Finance / Banking
|
||||
fin_credit_scoring: false, fin_aml_kyc: false, fin_algo_decisions: false, fin_customer_profiling: false,
|
||||
// General
|
||||
gen_affects_people: false, gen_automated_decisions: false, gen_sensitive_data: false,
|
||||
hosting_provider: '',
|
||||
hosting_region: '',
|
||||
model_usage: [],
|
||||
@@ -133,18 +198,164 @@ function AdvisoryBoardPageInner() {
|
||||
retention_purpose: form.retention_purpose,
|
||||
contracts_list: form.contracts,
|
||||
subprocessors: form.subprocessors,
|
||||
employee_monitoring: form.employee_monitoring,
|
||||
hr_decision_support: form.hr_decision_support,
|
||||
works_council_consulted: form.works_council_consulted,
|
||||
// Domain-specific contexts
|
||||
hr_context: ['hr', 'recruiting'].includes(form.domain) ? {
|
||||
automated_screening: form.hr_automated_screening,
|
||||
automated_rejection: form.hr_automated_rejection,
|
||||
candidate_ranking: form.hr_candidate_ranking,
|
||||
bias_audits_done: form.hr_bias_audits,
|
||||
agg_categories_visible: form.hr_agg_visible,
|
||||
human_review_enforced: form.hr_human_review,
|
||||
performance_evaluation: form.hr_performance_eval,
|
||||
} : undefined,
|
||||
education_context: ['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) ? {
|
||||
grade_influence: form.edu_grade_influence,
|
||||
exam_evaluation: form.edu_exam_evaluation,
|
||||
student_selection: form.edu_student_selection,
|
||||
minors_involved: form.edu_minors,
|
||||
teacher_review_required: form.edu_teacher_review,
|
||||
} : undefined,
|
||||
healthcare_context: ['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) ? {
|
||||
diagnosis_support: form.hc_diagnosis,
|
||||
treatment_recommendation: form.hc_treatment,
|
||||
triage_decision: form.hc_triage,
|
||||
patient_data_processed: form.hc_patient_data,
|
||||
medical_device: form.hc_medical_device,
|
||||
clinical_validation: form.hc_clinical_validation,
|
||||
} : undefined,
|
||||
legal_context: ['legal', 'consulting', 'tax_advisory'].includes(form.domain) ? {
|
||||
legal_advice: form.leg_legal_advice,
|
||||
court_prediction: form.leg_court_prediction,
|
||||
client_confidential: form.leg_client_confidential,
|
||||
} : undefined,
|
||||
public_sector_context: ['public_sector', 'defense', 'justice'].includes(form.domain) ? {
|
||||
admin_decision: form.pub_admin_decision,
|
||||
benefit_allocation: form.pub_benefit_allocation,
|
||||
transparency_ensured: form.pub_transparency,
|
||||
} : undefined,
|
||||
critical_infra_context: ['energy', 'utilities', 'oil_gas'].includes(form.domain) ? {
|
||||
grid_control: form.crit_grid_control,
|
||||
safety_critical: form.crit_safety_critical,
|
||||
redundancy_exists: form.crit_redundancy,
|
||||
} : undefined,
|
||||
automotive_context: ['automotive', 'aerospace'].includes(form.domain) ? {
|
||||
autonomous_driving: form.auto_autonomous,
|
||||
safety_relevant: form.auto_safety,
|
||||
functional_safety: form.auto_functional_safety,
|
||||
} : undefined,
|
||||
retail_context: ['retail', 'ecommerce', 'wholesale'].includes(form.domain) ? {
|
||||
pricing_personalized: form.ret_pricing,
|
||||
credit_scoring: form.ret_credit_scoring,
|
||||
dark_patterns: form.ret_dark_patterns,
|
||||
} : undefined,
|
||||
it_security_context: ['it_services', 'cybersecurity', 'telecom'].includes(form.domain) ? {
|
||||
employee_surveillance: form.its_surveillance,
|
||||
threat_detection: form.its_threat_detection,
|
||||
data_retention_logs: form.its_data_retention,
|
||||
} : undefined,
|
||||
logistics_context: ['logistics'].includes(form.domain) ? {
|
||||
driver_tracking: form.log_driver_tracking,
|
||||
workload_scoring: form.log_workload_scoring,
|
||||
} : undefined,
|
||||
construction_context: ['construction', 'real_estate', 'facility_management'].includes(form.domain) ? {
|
||||
tenant_screening: form.con_tenant_screening,
|
||||
worker_safety: form.con_worker_safety,
|
||||
} : undefined,
|
||||
marketing_context: ['marketing', 'media', 'entertainment'].includes(form.domain) ? {
|
||||
deepfake_content: form.mkt_deepfake,
|
||||
behavioral_targeting: form.mkt_targeting,
|
||||
minors_targeted: form.mkt_minors,
|
||||
ai_content_labeled: form.mkt_labeled,
|
||||
} : undefined,
|
||||
manufacturing_context: ['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage'].includes(form.domain) ? {
|
||||
machine_safety: form.mfg_machine_safety,
|
||||
ce_marking_required: form.mfg_ce_required,
|
||||
safety_validated: form.mfg_validated,
|
||||
} : undefined,
|
||||
agriculture_context: ['agriculture', 'forestry', 'fishing'].includes(form.domain) ? {
|
||||
pesticide_ai: form.agr_pesticide,
|
||||
animal_welfare: form.agr_animal_welfare,
|
||||
environmental_data: form.agr_environmental,
|
||||
} : undefined,
|
||||
social_services_context: ['social_services', 'nonprofit'].includes(form.domain) ? {
|
||||
vulnerable_groups: form.soc_vulnerable,
|
||||
benefit_decision: form.soc_benefit,
|
||||
case_management: form.soc_case_mgmt,
|
||||
} : undefined,
|
||||
hospitality_context: ['hospitality', 'tourism'].includes(form.domain) ? {
|
||||
guest_profiling: form.hos_guest_profiling,
|
||||
dynamic_pricing: form.hos_dynamic_pricing,
|
||||
review_manipulation: form.hos_review_manipulation,
|
||||
} : undefined,
|
||||
insurance_context: ['insurance'].includes(form.domain) ? {
|
||||
risk_classification: form.ins_risk_class,
|
||||
claims_automation: form.ins_claims,
|
||||
premium_calculation: form.ins_premium,
|
||||
fraud_detection: form.ins_fraud,
|
||||
} : undefined,
|
||||
investment_context: ['investment'].includes(form.domain) ? {
|
||||
algo_trading: form.inv_algo_trading,
|
||||
investment_advice: form.inv_advice,
|
||||
robo_advisor: form.inv_robo,
|
||||
} : undefined,
|
||||
defense_context: ['defense'].includes(form.domain) ? {
|
||||
dual_use: form.def_dual_use,
|
||||
export_controlled: form.def_export,
|
||||
classified_data: form.def_classified,
|
||||
} : undefined,
|
||||
supply_chain_context: ['textiles', 'packaging'].includes(form.domain) ? {
|
||||
supplier_monitoring: form.sch_supplier,
|
||||
human_rights_check: form.sch_human_rights,
|
||||
environmental_impact: form.sch_environmental,
|
||||
} : undefined,
|
||||
facility_context: ['facility_management'].includes(form.domain) ? {
|
||||
access_control_ai: form.fac_access,
|
||||
occupancy_tracking: form.fac_occupancy,
|
||||
energy_optimization: form.fac_energy,
|
||||
} : undefined,
|
||||
sports_context: ['sports'].includes(form.domain) ? {
|
||||
athlete_tracking: form.spo_athlete,
|
||||
fan_profiling: form.spo_fan,
|
||||
} : undefined,
|
||||
store_raw_text: true,
|
||||
// Finance/Banking and General don't need separate context structs —
|
||||
// their fields are evaluated via existing FinancialContext or generic rules
|
||||
}
|
||||
|
||||
const url = isEditMode
|
||||
? `/api/sdk/v1/ucca/assessments/${editId}`
|
||||
: '/api/sdk/v1/ucca/assess'
|
||||
: '/api/sdk/v1/ucca/assess-enriched'
|
||||
const method = isEditMode ? 'PUT' : 'POST'
|
||||
|
||||
// For new assessments, send enriched payload with company profile
|
||||
const payload = isEditMode ? intake : {
|
||||
intake,
|
||||
company_profile: sdkState.companyProfile ? {
|
||||
company_name: sdkState.companyProfile.companyName ?? '',
|
||||
legal_form: sdkState.companyProfile.legalForm ?? '',
|
||||
industry: Array.isArray(sdkState.companyProfile.industry)
|
||||
? sdkState.companyProfile.industry.join(', ')
|
||||
: (sdkState.companyProfile.industry ?? ''),
|
||||
employee_count: sdkState.companyProfile.employeeCount ?? '',
|
||||
annual_revenue: sdkState.companyProfile.annualRevenue ?? '',
|
||||
headquarters_country: sdkState.companyProfile.headquartersCountry ?? 'DE',
|
||||
is_data_controller: sdkState.companyProfile.isDataController ?? true,
|
||||
is_data_processor: sdkState.companyProfile.isDataProcessor ?? false,
|
||||
uses_ai: true,
|
||||
dpo_name: sdkState.companyProfile.dpoName ?? null,
|
||||
subject_to_nis2: false,
|
||||
subject_to_ai_act: false,
|
||||
subject_to_iso27001: false,
|
||||
} : undefined,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(intake),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentSessionsPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Sessions</h1>
|
||||
<p className="text-gray-500 mt-1">Chat-Verlaeufe und Session-Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Sessions-Tracking</h2>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Das Session-Tracking fuer Compliance-Agenten wird in einer zukuenftigen Version implementiert.
|
||||
Hier werden Chat-Verlaeufe, Antwortqualitaet und Nutzer-Feedback angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentStatisticsPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Statistiken</h1>
|
||||
<p className="text-gray-500 mt-1">Performance-Metriken und Nutzungsanalysen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Agent-Statistiken</h2>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Detaillierte Statistiken wie Antwortzeiten, Erfolgsraten, haeufigste Themen und
|
||||
RAG-Trefferquoten werden in einer zukuenftigen Version implementiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,178 @@ import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { RiskPyramid } from './_components/RiskPyramid'
|
||||
import { AddSystemForm } from './_components/AddSystemForm'
|
||||
import { AISystemCard } from './_components/AISystemCard'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// 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<SavedResult[]>([])
|
||||
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<string, string> = {
|
||||
unacceptable: 'Unzulässig',
|
||||
high_risk: 'Hochrisiko',
|
||||
limited_risk: 'Begrenztes Risiko',
|
||||
minimal_risk: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
none: 'Kein GPAI',
|
||||
standard: 'GPAI Standard',
|
||||
systemic: 'GPAI Systemisch',
|
||||
}
|
||||
|
||||
const gpaiColors: Record<string, string> = {
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
standard: 'bg-blue-100 text-blue-700',
|
||||
systemic: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||
{r.system_description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||
</span>
|
||||
{r.gpai_result?.is_systemic_risk && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// TABS
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
label: 'Entscheidungsbaum',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: 'Ergebnisse',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// MAIN PAGE
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -178,17 +347,38 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{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>
|
||||
@@ -196,82 +386,105 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -228,24 +228,39 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
dependsOn: ['qdrant', 'ollama', 'postgresql'],
|
||||
},
|
||||
{
|
||||
id: 'document-crawler',
|
||||
name: 'Document Crawler',
|
||||
nameShort: 'Crawler',
|
||||
id: 'control-pipeline',
|
||||
name: 'Control Pipeline',
|
||||
nameShort: 'Pipeline',
|
||||
layer: 'backend',
|
||||
tech: 'Python / FastAPI',
|
||||
port: 8098,
|
||||
url: 'https://macmini:8098',
|
||||
container: 'bp-compliance-document-crawler',
|
||||
description: 'Dokument-Analyse (PDF, DOCX, XLSX, PPTX), Gap-Analyse, IPFS-Archivierung.',
|
||||
descriptionLong: 'Der Document Crawler nimmt hochgeladene Dokumente (PDF, DOCX, XLSX, PPTX) entgegen, extrahiert deren Inhalt und fuehrt eine Gap-Analyse gegen bestehende Compliance-Anforderungen durch. Dafuer leitet er die Textinhalte an den AI Compliance SDK weiter, der die semantische Analyse uebernimmt. Abgeschlossene Dokumente koennen ueber den DSMS-Service dezentral auf IPFS archiviert werden.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [
|
||||
'POST /analyze',
|
||||
'POST /gap-analysis',
|
||||
'POST /archive',
|
||||
container: 'bp-core-control-pipeline',
|
||||
description: 'RAG-zu-Controls Pipeline: Control Generation, Pass 0a/0b, Ontology, Dedup, Dependency Engine, Applicability.',
|
||||
descriptionLong: 'Die Control Pipeline ist das Herzsttueck der automatisierten Compliance-Control-Generierung. Sie verarbeitet ~105.000 RAG-Chunks aus EU/DE-Regulierungen in 6 Phasen: (1) RAG Ingestion, (2) 7-Stufen Control Generation (Lizenz-Gate + Claude LLM), (3) Pass 0a Obligation Extraction (~181k Obligations), (4) Pass 0b Atomic Composition (MCP-taugliche Controls mit assertion/pass_criteria/fail_criteria), (5) Embedding-basierte Deduplizierung mit LLM-Verifikation, (6) Dependency Engine (5 Typen: supersedes, prerequisite, compensating_control, scope_exclusion, conditional_requirement) mit automatischer Generierung via Ontology, Pattern-Regeln und Domain Packs (DSGVO, AI Act, CRA, Security, Arbeitsrecht). 126+ Tests, alle bestanden.',
|
||||
dbTables: [
|
||||
'canonical_controls', 'obligation_candidates', 'control_parent_links',
|
||||
'control_dependencies', 'control_evaluation_results',
|
||||
'canonical_processed_chunks', 'canonical_generation_jobs',
|
||||
'control_dedup_reviews', 'control_patterns',
|
||||
],
|
||||
dependsOn: ['ai-compliance-sdk', 'dsms'],
|
||||
ragCollections: [
|
||||
'bp_compliance_gesetze', 'bp_compliance_datenschutz',
|
||||
'bp_compliance_ce', 'bp_dsfa_corpus', 'bp_legal_templates',
|
||||
],
|
||||
apiEndpoints: [
|
||||
'POST /v1/canonical/generate',
|
||||
'GET /v1/canonical/controls',
|
||||
'POST /v1/canonical/controls/applicable',
|
||||
'POST /v1/canonical/generate/submit-pass0b',
|
||||
'POST /v1/canonical/generate/process-batch',
|
||||
'GET /v1/canonical/generate/quality-metrics',
|
||||
'POST /v1/dependencies/generate',
|
||||
'POST /v1/dependencies/evaluate',
|
||||
'GET /v1/dependencies/graph',
|
||||
'POST /v1/document-compliance/required',
|
||||
],
|
||||
dependsOn: ['postgresql', 'qdrant', 'ollama'],
|
||||
},
|
||||
{
|
||||
id: 'compliance-tts',
|
||||
@@ -383,7 +398,7 @@ export const ARCH_EDGES: ArchEdge[] = [
|
||||
// Frontend → Backend
|
||||
{ source: 'admin-compliance', target: 'backend-compliance', label: 'REST API' },
|
||||
{ source: 'admin-compliance', target: 'ai-compliance-sdk', label: 'REST API' },
|
||||
{ source: 'admin-compliance', target: 'document-crawler', label: 'REST API' },
|
||||
{ source: 'admin-compliance', target: 'control-pipeline', label: 'REST API' },
|
||||
|
||||
// Backend → Infrastructure
|
||||
{ source: 'backend-compliance', target: 'postgresql', label: 'SQLAlchemy' },
|
||||
@@ -392,12 +407,9 @@ export const ARCH_EDGES: ArchEdge[] = [
|
||||
{ source: 'ai-compliance-sdk', target: 'ollama', label: 'LLM Inference' },
|
||||
{ source: 'ai-compliance-sdk', target: 'postgresql', label: 'GORM' },
|
||||
{ source: 'compliance-tts', target: 'minio', label: 'Audio/Video' },
|
||||
|
||||
// Backend → Backend
|
||||
{ source: 'document-crawler', target: 'ai-compliance-sdk', label: 'LLM Gateway' },
|
||||
|
||||
// Backend → Data Sovereignty
|
||||
{ source: 'document-crawler', target: 'dsms', label: 'IPFS Archive' },
|
||||
{ source: 'control-pipeline', target: 'postgresql', label: 'SQLAlchemy' },
|
||||
{ source: 'control-pipeline', target: 'qdrant', label: 'Embedding + Dedup' },
|
||||
{ source: 'control-pipeline', target: 'ollama', label: 'LLM Dedup (qwen3.5)' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||
import { DimensionZoneTable } from '@/components/sdk/compliance-optimizer/DimensionZoneTable'
|
||||
import { ConfigComparison } from '@/components/sdk/compliance-optimizer/ConfigComparison'
|
||||
import { OptimizationScoreCard } from '@/components/sdk/compliance-optimizer/OptimizationScoreCard'
|
||||
|
||||
export default function OptimizationDetailPage() {
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeVariant, setActiveVariant] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
fetch(`/api/sdk/v1/maximizer/optimizations/${id}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) return <div className="max-w-6xl mx-auto p-6 text-gray-500">Laden...</div>
|
||||
if (!data) return <div className="max-w-6xl mx-auto p-6 text-red-600">Optimierung nicht gefunden.</div>
|
||||
|
||||
const maxSafe = data.max_safe_config
|
||||
const variants = data.variants || []
|
||||
const zones = data.zone_map || {}
|
||||
const controls = data.original_evaluation?.required_controls || []
|
||||
const patterns = data.original_evaluation?.required_patterns || []
|
||||
const triggered = data.original_evaluation?.triggered_rules || []
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link href="/sdk/compliance-optimizer" className="text-sm text-blue-600 hover:underline">← Zurueck</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">{data.title || 'Optimierung'}</h1>
|
||||
<p className="text-sm text-gray-500">{new Date(data.created_at).toLocaleString('de-DE')} — v{data.constraint_version}</p>
|
||||
{data.assessment_id && (
|
||||
<Link href={`/sdk/use-cases/${data.assessment_id}`} className="text-sm text-purple-600 hover:underline">
|
||||
Basierend auf Assessment
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ZoneBadge zone={data.is_compliant ? 'SAFE' : 'FORBIDDEN'} />
|
||||
</div>
|
||||
|
||||
{/* 3-Zone Summary */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">3-Zonen-Analyse</h2>
|
||||
<DimensionZoneTable zoneMap={zones} />
|
||||
</div>
|
||||
|
||||
{/* Optimization Result */}
|
||||
{maxSafe && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Optimierte Konfiguration</h2>
|
||||
<OptimizationScoreCard
|
||||
safetyScore={maxSafe.safety_score}
|
||||
utilityScore={maxSafe.utility_score}
|
||||
compositeScore={maxSafe.composite_score}
|
||||
deltaCount={maxSafe.delta_count}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ConfigComparison deltas={maxSafe.deltas || []} />
|
||||
</div>
|
||||
{maxSafe.rationale && (
|
||||
<p className="mt-3 text-sm text-gray-600 italic">{maxSafe.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alternative Variants */}
|
||||
{variants.length > 1 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Alternative Varianten ({variants.length})</h2>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{variants.map((v: any, i: number) => (
|
||||
<button key={i} onClick={() => setActiveVariant(i)}
|
||||
className={`px-3 py-1 text-sm rounded ${i === activeVariant ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
|
||||
Variante {i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{variants[activeVariant] && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-2 text-sm text-gray-600">
|
||||
<span>Sicherheit: {variants[activeVariant].safety_score}</span>
|
||||
<span>Nutzen: {variants[activeVariant].utility_score}</span>
|
||||
<span>Gesamt: {Math.round(variants[activeVariant].composite_score)}</span>
|
||||
</div>
|
||||
<ConfigComparison deltas={variants[activeVariant].deltas || []} />
|
||||
{variants[activeVariant].rationale && (
|
||||
<p className="mt-2 text-sm text-gray-500 italic">{variants[activeVariant].rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Controls & Patterns */}
|
||||
{(controls.length > 0 || patterns.length > 0) && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Erforderliche Massnahmen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{controls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Controls</h4>
|
||||
<ul className="space-y-1">
|
||||
{controls.map((c: string, i: number) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full" />{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Architektur-Patterns</h4>
|
||||
<ul className="space-y-1">
|
||||
{patterns.map((p: string, i: number) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-purple-500 rounded-full" />{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Triggered Rules (Audit Trail) */}
|
||||
{triggered.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Ausgeloeste Regeln ({triggered.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{triggered.map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-3 text-sm border-b border-gray-100 pb-2">
|
||||
<span className="font-mono text-xs text-gray-400 min-w-[120px]">{r.rule_id}</span>
|
||||
<span className="text-gray-700">{r.title}</span>
|
||||
<span className="text-gray-400 ml-auto text-xs">{r.article_ref}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||
|
||||
interface DimensionField {
|
||||
key: string
|
||||
label: string
|
||||
options: { value: string; label: string }[]
|
||||
type?: 'select' | 'toggle'
|
||||
}
|
||||
|
||||
const DIMENSIONS: DimensionField[] = [
|
||||
{ key: 'automation_level', label: 'Automatisierungsgrad', options: [
|
||||
{ value: 'none', label: 'Keine' }, { value: 'assistive', label: 'Assistierend' },
|
||||
{ value: 'partial', label: 'Teilautomatisiert' }, { value: 'full', label: 'Vollautomatisiert' },
|
||||
]},
|
||||
{ key: 'decision_binding', label: 'Entscheidungsbindung', options: [
|
||||
{ value: 'non_binding', label: 'Unverbindlich' }, { value: 'human_review_required', label: 'Mensch entscheidet' },
|
||||
{ value: 'fully_binding', label: 'Vollstaendig bindend' },
|
||||
]},
|
||||
{ key: 'decision_impact', label: 'Entscheidungswirkung', options: [
|
||||
{ value: 'low', label: 'Niedrig' }, { value: 'medium', label: 'Mittel' }, { value: 'high', label: 'Hoch' },
|
||||
]},
|
||||
{ key: 'domain', label: 'Branche', options: [
|
||||
{ value: 'hr', label: 'HR / Personal' }, { value: 'finance', label: 'Finanzen' },
|
||||
{ value: 'education', label: 'Bildung' }, { value: 'health', label: 'Gesundheit' },
|
||||
{ value: 'marketing', label: 'Marketing' }, { value: 'general', label: 'Allgemein' },
|
||||
]},
|
||||
{ key: 'data_type', label: 'Datensensitivitaet', options: [
|
||||
{ value: 'non_personal', label: 'Keine personenbezogenen' }, { value: 'personal', label: 'Personenbezogen' },
|
||||
{ value: 'sensitive', label: 'Besondere Kategorien (Art. 9)' }, { value: 'biometric', label: 'Biometrisch' },
|
||||
]},
|
||||
{ key: 'human_in_loop', label: 'Menschliche Kontrolle', options: [
|
||||
{ value: 'required', label: 'Erforderlich' }, { value: 'optional', label: 'Optional' }, { value: 'none', label: 'Keine' },
|
||||
]},
|
||||
{ key: 'explainability', label: 'Erklaerbarkeit', options: [
|
||||
{ value: 'high', label: 'Hoch' }, { value: 'basic', label: 'Basis' }, { value: 'none', label: 'Keine' },
|
||||
]},
|
||||
{ key: 'risk_classification', label: 'Risikoklasse (AI Act)', options: [
|
||||
{ value: 'minimal', label: 'Minimal' }, { value: 'limited', label: 'Begrenzt' },
|
||||
{ value: 'high', label: 'Hoch' }, { value: 'prohibited', label: 'Verboten' },
|
||||
]},
|
||||
{ key: 'legal_basis', label: 'Rechtsgrundlage (DSGVO)', options: [
|
||||
{ value: 'consent', label: 'Einwilligung' }, { value: 'contract', label: 'Vertrag' },
|
||||
{ value: 'legal_obligation', label: 'Rechtl. Verpflichtung' },
|
||||
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse' },
|
||||
{ value: 'public_interest', label: 'Oeffentl. Interesse' },
|
||||
]},
|
||||
{ key: 'model_type', label: 'Modelltyp', options: [
|
||||
{ value: 'rule_based', label: 'Regelbasiert' }, { value: 'statistical', label: 'Statistisch / ML' },
|
||||
{ value: 'blackbox_llm', label: 'Blackbox / LLM' },
|
||||
]},
|
||||
{ key: 'deployment_scope', label: 'Einsatzbereich', options: [
|
||||
{ value: 'internal', label: 'Intern' }, { value: 'external', label: 'Extern (Kunden)' },
|
||||
{ value: 'public', label: 'Oeffentlich' },
|
||||
]},
|
||||
]
|
||||
|
||||
const TOGGLE_DIMENSIONS = [
|
||||
{ key: 'transparency_required', label: 'Transparenzpflicht' },
|
||||
{ key: 'logging_required', label: 'Protokollierungspflicht' },
|
||||
]
|
||||
|
||||
function NewOptimizationPageInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const fromAssessment = searchParams.get('from_assessment')
|
||||
const [autoOptimizing, setAutoOptimizing] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!fromAssessment) return
|
||||
setAutoOptimizing(true)
|
||||
fetch(`/api/sdk/v1/maximizer/optimize-from-assessment/${fromAssessment}`, { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : Promise.reject('failed'))
|
||||
.then(data => router.push(`/sdk/compliance-optimizer/${data.id}`))
|
||||
.catch(() => setAutoOptimizing(false))
|
||||
}, [fromAssessment, router])
|
||||
const [config, setConfig] = useState<Record<string, string>>({
|
||||
automation_level: 'assistive', decision_binding: 'non_binding', decision_impact: 'low',
|
||||
domain: 'general', data_type: 'non_personal', human_in_loop: 'required',
|
||||
explainability: 'basic', risk_classification: 'minimal', legal_basis: 'contract',
|
||||
transparency_required: 'false', logging_required: 'false',
|
||||
model_type: 'rule_based', deployment_scope: 'internal',
|
||||
})
|
||||
const [preview, setPreview] = useState<Record<string, { zone: string }> | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handlePreview() {
|
||||
try {
|
||||
const body = { ...config, transparency_required: config.transparency_required === 'true', logging_required: config.logging_required === 'true' }
|
||||
const res = await fetch('/api/sdk/v1/maximizer/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPreview(data.zone_map || {})
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const body = {
|
||||
config: { ...config, transparency_required: config.transparency_required === 'true', logging_required: config.logging_required === 'true' },
|
||||
title: title || 'Optimierung ' + new Date().toLocaleDateString('de-DE'),
|
||||
}
|
||||
const res = await fetch('/api/sdk/v1/maximizer/optimize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/sdk/compliance-optimizer/${data.id}`)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (autoOptimizing) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 text-center py-24">
|
||||
<div className="animate-pulse">
|
||||
<span className="text-4xl">📊</span>
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-4 mb-2">Optimierung laeuft...</h2>
|
||||
<p className="text-sm text-gray-500">Assessment wird analysiert und optimale Konfiguration berechnet.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Neue Optimierung</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">Konfigurieren Sie Ihren KI-Use-Case und finden Sie den maximalen regulatorischen Spielraum.</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="z.B. HR Bewerber-Ranking"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{DIMENSIONS.map((dim) => (
|
||||
<div key={dim.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{dim.label}
|
||||
{preview && preview[dim.key] && (
|
||||
<span className="ml-2"><ZoneBadge zone={preview[dim.key].zone as 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'} /></span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={config[dim.key]}
|
||||
onChange={(e) => { setConfig({ ...config, [dim.key]: e.target.value }); setPreview(null) }}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white"
|
||||
>
|
||||
{dim.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{TOGGLE_DIMENSIONS.map((dim) => (
|
||||
<div key={dim.key} className="flex items-center gap-3">
|
||||
<input type="checkbox" checked={config[dim.key] === 'true'}
|
||||
onChange={(e) => { setConfig({ ...config, [dim.key]: String(e.target.checked) }); setPreview(null) }}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600" />
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{dim.label}
|
||||
{preview && preview[dim.key] && (
|
||||
<span className="ml-2"><ZoneBadge zone={preview[dim.key].zone as 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'} /></span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handlePreview} className="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-50 text-sm">
|
||||
Vorschau (3-Zonen-Check)
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
||||
{submitting ? 'Optimiere...' : 'Optimieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewOptimizationPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="max-w-4xl mx-auto p-6 text-gray-500">Laden...</div>}>
|
||||
<NewOptimizationPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||
|
||||
interface OptimizationSummary {
|
||||
id: string
|
||||
title: string
|
||||
is_compliant: boolean
|
||||
constraint_version: string
|
||||
created_at: string
|
||||
zone_map: Record<string, { zone: 'FORBIDDEN' | 'RESTRICTED' | 'SAFE' }>
|
||||
max_safe_config?: { safety_score: number; utility_score: number }
|
||||
assessment_id?: string
|
||||
}
|
||||
|
||||
function countZones(zoneMap: Record<string, { zone: string }>) {
|
||||
let forbidden = 0, restricted = 0, safe = 0
|
||||
for (const v of Object.values(zoneMap || {})) {
|
||||
if (v.zone === 'FORBIDDEN') forbidden++
|
||||
else if (v.zone === 'RESTRICTED') restricted++
|
||||
else safe++
|
||||
}
|
||||
return { forbidden, restricted, safe }
|
||||
}
|
||||
|
||||
export default function ComplianceOptimizerPage() {
|
||||
const [optimizations, setOptimizations] = useState<OptimizationSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptimizations()
|
||||
}, [])
|
||||
|
||||
async function fetchOptimizations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/sdk/v1/maximizer/optimizations?limit=20')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setOptimizations(data.optimizations || [])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Optimizer</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Regulatorischen Spielraum maximieren — KI-Use-Cases optimal konfigurieren
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/sdk/compliance-optimizer/new"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
Neue Optimierung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : optimizations.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p className="text-gray-600 mb-2">Noch keine Optimierungen durchgefuehrt.</p>
|
||||
<Link href="/sdk/compliance-optimizer/new" className="text-blue-600 hover:underline text-sm">
|
||||
Erste Optimierung starten
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Titel</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zonen</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{optimizations.map((o) => {
|
||||
const zones = countZones(o.zone_map)
|
||||
return (
|
||||
<tr key={o.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/sdk/compliance-optimizer/${o.id}`} className="text-blue-600 hover:underline font-medium text-sm">
|
||||
{o.title || 'Ohne Titel'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ZoneBadge zone={o.is_compliant ? 'SAFE' : 'FORBIDDEN'} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{zones.forbidden > 0 && <span className="text-red-600 mr-2">{zones.forbidden} verboten</span>}
|
||||
{zones.restricted > 0 && <span className="text-yellow-600 mr-2">{zones.restricted} eingeschraenkt</span>}
|
||||
<span className="text-green-600">{zones.safe} erlaubt</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{o.assessment_id ? (
|
||||
<Link href={`/sdk/use-cases/${o.assessment_id}`} className="text-purple-600 hover:underline text-xs">
|
||||
Assessment
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">Manuell</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > 20 && (
|
||||
<div className="px-4 py-3 bg-gray-50 text-sm text-gray-500">
|
||||
{total} Optimierungen insgesamt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -274,7 +274,7 @@ function RetentionTimeline({ dataPoints, language }: RetentionTimelineProps) {
|
||||
// =============================================================================
|
||||
|
||||
interface ExportOptionsProps {
|
||||
onExport: (format: 'csv' | 'json' | 'pdf') => void
|
||||
onExport: (format: 'csv' | 'json') => void
|
||||
}
|
||||
|
||||
function ExportOptions({ onExport }: ExportOptionsProps) {
|
||||
@@ -294,13 +294,6 @@ function ExportOptions({ onExport }: ExportOptionsProps) {
|
||||
<Download className="w-4 h-4" />
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExport('pdf')}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -332,7 +325,7 @@ function RetentionContent() {
|
||||
}, [allDataPoints, filterCategory])
|
||||
|
||||
// Handle export
|
||||
const handleExport = (format: 'csv' | 'json' | 'pdf') => {
|
||||
const handleExport = (format: 'csv' | 'json') => {
|
||||
if (format === 'csv') {
|
||||
const headers = ['Code', 'Name', 'Kategorie', 'Loeschfrist', 'Rechtsgrundlage']
|
||||
const rows = allDataPoints.map((dp) => [
|
||||
@@ -354,8 +347,6 @@ function RetentionContent() {
|
||||
legalBasis: dp.legalBasis,
|
||||
}))
|
||||
downloadFile(JSON.stringify(data, null, 2), 'loeschfristen.json', 'application/json')
|
||||
} else {
|
||||
alert('PDF-Export wird noch implementiert.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
||||
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
|
||||
import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed'
|
||||
import type { SDKPackageId } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
@@ -331,6 +332,9 @@ export default function SDKDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regulatory News */}
|
||||
<RegulatoryNewsFeed businessModel={state.companyProfile?.businessModel as string} />
|
||||
|
||||
{/* 5 Packages */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface PaymentControl {
|
||||
control_id: string
|
||||
domain: string
|
||||
title: string
|
||||
objective: string
|
||||
check_target: string
|
||||
evidence: string[]
|
||||
automation: string
|
||||
}
|
||||
|
||||
interface PaymentDomain {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Assessment {
|
||||
id: string
|
||||
project_name: string
|
||||
tender_reference: string
|
||||
customer_name: string
|
||||
system_type: string
|
||||
total_controls: number
|
||||
controls_passed: number
|
||||
controls_failed: number
|
||||
controls_partial: number
|
||||
controls_not_applicable: number
|
||||
controls_not_checked: number
|
||||
compliance_score: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface TenderAnalysis {
|
||||
id: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
project_name: string
|
||||
customer_name: string
|
||||
status: string
|
||||
total_requirements: number
|
||||
matched_count: number
|
||||
unmatched_count: number
|
||||
partial_count: number
|
||||
requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }>
|
||||
match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
low: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
const TARGET_ICONS: Record<string, string> = {
|
||||
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
|
||||
repository: '📦', certificate: '📜',
|
||||
}
|
||||
|
||||
export default function PaymentCompliancePage() {
|
||||
const [controls, setControls] = useState<PaymentControl[]>([])
|
||||
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
|
||||
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [showNewAssessment, setShowNewAssessment] = useState(false)
|
||||
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [ctrlResp, assessResp, tenderResp] = await Promise.all([
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
||||
fetch('/api/sdk/v1/payment-compliance/tender'),
|
||||
])
|
||||
if (ctrlResp.ok) {
|
||||
const data = await ctrlResp.json()
|
||||
setControls(data.controls || [])
|
||||
setDomains(data.domains || [])
|
||||
}
|
||||
if (assessResp.ok) {
|
||||
const data = await assessResp.json()
|
||||
setAssessments(data.assessments || [])
|
||||
}
|
||||
if (tenderResp.ok) {
|
||||
const data = await tenderResp.json()
|
||||
setTenderAnalyses(data.analyses || [])
|
||||
}
|
||||
} catch {}
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleTenderUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('project_name', file.name.replace(/\.[^.]+$/, ''))
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
// Auto-start extraction + matching
|
||||
setProcessing(true)
|
||||
const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' })
|
||||
if (extractResp.ok) {
|
||||
await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' })
|
||||
}
|
||||
// Reload and show result
|
||||
const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`)
|
||||
if (detailResp.ok) {
|
||||
const detail = await detailResp.json()
|
||||
setSelectedTender(detail)
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
} catch {} finally {
|
||||
setUploading(false)
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewTender(id: string) {
|
||||
const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`)
|
||||
if (resp.ok) {
|
||||
setSelectedTender(await resp.json())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAssessment() {
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newProject),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowNewAssessment(false)
|
||||
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredControls = selectedDomain === 'all'
|
||||
? controls
|
||||
: controls.filter(c => c.domain === selectedDomain)
|
||||
|
||||
const domainStats = domains.map(d => ({
|
||||
...d,
|
||||
count: controls.filter(c => c.domain === d.id).length,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Technische Pruefbibliothek fuer Zahlungssysteme — {controls.length} Controls in {domains.length} Domaenen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setTab('controls')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Controls ({controls.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('assessments')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Assessments ({assessments.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('tender')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Ausschreibung ({tenderAnalyses.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800">
|
||||
<div className="font-semibold mb-2">Wie funktioniert Payment Terminal Compliance?</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="font-medium mb-1">1. Controls durchsuchen</div>
|
||||
<p className="text-xs text-blue-700">Unsere Bibliothek enthaelt {controls.length} technische Pruefregeln fuer Zahlungssysteme — von Transaktionslogik ueber Kryptographie bis ZVT/OPI-Protokollverhalten. Jeder Control definiert was geprueft wird und welche Evidenz noetig ist.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">2. Assessment erstellen</div>
|
||||
<p className="text-xs text-blue-700">Ein Assessment ist eine projektbezogene Pruefung — z.B. fuer eine bestimmte Ausschreibung oder einen Kunden. Sie ordnet jedem Control einen Status zu: bestanden, fehlgeschlagen, teilweise oder nicht anwendbar.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">3. Ausschreibung analysieren</div>
|
||||
<p className="text-xs text-blue-700">Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch die Anforderungen und matcht sie gegen unsere Controls. Ergebnis: Welche Anforderungen sind abgedeckt und wo gibt es Luecken.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : tab === 'controls' ? (
|
||||
<>
|
||||
{/* Domain Filter */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||
<button onClick={() => setSelectedDomain('all')}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
|
||||
<div className="text-xs text-gray-500">Alle</div>
|
||||
</button>
|
||||
{domainStats.map(d => (
|
||||
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-gray-900">{d.count}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{d.id}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Domain Description */}
|
||||
{selectedDomain !== 'all' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
|
||||
{domains.find(d => d.id === selectedDomain)?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls List */}
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => {
|
||||
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
|
||||
return (
|
||||
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
|
||||
{ctrl.automation}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{ctrl.evidence.map(ev => (
|
||||
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : tab === 'assessments' ? (
|
||||
<>
|
||||
{/* Assessments Tab */}
|
||||
<div className="mb-4">
|
||||
<button onClick={() => setShowNewAssessment(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Neues Assessment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewAssessment && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
|
||||
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
|
||||
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
|
||||
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||
<option value="full_stack">Full Stack (Terminal + Backend)</option>
|
||||
<option value="terminal">Nur Terminal</option>
|
||||
<option value="backend">Nur Backend</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
|
||||
<button onClick={() => setShowNewAssessment(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assessments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Assessments</p>
|
||||
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assessments.map(a => (
|
||||
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
{a.customer_name && <span>{a.customer_name} · </span>}
|
||||
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
|
||||
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
a.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{a.status}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold">{a.total_controls}</div>
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-50 rounded">
|
||||
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
|
||||
<div className="text-xs text-gray-500">Passed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-red-50 rounded">
|
||||
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-yellow-50 rounded">
|
||||
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
|
||||
<div className="text-xs text-gray-500">Partial</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
|
||||
<div className="text-xs text-gray-500">N/A</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
|
||||
<div className="text-xs text-gray-500">Offen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : tab === 'tender' ? (
|
||||
<>
|
||||
{/* Tender Analysis Tab */}
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
|
||||
</p>
|
||||
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
|
||||
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
|
||||
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
|
||||
</div>
|
||||
|
||||
{/* Selected Tender Detail */}
|
||||
{selectedTender && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
|
||||
<p className="text-sm text-gray-500">{selectedTender.file_name} — {selectedTender.status}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
|
||||
<div className="text-xs text-gray-500">Anforderungen</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
|
||||
<div className="text-xs text-gray-500">Abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
|
||||
<div className="text-xs text-gray-500">Teilweise</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
|
||||
<div className="text-xs text-gray-500">Luecken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Results */}
|
||||
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900">Requirement → Control Matching</h4>
|
||||
{selectedTender.match_results.map((mr, idx) => (
|
||||
<div key={idx} className={`p-4 rounded-lg border ${
|
||||
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
|
||||
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
|
||||
'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
|
||||
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-red-200 text-red-800'
|
||||
}`}>
|
||||
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{mr.req_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{mr.matched_controls && mr.matched_controls.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{mr.matched_controls.map(mc => (
|
||||
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
|
||||
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mr.gap_description && (
|
||||
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous Analyses */}
|
||||
{tenderAnalyses.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
|
||||
<div className="space-y-3">
|
||||
{tenderAnalyses.map(ta => (
|
||||
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
|
||||
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
|
||||
<p className="text-xs text-gray-500">{ta.file_name} — {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
|
||||
{ta.unmatched_count > 0 && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>{ta.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -250,4 +250,95 @@ export const STEPS_BETRIEB: SDKFlowStep[] = [
|
||||
url: '/sdk/isms',
|
||||
completion: 100,
|
||||
},
|
||||
|
||||
// ── Control Pipeline ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'control-library',
|
||||
name: 'Canonical Control Library',
|
||||
nameShort: 'Control Library',
|
||||
package: 'betrieb',
|
||||
seq: 5200,
|
||||
checkpointId: 'CP-CLIB',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Verwaltung der ~33.000 Rich Controls aus dem RAG-Korpus. 7-Stufen-Pipeline mit Lizenz-Gate.',
|
||||
descriptionLong: 'Die Canonical Control Library ist das zentrale Verzeichnis aller aus Regulierungstexten generierten Compliance Controls. Die 7-Stufen-Pipeline verarbeitet ~105.000 RAG-Chunks: (1) RAG Scan, (2) Lizenz-Klassifikation (Rule 1/2/3), (3a) Strukturierung (Rule 1+2) oder (3b) Reformulierung (Rule 3), (4) Harmonisierung (Embedding-Dedup), (5) Anchor Search (Open-Source-Referenzen), (6) Speicherung, (7) Chunk-Tracking. Domains: AUTH, CRYP, NET, DATA, SEC, AI, COMP, GOV, LAB, FIN u.a.',
|
||||
legalBasis: 'UrhG §44b (Text & Data Mining), UrhG §23 (Hinreichender Abstand)',
|
||||
inputs: ['ragChunks'],
|
||||
outputs: ['canonicalControls'],
|
||||
prerequisiteSteps: [],
|
||||
dbTables: ['canonical_controls', 'canonical_processed_chunks', 'canonical_generation_jobs'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: ['bp_compliance_gesetze', 'bp_compliance_datenschutz', 'bp_compliance_ce', 'bp_dsfa_corpus', 'bp_legal_templates'],
|
||||
ragPurpose: 'Quelldokumente fuer Control-Generierung (Gesetze, Verordnungen, Standards)',
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 100,
|
||||
},
|
||||
{
|
||||
id: 'obligation-extraction',
|
||||
name: 'Pass 0a: Obligation Extraction',
|
||||
nameShort: 'Pass 0a',
|
||||
package: 'betrieb',
|
||||
seq: 5300,
|
||||
checkpointId: 'CP-P0A',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Extraktion von ~181.000 normativen Pflichten aus Rich Controls via Claude Haiku (Batch API).',
|
||||
descriptionLong: 'Pass 0a zerlegt jeden Rich Control in einzelne normative Obligations via Claude Haiku (Anthropic Batch API, 50% Kostenreduktion). Jede Obligation wird klassifiziert: Pflicht/Empfehlung/Kann, Test-Obligation ja/nein, Reporting-Obligation ja/nein. Quality Gate mit 6 Regeln: nur normative Aussagen, ein Hauptverb, Test/Reporting separat, kein Evidence-Level-Split. Ergebnis: ~181.000 validierte Obligations mit action, object, condition, normative_strength.',
|
||||
legalBasis: 'Pipeline-intern (Normative Obligation Extraction)',
|
||||
inputs: ['canonicalControls'],
|
||||
outputs: ['obligationCandidates'],
|
||||
prerequisiteSteps: ['control-library'],
|
||||
dbTables: ['obligation_candidates'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 90,
|
||||
},
|
||||
{
|
||||
id: 'atomic-composition',
|
||||
name: 'Pass 0b: Atomic Composition',
|
||||
nameShort: 'Pass 0b',
|
||||
package: 'betrieb',
|
||||
seq: 5400,
|
||||
checkpointId: 'CP-P0B',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Komposition atomarer MCP-tauglicher Controls aus Obligations via Claude Sonnet + Pre-LLM Ontology-Filter.',
|
||||
descriptionLong: 'Pass 0b verwandelt jede validierte Obligation in ein eigenstaendiges atomares Control via Claude Sonnet (Anthropic Batch API). Vor dem LLM-Call klassifiziert die Control Ontology (26 Action Types) jede Obligation: atomic (an LLM senden), composite (ueberspringen), evidence (ueberspringen), framework_container (ueberspringen). MCP-taugliche Output-Felder: assertion (pruefbare Aussage), pass_criteria, fail_criteria, check_type (technical_config_check, document_clause_check, code_pattern_check), dependency_hints, lifecycle_phase_order (1-13). Canonical Key Format: action_type:normalized_object:control_phase.',
|
||||
legalBasis: 'Pipeline-intern (Atomic Control Composition)',
|
||||
inputs: ['obligationCandidates'],
|
||||
outputs: ['atomicControls'],
|
||||
prerequisiteSteps: ['obligation-extraction'],
|
||||
dbTables: ['canonical_controls', 'control_parent_links'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 80,
|
||||
},
|
||||
{
|
||||
id: 'dependency-engine',
|
||||
name: 'Dependency Engine + Evaluation',
|
||||
nameShort: 'Dependencies',
|
||||
package: 'betrieb',
|
||||
seq: 5500,
|
||||
checkpointId: 'CP-DEP',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: '5 Dependency-Typen, generische Condition Language, automatische Generierung via Ontology + Domain Packs.',
|
||||
descriptionLong: 'Die Dependency Engine modelliert logische Abhaengigkeiten zwischen Controls: supersedes (A ersetzt B), prerequisite (A muss vor B), compensating_control (A kompensiert B-Failure), scope_exclusion (A schliesst B aus), conditional_requirement (B nur unter Bedingung). Generische Condition Language (AND/OR/NOT + Feldoperatoren). Priority-basierte Konfliktloesung. Zykluserkennung (DFS). Automatische Generierung via: (1) Ontology (Phase-Sequenz), (2) Pattern-Regeln, (3) Domain Packs (DSGVO, AI Act, CRA, Security, Arbeitsrecht). MCP-Output mit dependency_resolution Trace.',
|
||||
legalBasis: 'Pipeline-intern (Control Dependency Resolution)',
|
||||
inputs: ['atomicControls'],
|
||||
outputs: ['evaluatedControls', 'dependencyGraph'],
|
||||
prerequisiteSteps: ['atomic-composition'],
|
||||
dbTables: ['control_dependencies', 'control_evaluation_results'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 100,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -53,7 +53,7 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
|
||||
checkpointId: 'CP-UC',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl.',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl. Inkl. BetrVG-Mitbestimmungspruefung und Betriebsrats-Konflikt-Score.',
|
||||
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad — assistiv/teilautomatisiert/vollautomatisiert. (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA).',
|
||||
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
||||
inputs: ['companyProfile'],
|
||||
@@ -66,6 +66,27 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
|
||||
isOptional: false,
|
||||
url: '/sdk/use-cases',
|
||||
},
|
||||
{
|
||||
id: 'ai-registration',
|
||||
name: 'EU AI Database Registrierung',
|
||||
nameShort: 'EU-Reg',
|
||||
package: 'vorbereitung',
|
||||
seq: 350,
|
||||
checkpointId: 'CP-REG',
|
||||
checkpointType: 'CONDITIONAL',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Registrierung von Hochrisiko-KI-Systemen in der EU AI Database gemaess Art. 49 KI-Verordnung.',
|
||||
descriptionLong: 'Fuer Hochrisiko-KI-Systeme (Annex III) ist eine Registrierung in der EU AI Database Pflicht. Ein 6-Schritte-Wizard fuehrt durch den Prozess: (1) Anbieter-Daten — Name, Rechtsform, Adresse, EU-Repraesentant. (2) System-Details — Name, Version, Beschreibung, Einsatzzweck (vorausgefuellt aus UCCA Assessment). (3) Klassifikation — Risikoklasse und Annex III Kategorie (aus Decision Tree). (4) Konformitaet — CE-Kennzeichnung, Notified Body. (5) Trainingsdaten — Zusammenfassung der Datenquellen (Art. 10). (6) Pruefung + Export — JSON-Download fuer EU-Datenbank-Submission. Der Status-Workflow ist: Entwurf → Bereit → Eingereicht → Registriert.',
|
||||
legalBasis: 'Art. 49 KI-Verordnung (EU) 2024/1689',
|
||||
inputs: ['useCases', 'companyProfile'],
|
||||
outputs: ['euRegistration'],
|
||||
prerequisiteSteps: ['use-case-assessment'],
|
||||
dbTables: ['ai_system_registrations'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: true,
|
||||
url: '/sdk/ai-registration',
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
name: 'Dokument-Import',
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||
import { EnrichmentHints } from '@/components/sdk/assessment/EnrichmentHints'
|
||||
|
||||
interface TriggeredRule {
|
||||
code: string
|
||||
@@ -57,6 +59,8 @@ interface FullAssessment {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
triggered_rules?: TriggeredRule[]
|
||||
required_controls?: RequiredControl[]
|
||||
recommended_architecture?: PatternRecommendation[]
|
||||
@@ -136,6 +140,18 @@ export default function AssessmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const handleOptimize = async () => {
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/maximizer/optimize-from-assessment/${assessmentId}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/sdk/compliance-optimizer/${data.id}`)
|
||||
}
|
||||
} catch { /* silent */ } finally { setOptimizing(false) }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -167,6 +183,8 @@ export default function AssessmentDetailPage() {
|
||||
dsfa_recommended: assessment.dsfa_recommended,
|
||||
art22_risk: assessment.art22_risk,
|
||||
training_allowed: assessment.training_allowed,
|
||||
betrvg_conflict_score: assessment.betrvg_conflict_score,
|
||||
betrvg_consultation_required: assessment.betrvg_consultation_required,
|
||||
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||
rule_code: r.code,
|
||||
@@ -230,6 +248,13 @@ export default function AssessmentDetailPage() {
|
||||
>
|
||||
↓ JSON
|
||||
</a>
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{optimizing ? 'Optimiere...' : 'Optimieren'}
|
||||
</button>
|
||||
<Link
|
||||
href={`/sdk/use-cases/new?edit=${assessmentId}`}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
@@ -269,6 +294,18 @@ export default function AssessmentDetailPage() {
|
||||
{/* Result */}
|
||||
<AssessmentResultCard result={resultForCard as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
|
||||
{/* Enrichment Hints */}
|
||||
{assessment.enrichment_hints && (
|
||||
<EnrichmentHints hints={assessment.enrichment_hints} />
|
||||
)}
|
||||
|
||||
{/* Compliance Optimizer Upsell */}
|
||||
<OptimizerUpsellCard
|
||||
feasibility={assessment.feasibility}
|
||||
assessmentId={assessmentId}
|
||||
riskScore={assessment.risk_score}
|
||||
/>
|
||||
|
||||
{/* KI-Erklärung */}
|
||||
{assessment.explanation_text && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Assessment {
|
||||
feasibility: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
domain: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -194,6 +196,16 @@ export default function UseCasesPage() {
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||
{feasibility.label}
|
||||
</span>
|
||||
{assessment.betrvg_conflict_score != null && assessment.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
assessment.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
assessment.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
assessment.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR {assessment.betrvg_conflict_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{assessment.domain}</span>
|
||||
|
||||
@@ -42,6 +42,30 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KI-Compliance */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
KI-Compliance
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem href="/sdk/advisory-board" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>} label="Use Case Erfassung" isActive={pathname === '/sdk/advisory-board'} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/use-cases" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>} label="Use Cases" isActive={pathname?.startsWith('/sdk/use-cases') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/ai-act" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>} label="AI Act" isActive={pathname?.startsWith('/sdk/ai-act') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* Payment / Terminal */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Payment / Terminal
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem href="/sdk/payment-compliance" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>} label="Payment Compliance" isActive={pathname?.startsWith('/sdk/payment-compliance') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* Additional Modules */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
|
||||
@@ -195,6 +195,16 @@ export const STEP_EXPLANATIONS_PART2: Record<string, ExplanationEntry> = {
|
||||
{ icon: 'lightbulb' as const, title: 'Variablen', description: 'Nutzen Sie Platzhalter wie {{name}}, {{email}} und {{company}} fuer automatische Personalisierung.' },
|
||||
],
|
||||
},
|
||||
'ai-act': {
|
||||
title: 'AI Act Compliance',
|
||||
description: 'Klassifizieren Sie Ihre KI-Systeme nach dem EU AI Act',
|
||||
explanation: 'Der EU AI Act (Verordnung 2024/1689) teilt KI-Systeme in Risikoklassen ein: verboten, Hochrisiko, begrenzt und minimal. Hier registrieren Sie Ihre KI-Systeme, klassifizieren sie ueber den Decision Tree und verwalten die daraus resultierenden Pflichten. Hochrisiko-Systeme erfordern u.a. Risikomanagementsystem, technische Dokumentation, Logging und menschliche Aufsicht.',
|
||||
tips: [
|
||||
{ icon: 'warning' as const, title: 'Fristen beachten', description: 'Verbotene KI-Praktiken gelten seit Februar 2025. Hochrisiko-Pflichten greifen ab August 2026.' },
|
||||
{ icon: 'lightbulb' as const, title: 'Decision Tree nutzen', description: 'Der 2-Achsen Decision Tree (Hochrisiko + GPAI) hilft bei der systematischen Einstufung nach Annex III.' },
|
||||
{ icon: 'info' as const, title: 'GPAI-Modelle', description: 'General Purpose AI (z.B. LLMs) hat eigene Transparenz- und Sicherheitspflichten — pruefen Sie auch Axis 2.' },
|
||||
],
|
||||
},
|
||||
'use-case-workshop': {
|
||||
title: 'Use Case Workshop',
|
||||
description: 'Erfassen und bewerten Sie Ihre KI-Anwendungsfaelle im Workshop-Format',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { StepHeader, STEP_EXPLANATIONS } from './StepHeader'
|
||||
export { StepHeader } from './StepHeader'
|
||||
export { STEP_EXPLANATIONS } from './StepExplanations'
|
||||
export type { StepTip } from './StepHeader'
|
||||
|
||||
@@ -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. 51–56).
|
||||
</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. 51–56)</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. 51–56)</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">×</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface EnrichmentHint {
|
||||
field: string
|
||||
label: string
|
||||
impact: string
|
||||
regulation: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES = {
|
||||
high: { icon: '⚠️', border: 'border-amber-300', bg: 'bg-amber-50' },
|
||||
medium: { icon: 'ℹ️', border: 'border-blue-200', bg: 'bg-blue-50' },
|
||||
low: { icon: '💡', border: 'border-gray-200', bg: 'bg-gray-50' },
|
||||
}
|
||||
|
||||
export function EnrichmentHints({ hints }: { hints: EnrichmentHint[] }) {
|
||||
if (!hints || hints.length === 0) return null
|
||||
|
||||
const highPriority = hints.filter(h => h.priority === 'high')
|
||||
const otherPriority = hints.filter(h => h.priority !== 'high')
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📋</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Bewertung verbessern — {hints.length} fehlende Firmendaten
|
||||
</h3>
|
||||
<p className="text-xs text-amber-700 mt-1 mb-3">
|
||||
Ergaenzen Sie diese Daten im Unternehmensprofil fuer eine vollstaendige regulatorische Bewertung.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{highPriority.map((h, i) => {
|
||||
const style = PRIORITY_STYLES[h.priority as keyof typeof PRIORITY_STYLES] || PRIORITY_STYLES.medium
|
||||
return (
|
||||
<div key={i} className={`flex items-start gap-2 ${style.bg} border ${style.border} rounded-lg px-3 py-2`}>
|
||||
<span className="text-sm">{style.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-800">{h.label}</span>
|
||||
<span className="text-xs text-gray-500 ml-2 px-1.5 py-0.5 bg-white rounded">{h.regulation}</span>
|
||||
<p className="text-xs text-gray-600 mt-0.5">{h.impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{otherPriority.map((h, i) => (
|
||||
<div key={`other-${i}`} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>ℹ️</span>
|
||||
<span>{h.label}</span>
|
||||
<span className="text-xs text-gray-400">({h.regulation})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/sdk/company-profile"
|
||||
className="inline-flex items-center gap-1 mt-3 text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Unternehmensprofil ergaenzen →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
interface DimensionDelta {
|
||||
dimension: string
|
||||
from: string
|
||||
to: string
|
||||
impact: string
|
||||
}
|
||||
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
automation_level: 'Automatisierungsgrad',
|
||||
decision_binding: 'Entscheidungsbindung',
|
||||
decision_impact: 'Entscheidungswirkung',
|
||||
domain: 'Branche',
|
||||
data_type: 'Datensensitivitaet',
|
||||
human_in_loop: 'Menschliche Kontrolle',
|
||||
explainability: 'Erklaerbarkeit',
|
||||
risk_classification: 'Risikoklasse',
|
||||
legal_basis: 'Rechtsgrundlage',
|
||||
transparency_required: 'Transparenzpflicht',
|
||||
logging_required: 'Protokollierung',
|
||||
model_type: 'Modelltyp',
|
||||
deployment_scope: 'Einsatzbereich',
|
||||
}
|
||||
|
||||
export function ConfigComparison({ deltas }: { deltas: DimensionDelta[] }) {
|
||||
if (deltas.length === 0) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-green-700 text-sm">
|
||||
Keine Aenderungen noetig — Ihre Konfiguration ist bereits konform.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Empfohlene Aenderungen ({deltas.length})</h4>
|
||||
<div className="space-y-1">
|
||||
{deltas.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2 bg-blue-50 border border-blue-200 rounded px-3 py-2 text-sm">
|
||||
<span className="font-medium text-gray-800 min-w-[160px]">
|
||||
{DIMENSION_LABELS[d.dimension] || d.dimension}
|
||||
</span>
|
||||
<span className="text-red-600 font-mono line-through">{d.from}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-green-700 font-mono font-bold">{d.to}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { ZoneBadge } from './ZoneBadge'
|
||||
|
||||
interface ZoneInfo {
|
||||
dimension: string
|
||||
current_value: string
|
||||
zone: 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'
|
||||
allowed_values?: string[]
|
||||
forbidden_values?: string[]
|
||||
safeguards?: string[]
|
||||
reason: string
|
||||
obligation_refs: string[]
|
||||
}
|
||||
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
automation_level: 'Automatisierungsgrad',
|
||||
decision_binding: 'Entscheidungsbindung',
|
||||
decision_impact: 'Entscheidungswirkung',
|
||||
domain: 'Branche',
|
||||
data_type: 'Datensensitivitaet',
|
||||
human_in_loop: 'Menschliche Kontrolle',
|
||||
explainability: 'Erklaerbarkeit',
|
||||
risk_classification: 'Risikoklasse',
|
||||
legal_basis: 'Rechtsgrundlage',
|
||||
transparency_required: 'Transparenzpflicht',
|
||||
logging_required: 'Protokollierung',
|
||||
model_type: 'Modelltyp',
|
||||
deployment_scope: 'Einsatzbereich',
|
||||
}
|
||||
|
||||
export function DimensionZoneTable({ zoneMap }: { zoneMap: Record<string, ZoneInfo> }) {
|
||||
const dimensions = Object.entries(zoneMap).sort(([, a], [, b]) => {
|
||||
const order = { FORBIDDEN: 0, RESTRICTED: 1, SAFE: 2 }
|
||||
return (order[a.zone] ?? 2) - (order[b.zone] ?? 2)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Dimension</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktueller Wert</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Zone</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Regelgrund</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Rechtsgrundlage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{dimensions.map(([dim, info]) => (
|
||||
<tr key={dim} className={info.zone === 'FORBIDDEN' ? 'bg-red-50' : info.zone === 'RESTRICTED' ? 'bg-yellow-50' : ''}>
|
||||
<td className="px-4 py-2 text-sm font-medium text-gray-900">
|
||||
{DIMENSION_LABELS[dim] || dim}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600 font-mono">
|
||||
{info.current_value}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<ZoneBadge zone={info.zone} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-600">
|
||||
{info.reason || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-gray-500">
|
||||
{info.obligation_refs?.join(', ') || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
interface ScoreCardProps {
|
||||
safetyScore: number
|
||||
utilityScore: number
|
||||
compositeScore: number
|
||||
deltaCount: number
|
||||
}
|
||||
|
||||
function ScoreGauge({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="relative w-16 h-16">
|
||||
<svg viewBox="0 0 36 36" className="w-16 h-16">
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none" stroke="#e5e7eb" strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
fill="none" stroke={color} strokeWidth="3"
|
||||
strokeDasharray={`${value}, 100`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold text-gray-800">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OptimizationScoreCard({ safetyScore, utilityScore, compositeScore, deltaCount }: ScoreCardProps) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Bewertung der optimierten Konfiguration</h4>
|
||||
<div className="flex items-center gap-6">
|
||||
<ScoreGauge value={safetyScore} label="Sicherheit" color="#22c55e" />
|
||||
<ScoreGauge value={utilityScore} label="Business-Nutzen" color="#3b82f6" />
|
||||
<ScoreGauge value={Math.round(compositeScore)} label="Gesamt" color="#8b5cf6" />
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-2xl font-bold text-gray-800">{deltaCount}</span>
|
||||
<span className="text-xs text-gray-500">Aenderungen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface OptimizerUpsellCardProps {
|
||||
feasibility: string
|
||||
assessmentId: string
|
||||
riskScore?: number
|
||||
}
|
||||
|
||||
export function OptimizerUpsellCard({ feasibility, assessmentId, riskScore }: OptimizerUpsellCardProps) {
|
||||
const isRestricted = feasibility === 'CONDITIONAL' || feasibility === 'NO'
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-300 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📊</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-amber-900">
|
||||
{feasibility === 'NO' ? 'Use Case aktuell nicht umsetzbar' : 'Use Case eingeschraenkt machbar'}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 mt-1">
|
||||
Der <strong>Compliance Optimizer</strong> zeigt Ihnen die optimale Konfiguration,
|
||||
um den regulatorischen Spielraum maximal auszunutzen — ohne Grenzen zu ueberschreiten.
|
||||
</p>
|
||||
{riskScore != null && riskScore >= 50 && (
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
Risiko-Score {riskScore}/100 — besonders hohes Optimierungspotenzial.
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/sdk/compliance-optimizer/new?from_assessment=${assessmentId}`}
|
||||
className="inline-flex items-center gap-1 mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Jetzt optimieren →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-blue-900">Regulatorischen Spielraum pruefen</h3>
|
||||
<p className="text-xs text-blue-700 mt-0.5">
|
||||
Pruefen Sie ob Sie den regulatorischen Spielraum noch besser nutzen koennen.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/sdk/compliance-optimizer/new?from_assessment=${assessmentId}`}
|
||||
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
Optional optimieren →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
const ZONE_STYLES = {
|
||||
FORBIDDEN: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300', label: 'Verboten' },
|
||||
RESTRICTED: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300', label: 'Eingeschraenkt' },
|
||||
SAFE: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300', label: 'Erlaubt' },
|
||||
}
|
||||
|
||||
export function ZoneBadge({ zone }: { zone: 'FORBIDDEN' | 'RESTRICTED' | 'SAFE' }) {
|
||||
const style = ZONE_STYLES[zone] || ZONE_STYLES.SAFE
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${style.bg} ${style.text} ${style.border}`}>
|
||||
{style.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface RegulatoryNewsItemData {
|
||||
id: string
|
||||
headline: string
|
||||
summary: string
|
||||
legal_reference: string
|
||||
deadline: string
|
||||
days_remaining: number
|
||||
urgency: 'critical' | 'high' | 'medium' | 'low'
|
||||
affected: string
|
||||
action_required: string
|
||||
action_link: string
|
||||
regulation: string
|
||||
sanctions?: string
|
||||
}
|
||||
|
||||
const URGENCY_STYLES = {
|
||||
critical: { badge: 'bg-red-100 text-red-700 border-red-200', border: 'border-l-red-500', icon: '🔴' },
|
||||
high: { badge: 'bg-orange-100 text-orange-700 border-orange-200', border: 'border-l-orange-400', icon: '🟠' },
|
||||
medium: { badge: 'bg-yellow-100 text-yellow-700 border-yellow-200', border: 'border-l-yellow-400', icon: '🟡' },
|
||||
low: { badge: 'bg-gray-100 text-gray-600 border-gray-200', border: 'border-l-gray-300', icon: '🔵' },
|
||||
}
|
||||
|
||||
export function RegulatoryNewsCard({ item }: { item: RegulatoryNewsItemData }) {
|
||||
const style = URGENCY_STYLES[item.urgency] || URGENCY_STYLES.low
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 border-l-4 ${style.border} rounded-lg p-4`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{item.headline}</h4>
|
||||
<p className="text-xs text-gray-600 mt-1">{item.summary}</p>
|
||||
<p className="text-xs text-gray-400 mt-1 italic">{item.legal_reference}</p>
|
||||
{item.sanctions && (
|
||||
<p className="text-xs text-red-600 mt-1">Sanktionen: {item.sanctions}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${style.badge}`}>
|
||||
{style.icon} {item.days_remaining} Tage
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(item.deadline).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400">{item.affected}</span>
|
||||
{item.action_link && (
|
||||
<Link href={item.action_link} className="text-xs text-blue-600 hover:underline font-medium">
|
||||
Massnahme ergreifen →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { RegulatoryNewsCard, RegulatoryNewsItemData } from './RegulatoryNewsCard'
|
||||
|
||||
interface RegulatoryNewsFeedProps {
|
||||
businessModel?: string
|
||||
maxItems?: number
|
||||
}
|
||||
|
||||
export function RegulatoryNewsFeed({ businessModel, maxItems = 3 }: RegulatoryNewsFeedProps) {
|
||||
const [items, setItems] = useState<RegulatoryNewsItemData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ limit: '10', horizon_days: '365' })
|
||||
if (businessModel) params.set('business_model', businessModel)
|
||||
|
||||
fetch(`/api/sdk/v1/regulatory-news?${params}`)
|
||||
.then(r => r.ok ? r.json() : { items: [] })
|
||||
.then(data => setItems(data.items || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [businessModel])
|
||||
|
||||
if (loading) return null
|
||||
if (items.length === 0) return null
|
||||
|
||||
const visible = showAll ? items : items.slice(0, maxItems)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<span>📢</span> Regulatorische Neuigkeiten
|
||||
</h2>
|
||||
{items.length > maxItems && (
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{showAll ? 'Weniger' : `Alle ${items.length} anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{visible.map(item => (
|
||||
<RegulatoryNewsCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ interface AssessmentResult {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
summary: string
|
||||
recommendation: string
|
||||
alternative_approach?: string
|
||||
@@ -76,6 +78,21 @@ export function AssessmentResultCard({ result }: AssessmentResultCardProps) {
|
||||
Art. 22 Risiko
|
||||
</span>
|
||||
)}
|
||||
{result.betrvg_conflict_score != null && result.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
result.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
result.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
result.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR-Konflikt: {result.betrvg_conflict_score}/100
|
||||
</span>
|
||||
)}
|
||||
{result.betrvg_consultation_required && (
|
||||
<span className="px-3 py-1 rounded-full text-sm bg-purple-100 text-purple-700">
|
||||
BR-Konsultation erforderlich
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-700">{result.summary}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
import type { HardTriggerRule } from './compliance-scope-types'
|
||||
import { HARD_TRIGGER_RULES_A_E } from './compliance-scope-triggers/triggers-a-e'
|
||||
import { HARD_TRIGGER_RULES_F_M } from './compliance-scope-triggers/triggers-f-m'
|
||||
import { HARD_TRIGGER_RULES_N_V } from './compliance-scope-triggers/triggers-n-v'
|
||||
|
||||
export { HARD_TRIGGER_RULES_A_E } from './compliance-scope-triggers/triggers-a-e'
|
||||
export { HARD_TRIGGER_RULES_F_M } from './compliance-scope-triggers/triggers-f-m'
|
||||
export { HARD_TRIGGER_RULES_N_V } from './compliance-scope-triggers/triggers-n-v'
|
||||
|
||||
export const HARD_TRIGGER_RULES: HardTriggerRule[] = [
|
||||
...HARD_TRIGGER_RULES_A_E,
|
||||
...HARD_TRIGGER_RULES_F_M,
|
||||
...HARD_TRIGGER_RULES_N_V,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Hard Trigger Rules N–V
|
||||
* Groups: Verbraucherrecht (N)
|
||||
*/
|
||||
import type { HardTriggerRule } from '../compliance-scope-types'
|
||||
|
||||
export const HARD_TRIGGER_RULES_N_V: HardTriggerRule[] = [
|
||||
// ========== N: Verbraucherrecht / E-Commerce ==========
|
||||
{
|
||||
id: 'HT-N01',
|
||||
category: 'consumer_protection',
|
||||
questionId: 'org_business_model',
|
||||
condition: 'IN',
|
||||
conditionValue: ['B2C', 'B2B2C'],
|
||||
minimumLevel: 'L2',
|
||||
requiresDSFA: false,
|
||||
mandatoryDocuments: ['WIDERRUF', 'CONSENT'],
|
||||
legalReference: 'EU-RL 2023/2673, § 356a BGB',
|
||||
description: 'B2C-Geschaeftsmodell: Widerrufsbutton-Pflicht ab 19.06.2026, Widerrufsbelehrung, Button-Loesung',
|
||||
},
|
||||
{
|
||||
id: 'HT-N02',
|
||||
category: 'consumer_protection',
|
||||
questionId: 'org_operates_online_shop',
|
||||
condition: 'EQUALS',
|
||||
conditionValue: 'yes',
|
||||
minimumLevel: 'L2',
|
||||
requiresDSFA: false,
|
||||
mandatoryDocuments: ['WIDERRUF', 'AGB', 'CONSENT'],
|
||||
legalReference: '§ 312j BGB, EU-RL 2023/2673',
|
||||
description: 'Online-Shop: Widerrufsbutton, Button-Loesung (zahlungspflichtig bestellen), AGB',
|
||||
},
|
||||
{
|
||||
id: 'HT-N03',
|
||||
category: 'consumer_protection',
|
||||
questionId: 'org_offers_subscriptions',
|
||||
condition: 'EQUALS',
|
||||
conditionValue: 'yes',
|
||||
minimumLevel: 'L2',
|
||||
requiresDSFA: false,
|
||||
mandatoryDocuments: ['WIDERRUF', 'CONSENT'],
|
||||
legalReference: 'EU-RL 2023/2673, § 356a BGB',
|
||||
description: 'Abo-Modell: Widerrufsbutton-Pflicht, Kuendigungsbutton (§ 312k BGB)',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Wiki Article: BetrVG & KI — Mitbestimmung bei IT-Systemen
|
||||
-- Kategorie: arbeitsrecht (existiert bereits)
|
||||
-- Ausfuehren auf Production-DB nach Compliance-Refactoring
|
||||
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES
|
||||
('betrvg-ki-mitbestimmung', 'arbeitsrecht',
|
||||
'BetrVG & KI — Mitbestimmung bei IT-Systemen',
|
||||
'Uebersicht der Mitbestimmungsrechte des Betriebsrats bei Einfuehrung von KI- und IT-Systemen gemaess §87 Abs.1 Nr.6 BetrVG. Inkl. BAG-Rechtsprechung und Konflikt-Score.',
|
||||
'# BetrVG & KI — Mitbestimmung bei IT-Systemen
|
||||
|
||||
## Kernregel: §87 Abs.1 Nr.6 BetrVG
|
||||
|
||||
Die **Einfuehrung und Anwendung** von technischen Einrichtungen, die dazu **geeignet** sind, das Verhalten oder die Leistung der Arbeitnehmer zu ueberwachen, beduerfen der **Zustimmung des Betriebsrats**.
|
||||
|
||||
### Wichtig: Eignung genuegt!
|
||||
Das BAG hat klargestellt: Bereits die **objektive Eignung** zur Ueberwachung genuegt — eine tatsaechliche Nutzung zu diesem Zweck ist nicht erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## Leitentscheidungen des BAG
|
||||
|
||||
### Microsoft Office 365 (BAG 1 ABR 20/21, 08.03.2022)
|
||||
Das BAG hat ausdruecklich entschieden, dass Microsoft Office 365 der Mitbestimmung unterliegt.
|
||||
|
||||
### Standardsoftware (BAG 1 ABN 36/18, 23.10.2018)
|
||||
Auch alltaegliche Standardsoftware wie Excel ist mitbestimmungsrelevant. Keine Geringfuegigkeitsschwelle.
|
||||
|
||||
### SAP ERP (BAG 1 ABR 45/11, 25.09.2012)
|
||||
HR-/ERP-Systeme erheben und verknuepfen individualisierbare Verhaltens- und Leistungsdaten.
|
||||
|
||||
### SaaS/Cloud (BAG 1 ABR 68/13, 21.07.2015)
|
||||
Auch bei Ueberwachung ueber Dritt-Systeme bleibt der Betriebsrat zu beteiligen.
|
||||
|
||||
### Belastungsstatistik (BAG 1 ABR 46/15, 25.04.2017)
|
||||
Dauerhafte Kennzahlenueberwachung ist ein schwerwiegender Eingriff in das Persoenlichkeitsrecht.
|
||||
|
||||
---
|
||||
|
||||
## Betriebsrats-Konflikt-Score (SDK)
|
||||
|
||||
Das SDK berechnet automatisch einen Konflikt-Score (0-100):
|
||||
- Beschaeftigtendaten (+10), Ueberwachungseignung (+20), HR-Bezug (+20)
|
||||
- Individualisierbare Logs (+15), Kommunikationsanalyse (+10)
|
||||
- Scoring/Ranking (+10), Vollautomatisiert (+10), Keine BR-Konsultation (+5)
|
||||
|
||||
Eskalation: Score >= 50 ohne BR → E2, Score >= 75 → E3.',
|
||||
'["§87 Abs.1 Nr.6 BetrVG", "§90 BetrVG", "§94 BetrVG", "§95 BetrVG", "Art. 88 DSGVO", "§26 BDSG"]',
|
||||
ARRAY['BetrVG', 'Mitbestimmung', 'Betriebsrat', 'KI', 'Ueberwachung', 'Microsoft 365'],
|
||||
'critical',
|
||||
'["https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/", "https://www.bundesarbeitsgericht.de/entscheidung/1-abn-36-18/"]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, summary = EXCLUDED.summary, updated_at = NOW();
|
||||
@@ -0,0 +1,157 @@
|
||||
-- Wiki Articles: Domain-spezifische KI-Compliance
|
||||
-- 4 Artikel fuer die wichtigsten Hochrisiko-Domains
|
||||
|
||||
-- 1. KI im Recruiting
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-recruiting-compliance', 'arbeitsrecht',
|
||||
'KI im Recruiting — AGG, DSGVO Art. 22, AI Act Hochrisiko',
|
||||
'Compliance-Anforderungen bei KI-gestuetzter Personalauswahl: Automatisierte Absagen, Bias-Risiken, Beweislastumkehr.',
|
||||
'# KI im Recruiting — Compliance-Anforderungen
|
||||
|
||||
## AI Act Einstufung
|
||||
KI im Recruiting faellt unter **Annex III Nr. 4 (Employment)** = **High-Risk**.
|
||||
|
||||
## Kritische Punkte
|
||||
|
||||
### Art. 22 DSGVO — Automatisierte Entscheidungen
|
||||
Vollautomatische Absagen ohne menschliche Pruefung sind **grundsaetzlich unzulaessig**.
|
||||
Erlaubt: KI erstellt Vorschlag → Mensch prueft → Mensch entscheidet → Mensch gibt Absage frei.
|
||||
|
||||
### AGG — Diskriminierungsverbot
|
||||
- § 1 AGG: Keine Benachteiligung nach Geschlecht, Alter, Herkunft, Religion, Behinderung
|
||||
- § 22 AGG: **Beweislastumkehr** — Arbeitgeber muss beweisen, dass KEINE Diskriminierung vorliegt
|
||||
- § 15 AGG: Schadensersatz bis 3 Monatsgehaelter pro Fall
|
||||
- Proxy-Merkmale vermeiden: Name→Herkunft, Foto→Alter
|
||||
|
||||
### BetrVG — Mitbestimmung
|
||||
- § 87 Abs. 1 Nr. 6: Betriebsrat muss zustimmen
|
||||
- § 95: Auswahlrichtlinien mitbestimmungspflichtig
|
||||
- BAG 1 ABR 20/21: Gilt auch fuer Standardsoftware
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Human-in-the-Loop (echt, kein Rubber Stamping)
|
||||
2. Regelmaessige Bias-Audits
|
||||
3. DSFA durchfuehren
|
||||
4. Betriebsvereinbarung abschliessen
|
||||
5. Bewerber ueber KI-Nutzung informieren',
|
||||
'["Art. 22 DSGVO", "§ 1 AGG", "§ 22 AGG", "§ 15 AGG", "§ 87 BetrVG", "§ 95 BetrVG", "Annex III Nr. 4 AI Act"]',
|
||||
ARRAY['Recruiting', 'HR', 'AGG', 'Bias', 'Art. 22', 'Beweislastumkehr', 'Betriebsrat'],
|
||||
'critical',
|
||||
'["https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/"]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
-- 2. KI in der Bildung
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-bildung-compliance', 'branchenspezifisch',
|
||||
'KI in der Bildung — Notenvergabe, Pruefungsbewertung, Minderjaehrige',
|
||||
'AI Act Annex III Nr. 3: Hochrisiko bei KI-gestuetzter Bewertung in Bildung und Ausbildung.',
|
||||
'# KI in der Bildung — Compliance-Anforderungen
|
||||
|
||||
## AI Act Einstufung
|
||||
KI in Bildung/Ausbildung faellt unter **Annex III Nr. 3 (Education)** = **High-Risk**.
|
||||
|
||||
## Kritische Szenarien
|
||||
- KI beeinflusst Noten → High-Risk
|
||||
- KI bewertet Pruefungen → High-Risk
|
||||
- KI steuert Zugang zu Bildungsangeboten → High-Risk
|
||||
- Minderjaehrige betroffen → Besonderer Schutz (Art. 24 EU-Grundrechtecharta)
|
||||
|
||||
## BLOCK-Regel
|
||||
**Minderjaehrige betroffen + keine Lehrkraft-Pruefung = UNZULAESSIG**
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Lehrkraft prueft JEDES KI-Ergebnis vor Mitteilung an Schueler
|
||||
2. Chancengleichheit unabhaengig von sozioekonomischem Hintergrund
|
||||
3. Keine Benachteiligung durch Sprache oder Behinderung
|
||||
4. FRIA durchfuehren (Grundrechte-Folgenabschaetzung)
|
||||
5. DSFA bei Verarbeitung von Schuelerdaten
|
||||
|
||||
## Grundrechte
|
||||
- Recht auf Bildung (Art. 14 EU-Charta)
|
||||
- Rechte des Kindes (Art. 24 EU-Charta)
|
||||
- Nicht-Diskriminierung (Art. 21 EU-Charta)',
|
||||
'["Annex III Nr. 3 AI Act", "Art. 14 EU-Grundrechtecharta", "Art. 24 EU-Grundrechtecharta", "Art. 35 DSGVO"]',
|
||||
ARRAY['Bildung', 'Education', 'Noten', 'Pruefung', 'Minderjaehrige', 'Schule'],
|
||||
'critical',
|
||||
'[]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
-- 3. KI im Gesundheitswesen
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-gesundheit-compliance', 'branchenspezifisch',
|
||||
'KI im Gesundheitswesen — MDR, Diagnose, Triage',
|
||||
'AI Act Annex III Nr. 5 + MDR: Hochrisiko bei KI in Diagnose, Behandlung und Triage.',
|
||||
'# KI im Gesundheitswesen — Compliance-Anforderungen
|
||||
|
||||
## Regulatorischer Rahmen
|
||||
- **AI Act Annex III Nr. 5** — Zugang zu wesentlichen Diensten (Gesundheit)
|
||||
- **MDR (EU) 2017/745** — Medizinprodukteverordnung
|
||||
- **DSGVO Art. 9** — Gesundheitsdaten = besondere Kategorie
|
||||
|
||||
## Kritische Szenarien
|
||||
- KI unterstuetzt Diagnosen → High-Risk + DSFA Pflicht
|
||||
- KI priorisiert Patienten (Triage) → Lebenskritisch, hoechste Anforderungen
|
||||
- KI empfiehlt Behandlungen → High-Risk
|
||||
- System ist Medizinprodukt → MDR-Zertifizierung erforderlich
|
||||
|
||||
## BLOCK-Regeln
|
||||
- **Medizinprodukt ohne klinische Validierung = UNZULAESSIG**
|
||||
- MDR Art. 61: Klinische Bewertung ist Pflicht
|
||||
|
||||
## Grundrechte
|
||||
- Menschenwuerde (Art. 1 EU-Charta)
|
||||
- Schutz personenbezogener Daten (Art. 8 EU-Charta)
|
||||
- Patientenautonomie
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Klinische Validierung vor Einsatz
|
||||
2. Human Oversight durch qualifiziertes Fachpersonal
|
||||
3. DSFA fuer Gesundheitsdatenverarbeitung
|
||||
4. Genauigkeitsmetriken definieren und messen
|
||||
5. Incident Reporting bei Fehlfunktionen',
|
||||
'["Annex III Nr. 5 AI Act", "MDR (EU) 2017/745", "Art. 9 DSGVO", "Art. 35 DSGVO"]',
|
||||
ARRAY['Gesundheit', 'Healthcare', 'MDR', 'Diagnose', 'Triage', 'Medizinprodukt'],
|
||||
'critical',
|
||||
'[]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
-- 4. KI in Finanzdienstleistungen
|
||||
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
|
||||
VALUES ('ki-finance-compliance', 'branchenspezifisch',
|
||||
'KI in Finanzdienstleistungen — Scoring, DORA, Versicherung',
|
||||
'AI Act Annex III Nr. 5 + DORA + MaRisk: Compliance bei Kredit-Scoring, Algo-Trading, Versicherungspraemien.',
|
||||
'# KI in Finanzdienstleistungen — Compliance-Anforderungen
|
||||
|
||||
## Regulatorischer Rahmen
|
||||
- **AI Act Annex III Nr. 5** — Zugang zu wesentlichen Diensten
|
||||
- **DORA** — Digital Operational Resilience Act
|
||||
- **MaRisk/BAIT** — Bankaufsichtliche Anforderungen
|
||||
- **MiFID II** — Algorithmischer Handel
|
||||
|
||||
## Kritische Szenarien
|
||||
- Kredit-Scoring → High-Risk (Art. 22 DSGVO + Annex III)
|
||||
- Automatisierte Schadenbearbeitung → Art. 22 Risiko
|
||||
- Individuelle Praemienberechnung → Diskriminierungsrisiko
|
||||
- Algo-Trading → MiFID II Anforderungen
|
||||
- Robo Advisor → WpHG-Pflichten
|
||||
|
||||
## Pflichtmassnahmen
|
||||
1. Transparenz bei Scoring-Entscheidungen
|
||||
2. Bias-Audits bei Kreditvergabe
|
||||
3. Human Oversight bei Ablehnungen
|
||||
4. DORA-konforme IT-Resilienz
|
||||
5. Incident Reporting
|
||||
|
||||
## Besondere Risiken
|
||||
- Diskriminierendes Kredit-Scoring (AGG + AI Act)
|
||||
- Ungerechtfertigte Verweigerung von Finanzdienstleistungen
|
||||
- Mangelnde Erklaerbarkeit bei Scoring-Algorithmen',
|
||||
'["Annex III Nr. 5 AI Act", "DORA", "MaRisk", "MiFID II", "Art. 22 DSGVO", "§ 1 AGG"]',
|
||||
ARRAY['Finance', 'Banking', 'Versicherung', 'Scoring', 'DORA', 'Kredit', 'Algo-Trading'],
|
||||
'critical',
|
||||
'[]',
|
||||
1)
|
||||
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
@@ -0,0 +1,172 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/maximizer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MaximizerHandlers exposes the Compliance Maximizer API.
|
||||
type MaximizerHandlers struct {
|
||||
svc *maximizer.Service
|
||||
}
|
||||
|
||||
// NewMaximizerHandlers creates handlers backed by a maximizer service.
|
||||
func NewMaximizerHandlers(svc *maximizer.Service) *MaximizerHandlers {
|
||||
return &MaximizerHandlers{svc: svc}
|
||||
}
|
||||
|
||||
// Optimize evaluates a DimensionConfig and returns optimized variants.
|
||||
func (h *MaximizerHandlers) Optimize(c *gin.Context) {
|
||||
var req maximizer.OptimizeInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.TenantID, _ = getTenantID(c)
|
||||
req.UserID = maximizerGetUserID(c)
|
||||
result, err := h.svc.Optimize(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// OptimizeFromIntake maps a UseCaseIntake to dimensions and optimizes.
|
||||
func (h *MaximizerHandlers) OptimizeFromIntake(c *gin.Context) {
|
||||
var req maximizer.OptimizeFromIntakeInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.TenantID, _ = getTenantID(c)
|
||||
req.UserID = maximizerGetUserID(c)
|
||||
result, err := h.svc.OptimizeFromIntake(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// OptimizeFromAssessment optimizes an existing UCCA assessment.
|
||||
func (h *MaximizerHandlers) OptimizeFromAssessment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assessment id"})
|
||||
return
|
||||
}
|
||||
tid, _ := getTenantID(c)
|
||||
uid := maximizerGetUserID(c)
|
||||
result, err := h.svc.OptimizeFromAssessment(c.Request.Context(), id, tid, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// OptimizeFromIntakeWithProfile maps intake + profile to dimensions and optimizes.
|
||||
func (h *MaximizerHandlers) OptimizeFromIntakeWithProfile(c *gin.Context) {
|
||||
var req maximizer.OptimizeFromIntakeWithProfileInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.TenantID, _ = getTenantID(c)
|
||||
req.UserID = maximizerGetUserID(c)
|
||||
result, err := h.svc.OptimizeFromIntakeWithProfile(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Evaluate performs a 3-zone evaluation without persisting.
|
||||
func (h *MaximizerHandlers) Evaluate(c *gin.Context) {
|
||||
var config maximizer.DimensionConfig
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result := h.svc.Evaluate(&config)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ListOptimizations returns stored optimizations for the tenant.
|
||||
func (h *MaximizerHandlers) ListOptimizations(c *gin.Context) {
|
||||
f := &maximizer.OptimizationFilters{
|
||||
Search: c.Query("search"),
|
||||
Limit: maximizerParseInt(c.Query("limit"), 20),
|
||||
Offset: maximizerParseInt(c.Query("offset"), 0),
|
||||
}
|
||||
tid, _ := getTenantID(c)
|
||||
results, total, err := h.svc.ListOptimizations(c.Request.Context(), tid, f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"optimizations": results, "total": total})
|
||||
}
|
||||
|
||||
// GetOptimization returns a single optimization.
|
||||
func (h *MaximizerHandlers) GetOptimization(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
result, err := h.svc.GetOptimization(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DeleteOptimization removes an optimization.
|
||||
func (h *MaximizerHandlers) DeleteOptimization(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteOptimization(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// GetDimensionSchema returns the dimension enum values for the frontend.
|
||||
func (h *MaximizerHandlers) GetDimensionSchema(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, h.svc.GetDimensionSchema())
|
||||
}
|
||||
|
||||
func maximizerGetUserID(c *gin.Context) uuid.UUID {
|
||||
if id, exists := c.Get("user_id"); exists {
|
||||
if uid, ok := id.(uuid.UUID); ok {
|
||||
return uid
|
||||
}
|
||||
}
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
// maximizerParseInt is a local helper for query param parsing.
|
||||
func maximizerParseInt(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return def
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PaymentHandlers handles payment compliance endpoints
|
||||
type PaymentHandlers struct {
|
||||
pool *pgxpool.Pool
|
||||
controls *PaymentControlLibrary
|
||||
}
|
||||
|
||||
// PaymentControlLibrary holds the control catalog
|
||||
type PaymentControlLibrary struct {
|
||||
Domains []PaymentDomain `json:"domains"`
|
||||
Controls []PaymentControl `json:"controls"`
|
||||
}
|
||||
|
||||
type PaymentDomain struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type PaymentControl struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Domain string `json:"domain"`
|
||||
Title string `json:"title"`
|
||||
Objective string `json:"objective"`
|
||||
CheckTarget string `json:"check_target"`
|
||||
Evidence []string `json:"evidence"`
|
||||
Automation string `json:"automation"`
|
||||
}
|
||||
|
||||
type PaymentAssessment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
TenderReference string `json:"tender_reference,omitempty"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SystemType string `json:"system_type,omitempty"`
|
||||
PaymentMethods json.RawMessage `json:"payment_methods,omitempty"`
|
||||
Protocols json.RawMessage `json:"protocols,omitempty"`
|
||||
TotalControls int `json:"total_controls"`
|
||||
ControlsPassed int `json:"controls_passed"`
|
||||
ControlsFailed int `json:"controls_failed"`
|
||||
ControlsPartial int `json:"controls_partial"`
|
||||
ControlsNA int `json:"controls_not_applicable"`
|
||||
ControlsUnchecked int `json:"controls_not_checked"`
|
||||
ComplianceScore float64 `json:"compliance_score"`
|
||||
Status string `json:"status"`
|
||||
ControlResults json.RawMessage `json:"control_results,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// NewPaymentHandlers creates payment handlers with loaded control library
|
||||
func NewPaymentHandlers(pool *pgxpool.Pool) *PaymentHandlers {
|
||||
lib := loadControlLibrary()
|
||||
return &PaymentHandlers{pool: pool, controls: lib}
|
||||
}
|
||||
|
||||
func loadControlLibrary() *PaymentControlLibrary {
|
||||
// Try to load from policies directory
|
||||
paths := []string{
|
||||
"policies/payment_controls_v1.json",
|
||||
"/app/policies/payment_controls_v1.json",
|
||||
}
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
// Try relative to executable
|
||||
execDir, _ := os.Executable()
|
||||
altPath := filepath.Join(filepath.Dir(execDir), p)
|
||||
data, err = os.ReadFile(altPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
var lib PaymentControlLibrary
|
||||
if err := json.Unmarshal(data, &lib); err == nil {
|
||||
return &lib
|
||||
}
|
||||
}
|
||||
return &PaymentControlLibrary{}
|
||||
}
|
||||
|
||||
// GetControlLibrary returns the loaded control library (for tender matching)
|
||||
func (h *PaymentHandlers) GetControlLibrary() *PaymentControlLibrary {
|
||||
return h.controls
|
||||
}
|
||||
|
||||
// ListControls returns the control library
|
||||
func (h *PaymentHandlers) ListControls(c *gin.Context) {
|
||||
domain := c.Query("domain")
|
||||
automation := c.Query("automation")
|
||||
|
||||
controls := h.controls.Controls
|
||||
if domain != "" {
|
||||
var filtered []PaymentControl
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Domain == domain {
|
||||
filtered = append(filtered, ctrl)
|
||||
}
|
||||
}
|
||||
controls = filtered
|
||||
}
|
||||
if automation != "" {
|
||||
var filtered []PaymentControl
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Automation == automation {
|
||||
filtered = append(filtered, ctrl)
|
||||
}
|
||||
}
|
||||
controls = filtered
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": controls,
|
||||
"domains": h.controls.Domains,
|
||||
"total": len(controls),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateAssessment creates a new payment compliance assessment
|
||||
func (h *PaymentHandlers) CreateAssessment(c *gin.Context) {
|
||||
var req PaymentAssessment
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": 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
|
||||
}
|
||||
|
||||
req.ID = uuid.New()
|
||||
req.TenantID = tenantID
|
||||
req.Status = "draft"
|
||||
req.TotalControls = len(h.controls.Controls)
|
||||
req.ControlsUnchecked = req.TotalControls
|
||||
req.CreatedAt = time.Now()
|
||||
req.UpdatedAt = time.Now()
|
||||
|
||||
_, err := h.pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO payment_compliance_assessments (
|
||||
id, tenant_id, project_name, tender_reference, customer_name, description,
|
||||
system_type, payment_methods, protocols,
|
||||
total_controls, controls_not_checked, status, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
req.ID, req.TenantID, req.ProjectName, req.TenderReference, req.CustomerName, req.Description,
|
||||
req.SystemType, req.PaymentMethods, req.Protocols,
|
||||
req.TotalControls, req.ControlsUnchecked, req.Status, req.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, req)
|
||||
}
|
||||
|
||||
// ListAssessments lists all payment assessments for a tenant
|
||||
func (h *PaymentHandlers) ListAssessments(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
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, tenant_id, project_name, tender_reference, customer_name,
|
||||
system_type, total_controls, controls_passed, controls_failed,
|
||||
controls_partial, controls_not_applicable, controls_not_checked,
|
||||
compliance_score, status, created_at, updated_at
|
||||
FROM payment_compliance_assessments
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assessments []PaymentAssessment
|
||||
for rows.Next() {
|
||||
var a PaymentAssessment
|
||||
rows.Scan(&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName,
|
||||
&a.SystemType, &a.TotalControls, &a.ControlsPassed, &a.ControlsFailed,
|
||||
&a.ControlsPartial, &a.ControlsNA, &a.ControlsUnchecked,
|
||||
&a.ComplianceScore, &a.Status, &a.CreatedAt, &a.UpdatedAt)
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
if assessments == nil {
|
||||
assessments = []PaymentAssessment{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assessments": assessments, "total": len(assessments)})
|
||||
}
|
||||
|
||||
// GetAssessment returns a single assessment with control results
|
||||
func (h *PaymentHandlers) GetAssessment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var a PaymentAssessment
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT id, tenant_id, project_name, tender_reference, customer_name, description,
|
||||
system_type, payment_methods, protocols,
|
||||
total_controls, controls_passed, controls_failed, controls_partial,
|
||||
controls_not_applicable, controls_not_checked, compliance_score,
|
||||
status, control_results, created_at, updated_at, created_by
|
||||
FROM payment_compliance_assessments WHERE id = $1`, id).Scan(
|
||||
&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName, &a.Description,
|
||||
&a.SystemType, &a.PaymentMethods, &a.Protocols,
|
||||
&a.TotalControls, &a.ControlsPassed, &a.ControlsFailed, &a.ControlsPartial,
|
||||
&a.ControlsNA, &a.ControlsUnchecked, &a.ComplianceScore,
|
||||
&a.Status, &a.ControlResults, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// UpdateControlVerdict updates the verdict for a single control
|
||||
func (h *PaymentHandlers) UpdateControlVerdict(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 {
|
||||
ControlID string `json:"control_id"`
|
||||
Verdict string `json:"verdict"` // passed, failed, partial, na, unchecked
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the control_results JSONB and recalculate scores
|
||||
_, err = h.pool.Exec(c.Request.Context(), `
|
||||
WITH updated AS (
|
||||
SELECT id,
|
||||
COALESCE(control_results, '[]'::jsonb) AS existing_results
|
||||
FROM payment_compliance_assessments WHERE id = $1
|
||||
)
|
||||
UPDATE payment_compliance_assessments SET
|
||||
control_results = (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'control_id' = $2 THEN
|
||||
jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5)
|
||||
ELSE elem END
|
||||
) FROM updated, jsonb_array_elements(
|
||||
CASE WHEN existing_results @> jsonb_build_array(jsonb_build_object('control_id', $2))
|
||||
THEN existing_results
|
||||
ELSE existing_results || jsonb_build_array(jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5))
|
||||
END
|
||||
) AS elem
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
id, body.ControlID, body.Verdict, body.Evidence, body.Notes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated", "control_id": body.ControlID, "verdict": body.Verdict})
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
|
||||
// Pre-fill registration from assessment intake
|
||||
intake := assessment.Intake
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegulatoryNewsHandlers serves regulatory news from obligation v2 data.
|
||||
type RegulatoryNewsHandlers struct {
|
||||
regulations map[string]*ucca.V2RegulationFile
|
||||
}
|
||||
|
||||
// NewRegulatoryNewsHandlers creates a handler backed by pre-loaded regulation data.
|
||||
func NewRegulatoryNewsHandlers(regs map[string]*ucca.V2RegulationFile) *RegulatoryNewsHandlers {
|
||||
return &RegulatoryNewsHandlers{regulations: regs}
|
||||
}
|
||||
|
||||
// GetNews returns upcoming regulatory deadlines sorted by urgency.
|
||||
func (h *RegulatoryNewsHandlers) GetNews(c *gin.Context) {
|
||||
filter := ucca.RegulatoryNewsFilter{
|
||||
BusinessModel: c.Query("business_model"),
|
||||
HorizonDays: parseIntOrDefault(c.Query("horizon_days"), 365),
|
||||
Limit: parseIntOrDefault(c.Query("limit"), 5),
|
||||
}
|
||||
|
||||
items := ucca.GetRegulatoryNews(h.regulations, filter)
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func parseIntOrDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TenderHandlers handles tender upload and requirement extraction
|
||||
type TenderHandlers struct {
|
||||
pool *pgxpool.Pool
|
||||
controls *PaymentControlLibrary
|
||||
}
|
||||
|
||||
// TenderAnalysis represents a tender document analysis
|
||||
type TenderAnalysis struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
Status string `json:"status"` // uploaded, extracting, extracted, matched, completed
|
||||
Requirements []ExtractedReq `json:"requirements,omitempty"`
|
||||
MatchResults []MatchResult `json:"match_results,omitempty"`
|
||||
TotalRequirements int `json:"total_requirements"`
|
||||
MatchedCount int `json:"matched_count"`
|
||||
UnmatchedCount int `json:"unmatched_count"`
|
||||
PartialCount int `json:"partial_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ExtractedReq represents a single requirement extracted from a tender document
|
||||
type ExtractedReq struct {
|
||||
ReqID string `json:"req_id"`
|
||||
Text string `json:"text"`
|
||||
SourcePage int `json:"source_page,omitempty"`
|
||||
SourceSection string `json:"source_section,omitempty"`
|
||||
ObligationLevel string `json:"obligation_level"` // MUST, SHALL, SHOULD, MAY
|
||||
TechnicalDomain string `json:"technical_domain"` // crypto, logging, payment_flow, etc.
|
||||
CheckTarget string `json:"check_target"` // code, system, config, process, certificate
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// MatchResult represents the matching of a requirement to controls
|
||||
type MatchResult struct {
|
||||
ReqID string `json:"req_id"`
|
||||
ReqText string `json:"req_text"`
|
||||
ObligationLevel string `json:"obligation_level"`
|
||||
MatchedControls []ControlMatch `json:"matched_controls"`
|
||||
Verdict string `json:"verdict"` // matched, partial, unmatched
|
||||
GapDescription string `json:"gap_description,omitempty"`
|
||||
}
|
||||
|
||||
// ControlMatch represents a single control match for a requirement
|
||||
type ControlMatch struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Title string `json:"title"`
|
||||
Relevance float64 `json:"relevance"` // 0-1
|
||||
CheckTarget string `json:"check_target"`
|
||||
}
|
||||
|
||||
// NewTenderHandlers creates tender handlers
|
||||
func NewTenderHandlers(pool *pgxpool.Pool, controls *PaymentControlLibrary) *TenderHandlers {
|
||||
return &TenderHandlers{pool: pool, controls: controls}
|
||||
}
|
||||
|
||||
// Upload handles tender document upload
|
||||
func (h *TenderHandlers) Upload(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
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
projectName := c.PostForm("project_name")
|
||||
if projectName == "" {
|
||||
projectName = header.Filename
|
||||
}
|
||||
customerName := c.PostForm("customer_name")
|
||||
|
||||
// Read file content
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store analysis record
|
||||
analysisID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
_, err = h.pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO tender_analyses (
|
||||
id, tenant_id, file_name, file_size, file_content,
|
||||
project_name, customer_name, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8, $9)`,
|
||||
analysisID, tenantID, header.Filename, header.Size, content,
|
||||
projectName, customerName, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": analysisID,
|
||||
"file_name": header.Filename,
|
||||
"file_size": header.Size,
|
||||
"project_name": projectName,
|
||||
"status": "uploaded",
|
||||
"message": "Dokument hochgeladen. Starte Analyse mit POST /extract.",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract extracts requirements from an uploaded tender document using LLM
|
||||
func (h *TenderHandlers) Extract(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get file content
|
||||
var fileContent []byte
|
||||
var fileName string
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT file_content, file_name FROM tender_analyses WHERE id = $1`, id,
|
||||
).Scan(&fileContent, &fileName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET status = 'extracting', updated_at = NOW() WHERE id = $1`, id)
|
||||
|
||||
// Extract text (simple: treat as text for now, PDF extraction would use embedding-service)
|
||||
text := string(fileContent)
|
||||
|
||||
// Use LLM to extract requirements
|
||||
requirements := h.extractRequirementsWithLLM(c.Request.Context(), text)
|
||||
|
||||
// Store results
|
||||
reqJSON, _ := json.Marshal(requirements)
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET
|
||||
status = 'extracted',
|
||||
requirements = $2,
|
||||
total_requirements = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`, id, reqJSON, len(requirements))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"status": "extracted",
|
||||
"requirements": requirements,
|
||||
"total": len(requirements),
|
||||
})
|
||||
}
|
||||
|
||||
// Match matches extracted requirements against the control library
|
||||
func (h *TenderHandlers) Match(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get requirements
|
||||
var reqJSON json.RawMessage
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT requirements FROM tender_analyses WHERE id = $1`, id,
|
||||
).Scan(&reqJSON)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var requirements []ExtractedReq
|
||||
json.Unmarshal(reqJSON, &requirements)
|
||||
|
||||
// Match each requirement against controls
|
||||
var results []MatchResult
|
||||
matched, unmatched, partial := 0, 0, 0
|
||||
|
||||
for _, req := range requirements {
|
||||
matches := h.findMatchingControls(req)
|
||||
result := MatchResult{
|
||||
ReqID: req.ReqID,
|
||||
ReqText: req.Text,
|
||||
ObligationLevel: req.ObligationLevel,
|
||||
MatchedControls: matches,
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
result.Verdict = "unmatched"
|
||||
result.GapDescription = "Kein passender Control gefunden — manueller Review erforderlich"
|
||||
unmatched++
|
||||
} else if matches[0].Relevance >= 0.7 {
|
||||
result.Verdict = "matched"
|
||||
matched++
|
||||
} else {
|
||||
result.Verdict = "partial"
|
||||
result.GapDescription = "Teilweise Abdeckung — Control deckt Anforderung nicht vollstaendig ab"
|
||||
partial++
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Store results
|
||||
resultsJSON, _ := json.Marshal(results)
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET
|
||||
status = 'matched',
|
||||
match_results = $2,
|
||||
matched_count = $3,
|
||||
unmatched_count = $4,
|
||||
partial_count = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`, id, resultsJSON, matched, unmatched, partial)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"status": "matched",
|
||||
"results": results,
|
||||
"matched": matched,
|
||||
"unmatched": unmatched,
|
||||
"partial": partial,
|
||||
"total": len(requirements),
|
||||
})
|
||||
}
|
||||
|
||||
// ListAnalyses lists all tender analyses for a tenant
|
||||
func (h *TenderHandlers) ListAnalyses(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
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, tenant_id, file_name, file_size, project_name, customer_name,
|
||||
status, total_requirements, matched_count, unmatched_count, partial_count,
|
||||
created_at, updated_at
|
||||
FROM tender_analyses
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var analyses []TenderAnalysis
|
||||
for rows.Next() {
|
||||
var a TenderAnalysis
|
||||
rows.Scan(&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName,
|
||||
&a.Status, &a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount,
|
||||
&a.CreatedAt, &a.UpdatedAt)
|
||||
analyses = append(analyses, a)
|
||||
}
|
||||
if analyses == nil {
|
||||
analyses = []TenderAnalysis{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"analyses": analyses, "total": len(analyses)})
|
||||
}
|
||||
|
||||
// GetAnalysis returns a single analysis with all details
|
||||
func (h *TenderHandlers) GetAnalysis(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var a TenderAnalysis
|
||||
var reqJSON, matchJSON json.RawMessage
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT id, tenant_id, file_name, file_size, project_name, customer_name,
|
||||
status, requirements, match_results,
|
||||
total_requirements, matched_count, unmatched_count, partial_count,
|
||||
created_at, updated_at
|
||||
FROM tender_analyses WHERE id = $1`, id).Scan(
|
||||
&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName,
|
||||
&a.Status, &reqJSON, &matchJSON,
|
||||
&a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount,
|
||||
&a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if reqJSON != nil {
|
||||
json.Unmarshal(reqJSON, &a.Requirements)
|
||||
}
|
||||
if matchJSON != nil {
|
||||
json.Unmarshal(matchJSON, &a.MatchResults)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (h *TenderHandlers) extractRequirementsWithLLM(ctx context.Context, text string) []ExtractedReq {
|
||||
// Try Anthropic API for requirement extraction
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Fallback: simple keyword-based extraction
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Analysiere das folgende Ausschreibungsdokument und extrahiere alle technischen Anforderungen.
|
||||
|
||||
Fuer jede Anforderung gib zurueck:
|
||||
- req_id: fortlaufende ID (REQ-001, REQ-002, ...)
|
||||
- text: die Anforderung als kurzer Satz
|
||||
- obligation_level: MUST, SHALL, SHOULD oder MAY
|
||||
- technical_domain: eines von: payment_flow, logging, crypto, api_security, terminal_comm, firmware, reporting, access_control, error_handling, build_deploy
|
||||
- check_target: eines von: code, system, config, process, certificate
|
||||
|
||||
Antworte NUR mit JSON Array. Keine Erklaerung.
|
||||
|
||||
Dokument:
|
||||
%s`, text[:min(len(text), 15000)])
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"max_tokens": 4096,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(bodyJSON)))
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if len(result.Content) == 0 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
// Parse LLM response
|
||||
responseText := result.Content[0].Text
|
||||
// Find JSON array in response
|
||||
start := strings.Index(responseText, "[")
|
||||
end := strings.LastIndex(responseText, "]")
|
||||
if start < 0 || end < 0 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
var reqs []ExtractedReq
|
||||
if err := json.Unmarshal([]byte(responseText[start:end+1]), &reqs); err != nil {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
// Set confidence for LLM-extracted requirements
|
||||
for i := range reqs {
|
||||
reqs[i].Confidence = 0.8
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (h *TenderHandlers) extractRequirementsKeyword(text string) []ExtractedReq {
|
||||
// Simple keyword-based extraction as fallback
|
||||
keywords := map[string]string{
|
||||
"muss": "MUST",
|
||||
"muessen": "MUST",
|
||||
"ist sicherzustellen": "MUST",
|
||||
"soll": "SHOULD",
|
||||
"sollte": "SHOULD",
|
||||
"kann": "MAY",
|
||||
"wird gefordert": "MUST",
|
||||
"nachzuweisen": "MUST",
|
||||
"zertifiziert": "MUST",
|
||||
}
|
||||
|
||||
var reqs []ExtractedReq
|
||||
lines := strings.Split(text, "\n")
|
||||
reqNum := 1
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 20 || len(line) > 500 {
|
||||
continue
|
||||
}
|
||||
|
||||
for keyword, level := range keywords {
|
||||
if strings.Contains(strings.ToLower(line), keyword) {
|
||||
reqs = append(reqs, ExtractedReq{
|
||||
ReqID: fmt.Sprintf("REQ-%03d", reqNum),
|
||||
Text: line,
|
||||
ObligationLevel: level,
|
||||
TechnicalDomain: inferDomain(line),
|
||||
CheckTarget: inferCheckTarget(line),
|
||||
Confidence: 0.5,
|
||||
})
|
||||
reqNum++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (h *TenderHandlers) findMatchingControls(req ExtractedReq) []ControlMatch {
|
||||
var matches []ControlMatch
|
||||
|
||||
reqLower := strings.ToLower(req.Text + " " + req.TechnicalDomain)
|
||||
|
||||
for _, ctrl := range h.controls.Controls {
|
||||
titleLower := strings.ToLower(ctrl.Title + " " + ctrl.Objective)
|
||||
relevance := calculateRelevance(reqLower, titleLower, req.TechnicalDomain, ctrl.Domain)
|
||||
|
||||
if relevance > 0.3 {
|
||||
matches = append(matches, ControlMatch{
|
||||
ControlID: ctrl.ControlID,
|
||||
Title: ctrl.Title,
|
||||
Relevance: relevance,
|
||||
CheckTarget: ctrl.CheckTarget,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance (simple bubble sort for small lists)
|
||||
for i := 0; i < len(matches); i++ {
|
||||
for j := i + 1; j < len(matches); j++ {
|
||||
if matches[j].Relevance > matches[i].Relevance {
|
||||
matches[i], matches[j] = matches[j], matches[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return top 5
|
||||
if len(matches) > 5 {
|
||||
matches = matches[:5]
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func calculateRelevance(reqText, ctrlText, reqDomain, ctrlDomain string) float64 {
|
||||
score := 0.0
|
||||
|
||||
// Domain match bonus
|
||||
domainMap := map[string]string{
|
||||
"payment_flow": "PAY",
|
||||
"logging": "LOG",
|
||||
"crypto": "CRYPTO",
|
||||
"api_security": "API",
|
||||
"terminal_comm": "TERM",
|
||||
"firmware": "FW",
|
||||
"reporting": "REP",
|
||||
"access_control": "ACC",
|
||||
"error_handling": "ERR",
|
||||
"build_deploy": "BLD",
|
||||
}
|
||||
|
||||
if mapped, ok := domainMap[reqDomain]; ok && mapped == ctrlDomain {
|
||||
score += 0.4
|
||||
}
|
||||
|
||||
// Keyword overlap
|
||||
reqWords := strings.Fields(reqText)
|
||||
for _, word := range reqWords {
|
||||
if len(word) > 3 && strings.Contains(ctrlText, word) {
|
||||
score += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func inferDomain(text string) string {
|
||||
textLower := strings.ToLower(text)
|
||||
domainKeywords := map[string][]string{
|
||||
"payment_flow": {"zahlung", "transaktion", "buchung", "payment", "betrag"},
|
||||
"logging": {"log", "protokoll", "audit", "nachvollzieh"},
|
||||
"crypto": {"verschlüssel", "schlüssel", "krypto", "tls", "ssl", "hsm", "pin"},
|
||||
"api_security": {"api", "schnittstelle", "authentifiz", "autorisier"},
|
||||
"terminal_comm": {"terminal", "zvt", "opi", "gerät", "kontaktlos", "nfc"},
|
||||
"firmware": {"firmware", "update", "signatur", "boot"},
|
||||
"reporting": {"bericht", "report", "abrechnung", "export", "abgleich"},
|
||||
"access_control": {"zugang", "benutzer", "passwort", "rolle", "berechtigung"},
|
||||
"error_handling": {"fehler", "ausfall", "recovery", "offline", "störung"},
|
||||
"build_deploy": {"build", "deploy", "release", "ci", "pipeline"},
|
||||
}
|
||||
|
||||
for domain, keywords := range domainKeywords {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(textLower, kw) {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
}
|
||||
return "general"
|
||||
}
|
||||
|
||||
func inferCheckTarget(text string) string {
|
||||
textLower := strings.ToLower(text)
|
||||
if strings.Contains(textLower, "zertifik") || strings.Contains(textLower, "zulassung") {
|
||||
return "certificate"
|
||||
}
|
||||
if strings.Contains(textLower, "prozess") || strings.Contains(textLower, "verfahren") {
|
||||
return "process"
|
||||
}
|
||||
if strings.Contains(textLower, "konfigur") {
|
||||
return "config"
|
||||
}
|
||||
return "code"
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -330,3 +330,65 @@ func (h *UCCAHandlers) createEscalationForAssessment(c *gin.Context, assessment
|
||||
|
||||
return escalation
|
||||
}
|
||||
|
||||
// AssessEnriched evaluates a use case with optional company profile context.
|
||||
func (h *UCCAHandlers) AssessEnriched(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
userID := rbac.GetUserID(c)
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Intake ucca.UseCaseIntake `json:"intake"`
|
||||
CompanyProfile *ucca.CompanyProfileInput `json:"company_profile,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Standard UCCA evaluation
|
||||
result, policyVersion := h.evaluateIntake(&req.Intake)
|
||||
hash := sha256.Sum256([]byte(req.Intake.UseCaseText))
|
||||
|
||||
assessment := &ucca.Assessment{
|
||||
TenantID: tenantID, Title: req.Intake.Title, PolicyVersion: policyVersion,
|
||||
Status: "completed", Intake: req.Intake,
|
||||
UseCaseTextStored: req.Intake.StoreRawText, UseCaseTextHash: hex.EncodeToString(hash[:]),
|
||||
Feasibility: result.Feasibility, RiskLevel: result.RiskLevel,
|
||||
Complexity: result.Complexity, RiskScore: result.RiskScore,
|
||||
TriggeredRules: result.TriggeredRules, RequiredControls: result.RequiredControls,
|
||||
RecommendedArchitecture: result.RecommendedArchitecture,
|
||||
ForbiddenPatterns: result.ForbiddenPatterns, ExampleMatches: result.ExampleMatches,
|
||||
DSFARecommended: result.DSFARecommended, Art22Risk: result.Art22Risk,
|
||||
TrainingAllowed: result.TrainingAllowed, Domain: req.Intake.Domain, CreatedBy: userID,
|
||||
}
|
||||
if !req.Intake.StoreRawText {
|
||||
assessment.Intake.UseCaseText = ""
|
||||
}
|
||||
if assessment.Title == "" {
|
||||
assessment.Title = fmt.Sprintf("Assessment vom %s", time.Now().Format("02.01.2006 15:04"))
|
||||
}
|
||||
if err := h.store.CreateAssessment(c.Request.Context(), assessment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build enriched response
|
||||
resp := gin.H{
|
||||
"assessment": assessment,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
// Company profile enrichment
|
||||
if req.CompanyProfile != nil {
|
||||
resp["enrichment_hints"] = ucca.ComputeEnrichmentHints(req.CompanyProfile)
|
||||
resp["company_context"] = ucca.BuildCompanyContext(req.CompanyProfile)
|
||||
} else {
|
||||
resp["enrichment_hints"] = ucca.ComputeEnrichmentHints(nil)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/config"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/maximizer"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/roadmap"
|
||||
@@ -132,6 +133,25 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator, blockGenerator, ttsClient)
|
||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
||||
|
||||
// Regulatory News
|
||||
allV2Regs, err := ucca.LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
log.Printf("WARNING: V2 regulations not loaded: %v", err)
|
||||
allV2Regs = make(map[string]*ucca.V2RegulationFile)
|
||||
}
|
||||
regulatoryNewsHandlers := handlers.NewRegulatoryNewsHandlers(allV2Regs)
|
||||
|
||||
// Maximizer
|
||||
maximizerStore := maximizer.NewStore(pool)
|
||||
maximizerRules, err := maximizer.LoadConstraintRulesFromDefault()
|
||||
if err != nil {
|
||||
log.Printf("WARNING: Maximizer constraints not loaded: %v", err)
|
||||
maximizerRules = &maximizer.ConstraintRuleSet{Version: "0.0.0"}
|
||||
}
|
||||
maximizerSvc := maximizer.NewService(maximizerStore, uccaStore, maximizerRules)
|
||||
maximizerHandlers := handlers.NewMaximizerHandlers(maximizerSvc)
|
||||
|
||||
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
|
||||
|
||||
// Router
|
||||
@@ -155,7 +175,8 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
rbacHandlers, llmHandlers, auditHandlers,
|
||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler)
|
||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||
maximizerHandlers, regulatoryNewsHandlers)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ func registerRoutes(
|
||||
trainingHandlers *handlers.TrainingHandlers,
|
||||
whistleblowerHandlers *handlers.WhistleblowerHandlers,
|
||||
iaceHandler *handlers.IACEHandler,
|
||||
maximizerHandlers *handlers.MaximizerHandlers,
|
||||
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||
) {
|
||||
v1 := router.Group("/sdk/v1")
|
||||
{
|
||||
@@ -46,6 +48,8 @@ func registerRoutes(
|
||||
registerTrainingRoutes(v1, trainingHandlers)
|
||||
registerWhistleblowerRoutes(v1, whistleblowerHandlers)
|
||||
registerIACERoutes(v1, iaceHandler)
|
||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +126,7 @@ func registerUCCARoutes(v1 *gin.RouterGroup, h *handlers.UCCAHandlers, eh *handl
|
||||
uccaRoutes := v1.Group("/ucca")
|
||||
{
|
||||
uccaRoutes.POST("/assess", h.Assess)
|
||||
uccaRoutes.POST("/assess-enriched", h.AssessEnriched)
|
||||
uccaRoutes.GET("/assessments", h.ListAssessments)
|
||||
uccaRoutes.GET("/assessments/:id", h.GetAssessment)
|
||||
uccaRoutes.PUT("/assessments/:id", h.UpdateAssessment)
|
||||
@@ -407,3 +412,18 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", h.EnrichTechFileSection)
|
||||
}
|
||||
}
|
||||
|
||||
func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers) {
|
||||
m := v1.Group("/maximizer")
|
||||
{
|
||||
m.POST("/optimize", h.Optimize)
|
||||
m.POST("/optimize-from-intake", h.OptimizeFromIntake)
|
||||
m.POST("/optimize-from-intake-enriched", h.OptimizeFromIntakeWithProfile)
|
||||
m.POST("/optimize-from-assessment/:id", h.OptimizeFromAssessment)
|
||||
m.POST("/evaluate", h.Evaluate)
|
||||
m.GET("/optimizations", h.ListOptimizations)
|
||||
m.GET("/optimizations/:id", h.GetOptimization)
|
||||
m.DELETE("/optimizations/:id", h.DeleteOptimization)
|
||||
m.GET("/dimensions", h.GetDimensionSchema)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package maximizer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const defaultConstraintFile = "policies/maximizer_constraints_v1.json"
|
||||
|
||||
// LoadConstraintRules reads a constraint ruleset from a JSON file.
|
||||
func LoadConstraintRules(path string) (*ConstraintRuleSet, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read constraint file %s: %w", path, err)
|
||||
}
|
||||
var rs ConstraintRuleSet
|
||||
if err := json.Unmarshal(data, &rs); err != nil {
|
||||
return nil, fmt.Errorf("parse constraint file %s: %w", path, err)
|
||||
}
|
||||
if rs.Version == "" {
|
||||
return nil, fmt.Errorf("constraint file %s: missing version", path)
|
||||
}
|
||||
return &rs, nil
|
||||
}
|
||||
|
||||
// LoadConstraintRulesFromDefault loads from the default policy file
|
||||
// relative to the project root.
|
||||
func LoadConstraintRulesFromDefault() (*ConstraintRuleSet, error) {
|
||||
root := findProjectRoot()
|
||||
path := filepath.Join(root, defaultConstraintFile)
|
||||
return LoadConstraintRules(path)
|
||||
}
|
||||
|
||||
// findProjectRoot walks up from the current source file to find the
|
||||
// ai-compliance-sdk root (contains go.mod or policies/).
|
||||
func findProjectRoot() string {
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return "."
|
||||
}
|
||||
dir := filepath.Dir(filename)
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := os.Stat(filepath.Join(dir, "policies")); err == nil {
|
||||
return dir
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
return "."
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package maximizer
|
||||
|
||||
// ConstraintRuleSet is the top-level container loaded from maximizer_constraints_v1.json.
|
||||
type ConstraintRuleSet struct {
|
||||
Version string `json:"version"`
|
||||
Regulations []string `json:"regulations"`
|
||||
Rules []ConstraintRule `json:"rules"`
|
||||
}
|
||||
|
||||
// ConstraintRule maps a regulatory obligation to dimension restrictions.
|
||||
type ConstraintRule struct {
|
||||
ID string `json:"id"`
|
||||
ObligationID string `json:"obligation_id"`
|
||||
Regulation string `json:"regulation"`
|
||||
ArticleRef string `json:"article_ref"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
RuleType string `json:"rule_type"` // hard_prohibition, requirement, classification_rule, optimizer_rule, escalation_gate
|
||||
Constraints []Constraint `json:"constraints"`
|
||||
}
|
||||
|
||||
// Constraint is a single if-then rule on the dimension space.
|
||||
type Constraint struct {
|
||||
If ConditionSet `json:"if"`
|
||||
Then EffectSet `json:"then"`
|
||||
}
|
||||
|
||||
// ConditionSet maps dimension names to their required values.
|
||||
// Values can be a string (exact match) or []string (any of).
|
||||
type ConditionSet map[string]interface{}
|
||||
|
||||
// EffectSet defines what must be true when the condition matches.
|
||||
type EffectSet struct {
|
||||
// Allowed=false means hard block — no optimization possible for this rule
|
||||
Allowed *bool `json:"allowed,omitempty"`
|
||||
|
||||
// RequiredValues: dimension must have exactly this value
|
||||
RequiredValues map[string]string `json:"required_values,omitempty"`
|
||||
|
||||
// RequiredControls: organizational/technical controls needed
|
||||
RequiredControls []string `json:"required_controls,omitempty"`
|
||||
|
||||
// RequiredPatterns: architectural patterns needed
|
||||
RequiredPatterns []string `json:"required_patterns,omitempty"`
|
||||
|
||||
// Classification overrides
|
||||
SetRiskClassification string `json:"set_risk_classification,omitempty"`
|
||||
}
|
||||
|
||||
// Matches checks if a DimensionConfig satisfies all conditions in this set.
|
||||
func (cs ConditionSet) Matches(config *DimensionConfig) bool {
|
||||
for dim, expected := range cs {
|
||||
actual := config.GetValue(dim)
|
||||
if actual == "" {
|
||||
return false
|
||||
}
|
||||
switch v := expected.(type) {
|
||||
case string:
|
||||
if actual != v {
|
||||
return false
|
||||
}
|
||||
case []interface{}:
|
||||
found := false
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && actual == s {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package maximizer
|
||||
|
||||
// DimensionConfig is the normalized representation of an AI use case
|
||||
// as a point in a 13-dimensional regulatory constraint space.
|
||||
// Each dimension maps to regulatory obligations from DSGVO, AI Act, etc.
|
||||
type DimensionConfig struct {
|
||||
AutomationLevel AutomationLevel `json:"automation_level"`
|
||||
DecisionBinding DecisionBinding `json:"decision_binding"`
|
||||
DecisionImpact DecisionImpact `json:"decision_impact"`
|
||||
Domain DomainCategory `json:"domain"`
|
||||
DataType DataTypeSensitivity `json:"data_type"`
|
||||
HumanInLoop HumanInLoopLevel `json:"human_in_loop"`
|
||||
Explainability ExplainabilityLevel `json:"explainability"`
|
||||
RiskClassification RiskClass `json:"risk_classification"`
|
||||
LegalBasis LegalBasisType `json:"legal_basis"`
|
||||
TransparencyRequired bool `json:"transparency_required"`
|
||||
LoggingRequired bool `json:"logging_required"`
|
||||
ModelType ModelType `json:"model_type"`
|
||||
DeploymentScope DeploymentScope `json:"deployment_scope"`
|
||||
}
|
||||
|
||||
// --- Dimension Enums ---
|
||||
|
||||
type AutomationLevel string
|
||||
|
||||
const (
|
||||
AutoNone AutomationLevel = "none"
|
||||
AutoAssistive AutomationLevel = "assistive"
|
||||
AutoPartial AutomationLevel = "partial"
|
||||
AutoFull AutomationLevel = "full"
|
||||
)
|
||||
|
||||
type DecisionBinding string
|
||||
|
||||
const (
|
||||
BindingNonBinding DecisionBinding = "non_binding"
|
||||
BindingHumanReview DecisionBinding = "human_review_required"
|
||||
BindingFullyBinding DecisionBinding = "fully_binding"
|
||||
)
|
||||
|
||||
type DecisionImpact string
|
||||
|
||||
const (
|
||||
ImpactLow DecisionImpact = "low"
|
||||
ImpactMedium DecisionImpact = "medium"
|
||||
ImpactHigh DecisionImpact = "high"
|
||||
)
|
||||
|
||||
type DomainCategory string
|
||||
|
||||
const (
|
||||
DomainHR DomainCategory = "hr"
|
||||
DomainFinance DomainCategory = "finance"
|
||||
DomainEducation DomainCategory = "education"
|
||||
DomainHealth DomainCategory = "health"
|
||||
DomainMarketing DomainCategory = "marketing"
|
||||
DomainGeneral DomainCategory = "general"
|
||||
)
|
||||
|
||||
type DataTypeSensitivity string
|
||||
|
||||
const (
|
||||
DataNonPersonal DataTypeSensitivity = "non_personal"
|
||||
DataPersonal DataTypeSensitivity = "personal"
|
||||
DataSensitive DataTypeSensitivity = "sensitive"
|
||||
DataBiometric DataTypeSensitivity = "biometric"
|
||||
)
|
||||
|
||||
type HumanInLoopLevel string
|
||||
|
||||
const (
|
||||
HILNone HumanInLoopLevel = "none"
|
||||
HILOptional HumanInLoopLevel = "optional"
|
||||
HILRequired HumanInLoopLevel = "required"
|
||||
)
|
||||
|
||||
type ExplainabilityLevel string
|
||||
|
||||
const (
|
||||
ExplainNone ExplainabilityLevel = "none"
|
||||
ExplainBasic ExplainabilityLevel = "basic"
|
||||
ExplainHigh ExplainabilityLevel = "high"
|
||||
)
|
||||
|
||||
type RiskClass string
|
||||
|
||||
const (
|
||||
RiskMinimal RiskClass = "minimal"
|
||||
RiskLimited RiskClass = "limited"
|
||||
RiskHigh RiskClass = "high"
|
||||
RiskProhibited RiskClass = "prohibited"
|
||||
)
|
||||
|
||||
type LegalBasisType string
|
||||
|
||||
const (
|
||||
LegalConsent LegalBasisType = "consent"
|
||||
LegalContract LegalBasisType = "contract"
|
||||
LegalLegalObligation LegalBasisType = "legal_obligation"
|
||||
LegalLegitimateInterest LegalBasisType = "legitimate_interest"
|
||||
LegalPublicInterest LegalBasisType = "public_interest"
|
||||
)
|
||||
|
||||
type ModelType string
|
||||
|
||||
const (
|
||||
ModelRuleBased ModelType = "rule_based"
|
||||
ModelStatistical ModelType = "statistical"
|
||||
ModelBlackboxLLM ModelType = "blackbox_llm"
|
||||
)
|
||||
|
||||
type DeploymentScope string
|
||||
|
||||
const (
|
||||
ScopeInternal DeploymentScope = "internal"
|
||||
ScopeExternal DeploymentScope = "external"
|
||||
ScopePublic DeploymentScope = "public"
|
||||
)
|
||||
|
||||
// --- Ordinal Orderings (higher = more regulatory risk) ---
|
||||
|
||||
var automationOrder = map[AutomationLevel]int{
|
||||
AutoNone: 0, AutoAssistive: 1, AutoPartial: 2, AutoFull: 3,
|
||||
}
|
||||
|
||||
var bindingOrder = map[DecisionBinding]int{
|
||||
BindingNonBinding: 0, BindingHumanReview: 1, BindingFullyBinding: 2,
|
||||
}
|
||||
|
||||
var impactOrder = map[DecisionImpact]int{
|
||||
ImpactLow: 0, ImpactMedium: 1, ImpactHigh: 2,
|
||||
}
|
||||
|
||||
var dataTypeOrder = map[DataTypeSensitivity]int{
|
||||
DataNonPersonal: 0, DataPersonal: 1, DataSensitive: 2, DataBiometric: 3,
|
||||
}
|
||||
|
||||
var hilOrder = map[HumanInLoopLevel]int{
|
||||
HILRequired: 0, HILOptional: 1, HILNone: 2,
|
||||
}
|
||||
|
||||
var explainOrder = map[ExplainabilityLevel]int{
|
||||
ExplainHigh: 0, ExplainBasic: 1, ExplainNone: 2,
|
||||
}
|
||||
|
||||
var riskOrder = map[RiskClass]int{
|
||||
RiskMinimal: 0, RiskLimited: 1, RiskHigh: 2, RiskProhibited: 3,
|
||||
}
|
||||
|
||||
var modelTypeOrder = map[ModelType]int{
|
||||
ModelRuleBased: 0, ModelStatistical: 1, ModelBlackboxLLM: 2,
|
||||
}
|
||||
|
||||
var scopeOrder = map[DeploymentScope]int{
|
||||
ScopeInternal: 0, ScopeExternal: 1, ScopePublic: 2,
|
||||
}
|
||||
|
||||
// AllValues returns the ordered list of allowed values for each dimension.
|
||||
var AllValues = map[string][]string{
|
||||
"automation_level": {"none", "assistive", "partial", "full"},
|
||||
"decision_binding": {"non_binding", "human_review_required", "fully_binding"},
|
||||
"decision_impact": {"low", "medium", "high"},
|
||||
"domain": {"hr", "finance", "education", "health", "marketing", "general"},
|
||||
"data_type": {"non_personal", "personal", "sensitive", "biometric"},
|
||||
"human_in_loop": {"required", "optional", "none"},
|
||||
"explainability": {"high", "basic", "none"},
|
||||
"risk_classification": {"minimal", "limited", "high", "prohibited"},
|
||||
"legal_basis": {"consent", "contract", "legal_obligation", "legitimate_interest", "public_interest"},
|
||||
"transparency_required": {"true", "false"},
|
||||
"logging_required": {"true", "false"},
|
||||
"model_type": {"rule_based", "statistical", "blackbox_llm"},
|
||||
"deployment_scope": {"internal", "external", "public"},
|
||||
}
|
||||
|
||||
// DimensionDelta represents a single change between two configs.
|
||||
type DimensionDelta struct {
|
||||
Dimension string `json:"dimension"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Impact string `json:"impact"` // human-readable impact description
|
||||
}
|
||||
|
||||
// GetValue returns the string value of a dimension by name.
|
||||
func (d *DimensionConfig) GetValue(dimension string) string {
|
||||
switch dimension {
|
||||
case "automation_level":
|
||||
return string(d.AutomationLevel)
|
||||
case "decision_binding":
|
||||
return string(d.DecisionBinding)
|
||||
case "decision_impact":
|
||||
return string(d.DecisionImpact)
|
||||
case "domain":
|
||||
return string(d.Domain)
|
||||
case "data_type":
|
||||
return string(d.DataType)
|
||||
case "human_in_loop":
|
||||
return string(d.HumanInLoop)
|
||||
case "explainability":
|
||||
return string(d.Explainability)
|
||||
case "risk_classification":
|
||||
return string(d.RiskClassification)
|
||||
case "legal_basis":
|
||||
return string(d.LegalBasis)
|
||||
case "transparency_required":
|
||||
if d.TransparencyRequired {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case "logging_required":
|
||||
if d.LoggingRequired {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case "model_type":
|
||||
return string(d.ModelType)
|
||||
case "deployment_scope":
|
||||
return string(d.DeploymentScope)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// SetValue sets a dimension value by name. Returns false if the dimension is unknown.
|
||||
func (d *DimensionConfig) SetValue(dimension, value string) bool {
|
||||
switch dimension {
|
||||
case "automation_level":
|
||||
d.AutomationLevel = AutomationLevel(value)
|
||||
case "decision_binding":
|
||||
d.DecisionBinding = DecisionBinding(value)
|
||||
case "decision_impact":
|
||||
d.DecisionImpact = DecisionImpact(value)
|
||||
case "domain":
|
||||
d.Domain = DomainCategory(value)
|
||||
case "data_type":
|
||||
d.DataType = DataTypeSensitivity(value)
|
||||
case "human_in_loop":
|
||||
d.HumanInLoop = HumanInLoopLevel(value)
|
||||
case "explainability":
|
||||
d.Explainability = ExplainabilityLevel(value)
|
||||
case "risk_classification":
|
||||
d.RiskClassification = RiskClass(value)
|
||||
case "legal_basis":
|
||||
d.LegalBasis = LegalBasisType(value)
|
||||
case "transparency_required":
|
||||
d.TransparencyRequired = value == "true"
|
||||
case "logging_required":
|
||||
d.LoggingRequired = value == "true"
|
||||
case "model_type":
|
||||
d.ModelType = ModelType(value)
|
||||
case "deployment_scope":
|
||||
d.DeploymentScope = DeploymentScope(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Diff computes the changes between two configs.
|
||||
func (d *DimensionConfig) Diff(other *DimensionConfig) []DimensionDelta {
|
||||
dimensions := []string{
|
||||
"automation_level", "decision_binding", "decision_impact", "domain",
|
||||
"data_type", "human_in_loop", "explainability", "risk_classification",
|
||||
"legal_basis", "transparency_required", "logging_required",
|
||||
"model_type", "deployment_scope",
|
||||
}
|
||||
var deltas []DimensionDelta
|
||||
for _, dim := range dimensions {
|
||||
from := d.GetValue(dim)
|
||||
to := other.GetValue(dim)
|
||||
if from != to {
|
||||
deltas = append(deltas, DimensionDelta{
|
||||
Dimension: dim,
|
||||
From: from,
|
||||
To: to,
|
||||
Impact: describeDeltaImpact(dim, from, to),
|
||||
})
|
||||
}
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the config.
|
||||
func (d *DimensionConfig) Clone() DimensionConfig {
|
||||
return *d
|
||||
}
|
||||
|
||||
func describeDeltaImpact(dimension, from, to string) string {
|
||||
switch dimension {
|
||||
case "automation_level":
|
||||
return "Automatisierungsgrad: " + from + " → " + to
|
||||
case "decision_binding":
|
||||
return "Entscheidungsbindung: " + from + " → " + to
|
||||
case "human_in_loop":
|
||||
return "Menschliche Kontrolle: " + from + " → " + to
|
||||
case "explainability":
|
||||
return "Erklaerbarkeit: " + from + " → " + to
|
||||
case "data_type":
|
||||
return "Datensensitivitaet: " + from + " → " + to
|
||||
case "transparency_required":
|
||||
return "Transparenzpflicht: " + from + " → " + to
|
||||
case "logging_required":
|
||||
return "Protokollierungspflicht: " + from + " → " + to
|
||||
default:
|
||||
return dimension + ": " + from + " → " + to
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package maximizer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
)
|
||||
|
||||
func TestGetValueSetValueRoundtrip(t *testing.T) {
|
||||
config := DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionBinding: BindingFullyBinding,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILNone,
|
||||
Explainability: ExplainNone,
|
||||
RiskClassification: RiskHigh,
|
||||
LegalBasis: LegalContract,
|
||||
TransparencyRequired: true,
|
||||
LoggingRequired: false,
|
||||
ModelType: ModelBlackboxLLM,
|
||||
DeploymentScope: ScopeExternal,
|
||||
}
|
||||
|
||||
for _, dim := range allDimensions {
|
||||
val := config.GetValue(dim)
|
||||
if val == "" {
|
||||
t.Errorf("GetValue(%q) returned empty", dim)
|
||||
}
|
||||
clone := DimensionConfig{}
|
||||
ok := clone.SetValue(dim, val)
|
||||
if !ok {
|
||||
t.Errorf("SetValue(%q, %q) returned false", dim, val)
|
||||
}
|
||||
if clone.GetValue(dim) != val {
|
||||
t.Errorf("SetValue roundtrip failed for %q: got %q, want %q", dim, clone.GetValue(dim), val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValueUnknownDimension(t *testing.T) {
|
||||
config := DimensionConfig{}
|
||||
if v := config.GetValue("nonexistent"); v != "" {
|
||||
t.Errorf("expected empty for unknown dimension, got %q", v)
|
||||
}
|
||||
if ok := config.SetValue("nonexistent", "x"); ok {
|
||||
t.Error("expected false for unknown dimension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffIdentical(t *testing.T) {
|
||||
config := DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
DecisionImpact: ImpactLow,
|
||||
Domain: DomainGeneral,
|
||||
}
|
||||
deltas := config.Diff(&config)
|
||||
if len(deltas) != 0 {
|
||||
t.Errorf("expected 0 deltas for identical configs, got %d", len(deltas))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffDetectsChanges(t *testing.T) {
|
||||
a := DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
HumanInLoop: HILNone,
|
||||
DecisionBinding: BindingFullyBinding,
|
||||
}
|
||||
b := DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
HumanInLoop: HILRequired,
|
||||
DecisionBinding: BindingHumanReview,
|
||||
}
|
||||
deltas := a.Diff(&b)
|
||||
|
||||
changed := make(map[string]bool)
|
||||
for _, d := range deltas {
|
||||
changed[d.Dimension] = true
|
||||
}
|
||||
for _, dim := range []string{"automation_level", "human_in_loop", "decision_binding"} {
|
||||
if !changed[dim] {
|
||||
t.Errorf("expected %q in deltas", dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClone(t *testing.T) {
|
||||
orig := DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
Domain: DomainHR,
|
||||
}
|
||||
clone := orig.Clone()
|
||||
clone.AutomationLevel = AutoAssistive
|
||||
if orig.AutomationLevel != AutoFull {
|
||||
t.Error("clone modified original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapIntakeToDimensions(t *testing.T) {
|
||||
intake := &ucca.UseCaseIntake{
|
||||
Domain: "hr",
|
||||
Automation: ucca.AutomationFullyAutomated,
|
||||
DataTypes: ucca.DataTypes{
|
||||
PersonalData: true,
|
||||
Article9Data: true,
|
||||
},
|
||||
Purpose: ucca.Purpose{
|
||||
DecisionMaking: true,
|
||||
},
|
||||
Outputs: ucca.Outputs{
|
||||
LegalEffects: true,
|
||||
},
|
||||
ModelUsage: ucca.ModelUsage{
|
||||
Training: true,
|
||||
},
|
||||
}
|
||||
|
||||
config := MapIntakeToDimensions(intake)
|
||||
|
||||
tests := []struct {
|
||||
dimension string
|
||||
expected string
|
||||
}{
|
||||
{"automation_level", "full"},
|
||||
{"domain", "hr"},
|
||||
{"data_type", "sensitive"},
|
||||
{"decision_impact", "high"},
|
||||
{"model_type", "blackbox_llm"},
|
||||
{"human_in_loop", "none"},
|
||||
{"decision_binding", "fully_binding"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := config.GetValue(tc.dimension)
|
||||
if got != tc.expected {
|
||||
t.Errorf("MapIntakeToDimensions: %s = %q, want %q", tc.dimension, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapIntakeToDimensionsBiometricWins(t *testing.T) {
|
||||
intake := &ucca.UseCaseIntake{
|
||||
DataTypes: ucca.DataTypes{
|
||||
PersonalData: true,
|
||||
Article9Data: true,
|
||||
BiometricData: true,
|
||||
},
|
||||
}
|
||||
config := MapIntakeToDimensions(intake)
|
||||
if config.DataType != DataBiometric {
|
||||
t.Errorf("expected biometric (highest sensitivity), got %s", config.DataType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDimensionsToIntakePreservesOriginal(t *testing.T) {
|
||||
original := &ucca.UseCaseIntake{
|
||||
UseCaseText: "Test use case",
|
||||
Domain: "hr",
|
||||
Title: "My Assessment",
|
||||
Automation: ucca.AutomationFullyAutomated,
|
||||
DataTypes: ucca.DataTypes{
|
||||
PersonalData: true,
|
||||
},
|
||||
Hosting: ucca.Hosting{
|
||||
Region: "eu",
|
||||
},
|
||||
}
|
||||
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
DataType: DataPersonal,
|
||||
Domain: DomainHR,
|
||||
}
|
||||
|
||||
result := MapDimensionsToIntake(config, original)
|
||||
|
||||
if result.UseCaseText != "Test use case" {
|
||||
t.Error("MapDimensionsToIntake did not preserve UseCaseText")
|
||||
}
|
||||
if result.Title != "My Assessment" {
|
||||
t.Error("MapDimensionsToIntake did not preserve Title")
|
||||
}
|
||||
if result.Hosting.Region != "eu" {
|
||||
t.Error("MapDimensionsToIntake did not preserve Hosting")
|
||||
}
|
||||
if result.Automation != ucca.AutomationAssistive {
|
||||
t.Errorf("expected assistive automation, got %s", result.Automation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllValuesComplete(t *testing.T) {
|
||||
for _, dim := range allDimensions {
|
||||
vals, ok := AllValues[dim]
|
||||
if !ok {
|
||||
t.Errorf("AllValues missing dimension %q", dim)
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
t.Errorf("AllValues[%q] is empty", dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package maximizer
|
||||
|
||||
// Zone classifies a dimension value's regulatory status.
|
||||
type Zone string
|
||||
|
||||
const (
|
||||
ZoneForbidden Zone = "FORBIDDEN"
|
||||
ZoneRestricted Zone = "RESTRICTED"
|
||||
ZoneSafe Zone = "SAFE"
|
||||
)
|
||||
|
||||
// ZoneInfo classifies a single dimension value within the constraint space.
|
||||
type ZoneInfo struct {
|
||||
Dimension string `json:"dimension"`
|
||||
CurrentValue string `json:"current_value"`
|
||||
Zone Zone `json:"zone"`
|
||||
AllowedValues []string `json:"allowed_values,omitempty"`
|
||||
ForbiddenValues []string `json:"forbidden_values,omitempty"`
|
||||
Safeguards []string `json:"safeguards,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
ObligationRefs []string `json:"obligation_refs"`
|
||||
}
|
||||
|
||||
// Violation is a hard block triggered by a constraint rule.
|
||||
type Violation struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
ObligationID string `json:"obligation_id"`
|
||||
ArticleRef string `json:"article_ref"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Dimension string `json:"dimension,omitempty"`
|
||||
}
|
||||
|
||||
// Restriction is a safeguard requirement (yellow zone).
|
||||
type Restriction struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
ObligationID string `json:"obligation_id"`
|
||||
ArticleRef string `json:"article_ref"`
|
||||
Title string `json:"title"`
|
||||
Required map[string]string `json:"required"`
|
||||
}
|
||||
|
||||
// TriggeredConstraint records which constraint rule was triggered and why.
|
||||
type TriggeredConstraint struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
ObligationID string `json:"obligation_id"`
|
||||
Regulation string `json:"regulation"`
|
||||
ArticleRef string `json:"article_ref"`
|
||||
Title string `json:"title"`
|
||||
RuleType string `json:"rule_type"`
|
||||
}
|
||||
|
||||
// EvaluationResult is the complete 3-zone analysis of a DimensionConfig.
|
||||
type EvaluationResult struct {
|
||||
IsCompliant bool `json:"is_compliant"`
|
||||
Violations []Violation `json:"violations"`
|
||||
Restrictions []Restriction `json:"restrictions"`
|
||||
ZoneMap map[string]ZoneInfo `json:"zone_map"`
|
||||
RequiredControls []string `json:"required_controls"`
|
||||
RequiredPatterns []string `json:"required_patterns"`
|
||||
TriggeredRules []TriggeredConstraint `json:"triggered_rules"`
|
||||
RiskClassification string `json:"risk_classification,omitempty"`
|
||||
}
|
||||
|
||||
// Evaluator evaluates dimension configs against constraint rules.
|
||||
type Evaluator struct {
|
||||
rules *ConstraintRuleSet
|
||||
}
|
||||
|
||||
// NewEvaluator creates an evaluator from a loaded constraint ruleset.
|
||||
func NewEvaluator(rules *ConstraintRuleSet) *Evaluator {
|
||||
return &Evaluator{rules: rules}
|
||||
}
|
||||
|
||||
// Evaluate checks a config against all constraints and produces a 3-zone result.
|
||||
func (e *Evaluator) Evaluate(config *DimensionConfig) *EvaluationResult {
|
||||
result := &EvaluationResult{
|
||||
IsCompliant: true,
|
||||
ZoneMap: make(map[string]ZoneInfo),
|
||||
RequiredControls: []string{},
|
||||
RequiredPatterns: []string{},
|
||||
}
|
||||
|
||||
// Initialize all dimensions as SAFE
|
||||
for _, dim := range allDimensions {
|
||||
result.ZoneMap[dim] = ZoneInfo{
|
||||
Dimension: dim,
|
||||
CurrentValue: config.GetValue(dim),
|
||||
Zone: ZoneSafe,
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate each rule
|
||||
for _, rule := range e.rules.Rules {
|
||||
e.evaluateRule(config, &rule, result)
|
||||
}
|
||||
|
||||
// Apply risk classification if set
|
||||
if result.RiskClassification == "" {
|
||||
result.RiskClassification = string(config.RiskClassification)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Evaluator) evaluateRule(config *DimensionConfig, rule *ConstraintRule, result *EvaluationResult) {
|
||||
for _, constraint := range rule.Constraints {
|
||||
if !constraint.If.Matches(config) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Rule triggered
|
||||
result.TriggeredRules = append(result.TriggeredRules, TriggeredConstraint{
|
||||
RuleID: rule.ID,
|
||||
ObligationID: rule.ObligationID,
|
||||
Regulation: rule.Regulation,
|
||||
ArticleRef: rule.ArticleRef,
|
||||
Title: rule.Title,
|
||||
RuleType: rule.RuleType,
|
||||
})
|
||||
|
||||
// Hard block?
|
||||
if constraint.Then.Allowed != nil && !*constraint.Then.Allowed {
|
||||
result.IsCompliant = false
|
||||
result.Violations = append(result.Violations, Violation{
|
||||
RuleID: rule.ID,
|
||||
ObligationID: rule.ObligationID,
|
||||
ArticleRef: rule.ArticleRef,
|
||||
Title: rule.Title,
|
||||
Description: rule.Description,
|
||||
})
|
||||
e.markForbiddenDimensions(config, constraint.If, rule, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// Required values (yellow zone)?
|
||||
if len(constraint.Then.RequiredValues) > 0 {
|
||||
e.applyRequiredValues(config, constraint.Then.RequiredValues, rule, result)
|
||||
}
|
||||
|
||||
// Risk classification override
|
||||
if constraint.Then.SetRiskClassification != "" {
|
||||
result.RiskClassification = constraint.Then.SetRiskClassification
|
||||
}
|
||||
|
||||
// Collect controls and patterns
|
||||
result.RequiredControls = appendUnique(result.RequiredControls, constraint.Then.RequiredControls...)
|
||||
result.RequiredPatterns = appendUnique(result.RequiredPatterns, constraint.Then.RequiredPatterns...)
|
||||
}
|
||||
}
|
||||
|
||||
// markForbiddenDimensions marks the dimensions from the condition as FORBIDDEN.
|
||||
func (e *Evaluator) markForbiddenDimensions(
|
||||
config *DimensionConfig, cond ConditionSet, rule *ConstraintRule, result *EvaluationResult,
|
||||
) {
|
||||
for dim := range cond {
|
||||
zi := result.ZoneMap[dim]
|
||||
zi.Zone = ZoneForbidden
|
||||
zi.Reason = rule.Title
|
||||
zi.ObligationRefs = appendUnique(zi.ObligationRefs, rule.ArticleRef)
|
||||
zi.ForbiddenValues = appendUnique(zi.ForbiddenValues, config.GetValue(dim))
|
||||
result.ZoneMap[dim] = zi
|
||||
}
|
||||
}
|
||||
|
||||
// applyRequiredValues checks if required dimension values are met.
|
||||
func (e *Evaluator) applyRequiredValues(
|
||||
config *DimensionConfig, required map[string]string, rule *ConstraintRule, result *EvaluationResult,
|
||||
) {
|
||||
unmet := make(map[string]string)
|
||||
for dim, requiredVal := range required {
|
||||
actual := config.GetValue(dim)
|
||||
if actual != requiredVal {
|
||||
unmet[dim] = requiredVal
|
||||
// Mark as RESTRICTED (upgrade from SAFE, but don't downgrade from FORBIDDEN)
|
||||
zi := result.ZoneMap[dim]
|
||||
if zi.Zone != ZoneForbidden {
|
||||
zi.Zone = ZoneRestricted
|
||||
zi.Reason = rule.Title
|
||||
zi.AllowedValues = appendUnique(zi.AllowedValues, requiredVal)
|
||||
zi.Safeguards = appendUnique(zi.Safeguards, rule.ArticleRef)
|
||||
zi.ObligationRefs = appendUnique(zi.ObligationRefs, rule.ArticleRef)
|
||||
result.ZoneMap[dim] = zi
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(unmet) > 0 {
|
||||
result.IsCompliant = false
|
||||
result.Restrictions = append(result.Restrictions, Restriction{
|
||||
RuleID: rule.ID,
|
||||
ObligationID: rule.ObligationID,
|
||||
ArticleRef: rule.ArticleRef,
|
||||
Title: rule.Title,
|
||||
Required: unmet,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var allDimensions = []string{
|
||||
"automation_level", "decision_binding", "decision_impact", "domain",
|
||||
"data_type", "human_in_loop", "explainability", "risk_classification",
|
||||
"legal_basis", "transparency_required", "logging_required",
|
||||
"model_type", "deployment_scope",
|
||||
}
|
||||
|
||||
func appendUnique(slice []string, items ...string) []string {
|
||||
seen := make(map[string]bool, len(slice))
|
||||
for _, s := range slice {
|
||||
seen[s] = true
|
||||
}
|
||||
for _, item := range items {
|
||||
if item != "" && !seen[item] {
|
||||
slice = append(slice, item)
|
||||
seen[item] = true
|
||||
}
|
||||
}
|
||||
return slice
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package maximizer
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadTestRules(t *testing.T) *ConstraintRuleSet {
|
||||
t.Helper()
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("cannot determine test file location")
|
||||
}
|
||||
// Walk up from internal/maximizer/ to ai-compliance-sdk/
|
||||
dir := filepath.Dir(filename) // internal/maximizer
|
||||
dir = filepath.Dir(dir) // internal
|
||||
dir = filepath.Dir(dir) // ai-compliance-sdk
|
||||
path := filepath.Join(dir, "policies", "maximizer_constraints_v1.json")
|
||||
rules, err := LoadConstraintRules(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConstraintRules: %v", err)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func TestLoadConstraintRules(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
if rules.Version != "1.0.0" {
|
||||
t.Errorf("expected version 1.0.0, got %s", rules.Version)
|
||||
}
|
||||
if len(rules.Rules) < 20 {
|
||||
t.Errorf("expected at least 20 rules, got %d", len(rules.Rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCompliantConfig(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
DecisionBinding: BindingHumanReview,
|
||||
DecisionImpact: ImpactLow,
|
||||
Domain: DomainGeneral,
|
||||
DataType: DataNonPersonal,
|
||||
HumanInLoop: HILRequired,
|
||||
Explainability: ExplainBasic,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalContract,
|
||||
TransparencyRequired: false,
|
||||
LoggingRequired: false,
|
||||
ModelType: ModelRuleBased,
|
||||
DeploymentScope: ScopeInternal,
|
||||
}
|
||||
|
||||
result := eval.Evaluate(config)
|
||||
if !result.IsCompliant {
|
||||
t.Errorf("expected compliant, got violations: %+v", result.Violations)
|
||||
}
|
||||
// All dimensions should be SAFE
|
||||
for dim, zi := range result.ZoneMap {
|
||||
if zi.Zone != ZoneSafe {
|
||||
t.Errorf("dimension %s: expected SAFE, got %s", dim, zi.Zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalHRFullAutomationBlocked(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionBinding: BindingFullyBinding,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILNone,
|
||||
Explainability: ExplainNone,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalContract,
|
||||
TransparencyRequired: false,
|
||||
LoggingRequired: false,
|
||||
ModelType: ModelBlackboxLLM,
|
||||
DeploymentScope: ScopeExternal,
|
||||
}
|
||||
|
||||
result := eval.Evaluate(config)
|
||||
if result.IsCompliant {
|
||||
t.Error("expected non-compliant for HR full automation")
|
||||
}
|
||||
if len(result.Violations) == 0 {
|
||||
t.Error("expected at least one violation")
|
||||
}
|
||||
|
||||
// automation_level should be FORBIDDEN
|
||||
zi := result.ZoneMap["automation_level"]
|
||||
if zi.Zone != ZoneForbidden {
|
||||
t.Errorf("automation_level: expected FORBIDDEN, got %s", zi.Zone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalProhibitedClassification(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
|
||||
config := &DimensionConfig{
|
||||
RiskClassification: RiskProhibited,
|
||||
DeploymentScope: ScopePublic,
|
||||
}
|
||||
|
||||
result := eval.Evaluate(config)
|
||||
if result.IsCompliant {
|
||||
t.Error("expected non-compliant for prohibited classification")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, v := range result.Violations {
|
||||
if v.RuleID == "MC-AIA-PROHIBITED-001" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected MC-AIA-PROHIBITED-001 violation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalSensitiveDataRequiresConsent(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
|
||||
config := &DimensionConfig{
|
||||
DataType: DataSensitive,
|
||||
LegalBasis: LegalLegitimateInterest, // wrong basis for sensitive
|
||||
}
|
||||
|
||||
result := eval.Evaluate(config)
|
||||
if result.IsCompliant {
|
||||
t.Error("expected non-compliant: sensitive data without consent")
|
||||
}
|
||||
|
||||
// Should require consent
|
||||
found := false
|
||||
for _, r := range result.Restrictions {
|
||||
if val, ok := r.Required["legal_basis"]; ok && val == "consent" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected restriction requiring legal_basis=consent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalHighRiskRequiresLogging(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
|
||||
config := &DimensionConfig{
|
||||
RiskClassification: RiskHigh,
|
||||
LoggingRequired: false,
|
||||
TransparencyRequired: false,
|
||||
HumanInLoop: HILNone,
|
||||
Explainability: ExplainNone,
|
||||
}
|
||||
|
||||
result := eval.Evaluate(config)
|
||||
if result.IsCompliant {
|
||||
t.Error("expected non-compliant: high risk without logging/transparency/hil")
|
||||
}
|
||||
|
||||
// Check logging_required is RESTRICTED
|
||||
zi := result.ZoneMap["logging_required"]
|
||||
if zi.Zone != ZoneRestricted {
|
||||
t.Errorf("logging_required: expected RESTRICTED, got %s", zi.Zone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalTriggeredRulesHaveObligationRefs(t *testing.T) {
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
}
|
||||
|
||||
result := eval.Evaluate(config)
|
||||
for _, tr := range result.TriggeredRules {
|
||||
if tr.RuleID == "" {
|
||||
t.Error("triggered rule missing RuleID")
|
||||
}
|
||||
if tr.ObligationID == "" {
|
||||
t.Error("triggered rule missing ObligationID")
|
||||
}
|
||||
if tr.ArticleRef == "" {
|
||||
t.Error("triggered rule missing ArticleRef")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionSetMatchesExact(t *testing.T) {
|
||||
config := &DimensionConfig{
|
||||
Domain: DomainHR,
|
||||
DecisionImpact: ImpactHigh,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cond ConditionSet
|
||||
matches bool
|
||||
}{
|
||||
{"exact match", ConditionSet{"domain": "hr", "decision_impact": "high"}, true},
|
||||
{"partial match fails", ConditionSet{"domain": "hr", "decision_impact": "low"}, false},
|
||||
{"unknown value", ConditionSet{"domain": "finance"}, false},
|
||||
{"empty condition", ConditionSet{}, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.cond.Matches(config)
|
||||
if got != tc.matches {
|
||||
t.Errorf("expected %v, got %v", tc.matches, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package maximizer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
)
|
||||
|
||||
// MapIntakeToDimensions converts a UseCaseIntake to a normalized DimensionConfig.
|
||||
// Highest sensitivity wins for multi-value fields.
|
||||
func MapIntakeToDimensions(intake *ucca.UseCaseIntake) *DimensionConfig {
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: mapAutomation(intake.Automation),
|
||||
DecisionBinding: deriveBinding(intake),
|
||||
DecisionImpact: deriveImpact(intake),
|
||||
Domain: mapDomain(intake.Domain),
|
||||
DataType: deriveDataType(intake.DataTypes),
|
||||
HumanInLoop: deriveHIL(intake.Automation),
|
||||
Explainability: ExplainBasic, // default
|
||||
RiskClassification: RiskMinimal, // will be set by evaluator
|
||||
LegalBasis: LegalContract, // default
|
||||
TransparencyRequired: false,
|
||||
LoggingRequired: false,
|
||||
ModelType: deriveModelType(intake.ModelUsage),
|
||||
DeploymentScope: deriveScope(intake),
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// MapDimensionsToIntake converts a DimensionConfig back to a UseCaseIntake,
|
||||
// preserving unchanged fields from the original intake.
|
||||
func MapDimensionsToIntake(config *DimensionConfig, original *ucca.UseCaseIntake) *ucca.UseCaseIntake {
|
||||
result := *original // shallow copy
|
||||
|
||||
// Map automation level
|
||||
switch config.AutomationLevel {
|
||||
case AutoNone:
|
||||
result.Automation = ucca.AutomationAssistive
|
||||
case AutoAssistive:
|
||||
result.Automation = ucca.AutomationAssistive
|
||||
case AutoPartial:
|
||||
result.Automation = ucca.AutomationSemiAutomated
|
||||
case AutoFull:
|
||||
result.Automation = ucca.AutomationFullyAutomated
|
||||
}
|
||||
|
||||
// Map data type back
|
||||
result.DataTypes = mapDataTypeBack(config.DataType, original.DataTypes)
|
||||
|
||||
// Map domain back
|
||||
result.Domain = mapDomainBack(config.Domain, original.Domain)
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
func mapAutomation(a ucca.AutomationLevel) AutomationLevel {
|
||||
switch a {
|
||||
case ucca.AutomationAssistive:
|
||||
return AutoAssistive
|
||||
case ucca.AutomationSemiAutomated:
|
||||
return AutoPartial
|
||||
case ucca.AutomationFullyAutomated:
|
||||
return AutoFull
|
||||
default:
|
||||
return AutoNone
|
||||
}
|
||||
}
|
||||
|
||||
func deriveBinding(intake *ucca.UseCaseIntake) DecisionBinding {
|
||||
if intake.Outputs.LegalEffects || intake.Outputs.AccessDecisions {
|
||||
if intake.Automation == ucca.AutomationFullyAutomated {
|
||||
return BindingFullyBinding
|
||||
}
|
||||
return BindingHumanReview
|
||||
}
|
||||
return BindingNonBinding
|
||||
}
|
||||
|
||||
func deriveImpact(intake *ucca.UseCaseIntake) DecisionImpact {
|
||||
if intake.Outputs.LegalEffects || intake.Outputs.AccessDecisions {
|
||||
return ImpactHigh
|
||||
}
|
||||
if intake.Outputs.RankingsOrScores || intake.Purpose.EvaluationScoring || intake.Purpose.DecisionMaking {
|
||||
return ImpactMedium
|
||||
}
|
||||
return ImpactLow
|
||||
}
|
||||
|
||||
func mapDomain(d ucca.Domain) DomainCategory {
|
||||
switch d {
|
||||
case "hr", "human_resources":
|
||||
return DomainHR
|
||||
case "finance", "banking", "insurance", "investment":
|
||||
return DomainFinance
|
||||
case "education", "school", "university":
|
||||
return DomainEducation
|
||||
case "health", "healthcare", "medical":
|
||||
return DomainHealth
|
||||
case "marketing", "advertising":
|
||||
return DomainMarketing
|
||||
default:
|
||||
return DomainGeneral
|
||||
}
|
||||
}
|
||||
|
||||
func deriveDataType(dt ucca.DataTypes) DataTypeSensitivity {
|
||||
// Highest sensitivity wins
|
||||
if dt.BiometricData {
|
||||
return DataBiometric
|
||||
}
|
||||
if dt.Article9Data {
|
||||
return DataSensitive
|
||||
}
|
||||
if dt.PersonalData || dt.EmployeeData || dt.CustomerData ||
|
||||
dt.FinancialData || dt.MinorData || dt.LocationData ||
|
||||
dt.Images || dt.Audio {
|
||||
return DataPersonal
|
||||
}
|
||||
return DataNonPersonal
|
||||
}
|
||||
|
||||
func deriveHIL(a ucca.AutomationLevel) HumanInLoopLevel {
|
||||
switch a {
|
||||
case ucca.AutomationAssistive:
|
||||
return HILRequired
|
||||
case ucca.AutomationSemiAutomated:
|
||||
return HILOptional
|
||||
case ucca.AutomationFullyAutomated:
|
||||
return HILNone
|
||||
default:
|
||||
return HILRequired
|
||||
}
|
||||
}
|
||||
|
||||
func deriveModelType(mu ucca.ModelUsage) ModelType {
|
||||
if mu.RAG && !mu.Training && !mu.Finetune {
|
||||
return ModelRuleBased
|
||||
}
|
||||
if mu.Training || mu.Finetune {
|
||||
return ModelBlackboxLLM
|
||||
}
|
||||
return ModelStatistical
|
||||
}
|
||||
|
||||
func deriveScope(intake *ucca.UseCaseIntake) DeploymentScope {
|
||||
if intake.Purpose.PublicService || intake.Outputs.DataExport {
|
||||
return ScopePublic
|
||||
}
|
||||
if intake.Purpose.CustomerSupport || intake.Purpose.Marketing {
|
||||
return ScopeExternal
|
||||
}
|
||||
return ScopeInternal
|
||||
}
|
||||
|
||||
func mapDataTypeBack(dt DataTypeSensitivity, original ucca.DataTypes) ucca.DataTypes {
|
||||
result := original
|
||||
switch dt {
|
||||
case DataNonPersonal:
|
||||
result.PersonalData = false
|
||||
result.Article9Data = false
|
||||
result.BiometricData = false
|
||||
case DataPersonal:
|
||||
result.PersonalData = true
|
||||
result.Article9Data = false
|
||||
result.BiometricData = false
|
||||
case DataSensitive:
|
||||
result.PersonalData = true
|
||||
result.Article9Data = true
|
||||
result.BiometricData = false
|
||||
case DataBiometric:
|
||||
result.PersonalData = true
|
||||
result.Article9Data = true
|
||||
result.BiometricData = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// EnrichDimensionsFromProfile adjusts dimensions based on company profile data.
|
||||
func EnrichDimensionsFromProfile(config *DimensionConfig, profile *ucca.CompanyProfileInput) {
|
||||
if profile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Domain override from company industry (if config still generic)
|
||||
if config.Domain == DomainGeneral && profile.Industry != "" {
|
||||
lower := strings.ToLower(profile.Industry)
|
||||
switch {
|
||||
case strings.Contains(lower, "gesundheit") || strings.Contains(lower, "health"):
|
||||
config.Domain = DomainHealth
|
||||
case strings.Contains(lower, "finanz") || strings.Contains(lower, "bank") || strings.Contains(lower, "versicherung"):
|
||||
config.Domain = DomainFinance
|
||||
case strings.Contains(lower, "bildung") || strings.Contains(lower, "schule"):
|
||||
config.Domain = DomainEducation
|
||||
case strings.Contains(lower, "personal") || strings.Contains(lower, "hr"):
|
||||
config.Domain = DomainHR
|
||||
case strings.Contains(lower, "marketing"):
|
||||
config.Domain = DomainMarketing
|
||||
}
|
||||
}
|
||||
|
||||
// NIS2/AI-Act regulatory flags
|
||||
if profile.SubjectToNIS2 {
|
||||
config.LoggingRequired = true
|
||||
}
|
||||
if profile.SubjectToAIAct {
|
||||
config.TransparencyRequired = true
|
||||
}
|
||||
}
|
||||
|
||||
func mapDomainBack(dc DomainCategory, original ucca.Domain) ucca.Domain {
|
||||
switch dc {
|
||||
case DomainHR:
|
||||
return "hr"
|
||||
case DomainFinance:
|
||||
return "finance"
|
||||
case DomainEducation:
|
||||
return "education"
|
||||
case DomainHealth:
|
||||
return "health"
|
||||
case DomainMarketing:
|
||||
return "marketing"
|
||||
default:
|
||||
return original
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package maximizer
|
||||
|
||||
import "sort"
|
||||
|
||||
const maxVariants = 5
|
||||
|
||||
// OptimizedVariant is a single compliant configuration with scoring.
|
||||
type OptimizedVariant struct {
|
||||
Config DimensionConfig `json:"config"`
|
||||
Evaluation *EvaluationResult `json:"evaluation"`
|
||||
Deltas []DimensionDelta `json:"deltas"`
|
||||
DeltaCount int `json:"delta_count"`
|
||||
SafetyScore int `json:"safety_score"`
|
||||
UtilityScore int `json:"utility_score"`
|
||||
CompositeScore float64 `json:"composite_score"`
|
||||
Rationale string `json:"rationale"`
|
||||
}
|
||||
|
||||
// OptimizationResult contains the original evaluation and ranked compliant variants.
|
||||
type OptimizationResult struct {
|
||||
OriginalConfig DimensionConfig `json:"original_config"`
|
||||
OriginalCompliant bool `json:"original_compliant"`
|
||||
OriginalEval *EvaluationResult `json:"original_evaluation"`
|
||||
Variants []OptimizedVariant `json:"variants"`
|
||||
MaxSafeConfig *OptimizedVariant `json:"max_safe_config"`
|
||||
}
|
||||
|
||||
// Optimizer finds the maximum compliant configuration variant.
|
||||
type Optimizer struct {
|
||||
evaluator *Evaluator
|
||||
weights ScoreWeights
|
||||
}
|
||||
|
||||
// NewOptimizer creates an optimizer backed by the given evaluator.
|
||||
func NewOptimizer(evaluator *Evaluator) *Optimizer {
|
||||
return &Optimizer{evaluator: evaluator, weights: DefaultWeights}
|
||||
}
|
||||
|
||||
// Optimize takes a desired (possibly non-compliant) config and returns
|
||||
// ranked compliant alternatives.
|
||||
func (o *Optimizer) Optimize(desired *DimensionConfig) *OptimizationResult {
|
||||
eval := o.evaluator.Evaluate(desired)
|
||||
result := &OptimizationResult{
|
||||
OriginalConfig: *desired,
|
||||
OriginalCompliant: eval.IsCompliant,
|
||||
OriginalEval: eval,
|
||||
}
|
||||
|
||||
if eval.IsCompliant {
|
||||
variant := o.scoreVariant(desired, desired, eval)
|
||||
variant.Rationale = "Konfiguration ist bereits konform"
|
||||
result.Variants = []OptimizedVariant{variant}
|
||||
result.MaxSafeConfig = &result.Variants[0]
|
||||
return result
|
||||
}
|
||||
|
||||
// Check for hard prohibitions that cannot be optimized
|
||||
if o.hasProhibitedClassification(desired) {
|
||||
result.Variants = []OptimizedVariant{}
|
||||
return result
|
||||
}
|
||||
|
||||
candidates := o.generateCandidates(desired, eval)
|
||||
result.Variants = candidates
|
||||
if len(candidates) > 0 {
|
||||
result.MaxSafeConfig = &result.Variants[0]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (o *Optimizer) hasProhibitedClassification(config *DimensionConfig) bool {
|
||||
return config.RiskClassification == RiskProhibited
|
||||
}
|
||||
|
||||
// generateCandidates builds compliant variants by fixing violations.
|
||||
func (o *Optimizer) generateCandidates(desired *DimensionConfig, eval *EvaluationResult) []OptimizedVariant {
|
||||
// Strategy 1: Fix all violations in one pass (greedy nearest fix)
|
||||
greedy := o.greedyFix(desired, eval)
|
||||
var candidates []OptimizedVariant
|
||||
|
||||
if greedy != nil {
|
||||
greedyEval := o.evaluator.Evaluate(&greedy.Config)
|
||||
if greedyEval.IsCompliant {
|
||||
v := o.scoreVariant(desired, &greedy.Config, greedyEval)
|
||||
v.Rationale = "Minimale Anpassung — naechster konformer Zustand"
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Conservative variant (maximum safety)
|
||||
conservative := o.conservativeFix(desired, eval)
|
||||
if conservative != nil {
|
||||
consEval := o.evaluator.Evaluate(&conservative.Config)
|
||||
if consEval.IsCompliant {
|
||||
v := o.scoreVariant(desired, &conservative.Config, consEval)
|
||||
v.Rationale = "Konservative Variante — maximale regulatorische Sicherheit"
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Fix restricted dimensions too (belt-and-suspenders)
|
||||
enhanced := o.enhancedFix(desired, eval)
|
||||
if enhanced != nil {
|
||||
enhEval := o.evaluator.Evaluate(&enhanced.Config)
|
||||
if enhEval.IsCompliant {
|
||||
v := o.scoreVariant(desired, &enhanced.Config, enhEval)
|
||||
v.Rationale = "Erweiterte Variante — alle Einschraenkungen vorab behoben"
|
||||
candidates = append(candidates, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and sort by composite score
|
||||
candidates = deduplicateVariants(candidates)
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].CompositeScore > candidates[j].CompositeScore
|
||||
})
|
||||
if len(candidates) > maxVariants {
|
||||
candidates = candidates[:maxVariants]
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
// greedyFix applies the minimum change per violated dimension.
|
||||
func (o *Optimizer) greedyFix(desired *DimensionConfig, eval *EvaluationResult) *OptimizedVariant {
|
||||
fixed := desired.Clone()
|
||||
|
||||
// Fix FORBIDDEN zones
|
||||
for dim, zi := range eval.ZoneMap {
|
||||
if zi.Zone != ZoneForbidden {
|
||||
continue
|
||||
}
|
||||
o.fixDimension(&fixed, dim, eval)
|
||||
}
|
||||
|
||||
// Fix RESTRICTED zones (required values not met)
|
||||
for _, restriction := range eval.Restrictions {
|
||||
for dim, requiredVal := range restriction.Required {
|
||||
fixed.SetValue(dim, requiredVal)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate and iterate (max 3 passes to converge)
|
||||
for i := 0; i < 3; i++ {
|
||||
reEval := o.evaluator.Evaluate(&fixed)
|
||||
if reEval.IsCompliant {
|
||||
break
|
||||
}
|
||||
for dim, zi := range reEval.ZoneMap {
|
||||
if zi.Zone == ZoneForbidden {
|
||||
o.fixDimension(&fixed, dim, reEval)
|
||||
}
|
||||
}
|
||||
for _, restriction := range reEval.Restrictions {
|
||||
for dim, requiredVal := range restriction.Required {
|
||||
fixed.SetValue(dim, requiredVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &OptimizedVariant{Config: fixed}
|
||||
}
|
||||
|
||||
// conservativeFix chooses the safest allowed value for each violated dimension.
|
||||
func (o *Optimizer) conservativeFix(desired *DimensionConfig, eval *EvaluationResult) *OptimizedVariant {
|
||||
fixed := desired.Clone()
|
||||
|
||||
for dim, zi := range eval.ZoneMap {
|
||||
if zi.Zone == ZoneSafe {
|
||||
continue
|
||||
}
|
||||
// Use the safest (lowest ordinal risk) value
|
||||
vals := AllValues[dim]
|
||||
if len(vals) > 0 {
|
||||
fixed.SetValue(dim, vals[0]) // index 0 = safest
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all required values
|
||||
for _, restriction := range eval.Restrictions {
|
||||
for dim, val := range restriction.Required {
|
||||
fixed.SetValue(dim, val)
|
||||
}
|
||||
}
|
||||
|
||||
return &OptimizedVariant{Config: fixed}
|
||||
}
|
||||
|
||||
// enhancedFix fixes violations AND proactively resolves restrictions.
|
||||
func (o *Optimizer) enhancedFix(desired *DimensionConfig, eval *EvaluationResult) *OptimizedVariant {
|
||||
fixed := desired.Clone()
|
||||
|
||||
// Fix all non-SAFE dimensions
|
||||
for dim, zi := range eval.ZoneMap {
|
||||
if zi.Zone == ZoneSafe {
|
||||
continue
|
||||
}
|
||||
if len(zi.AllowedValues) > 0 {
|
||||
fixed.SetValue(dim, zi.AllowedValues[0])
|
||||
} else {
|
||||
o.fixDimension(&fixed, dim, eval)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply required values
|
||||
for _, restriction := range eval.Restrictions {
|
||||
for dim, val := range restriction.Required {
|
||||
fixed.SetValue(dim, val)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate to converge
|
||||
for i := 0; i < 3; i++ {
|
||||
reEval := o.evaluator.Evaluate(&fixed)
|
||||
if reEval.IsCompliant {
|
||||
break
|
||||
}
|
||||
for _, restriction := range reEval.Restrictions {
|
||||
for dim, val := range restriction.Required {
|
||||
fixed.SetValue(dim, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &OptimizedVariant{Config: fixed}
|
||||
}
|
||||
|
||||
// fixDimension steps the dimension to the nearest safer value.
|
||||
func (o *Optimizer) fixDimension(config *DimensionConfig, dim string, eval *EvaluationResult) {
|
||||
vals := AllValues[dim]
|
||||
if len(vals) == 0 {
|
||||
return
|
||||
}
|
||||
current := config.GetValue(dim)
|
||||
currentIdx := indexOf(vals, current)
|
||||
if currentIdx < 0 {
|
||||
config.SetValue(dim, vals[0])
|
||||
return
|
||||
}
|
||||
|
||||
// For risk-ordered dimensions, step toward the safer end (lower index).
|
||||
// For inverse dimensions (human_in_loop, explainability), lower index = more safe.
|
||||
if currentIdx > 0 {
|
||||
config.SetValue(dim, vals[currentIdx-1])
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Optimizer) scoreVariant(original, variant *DimensionConfig, eval *EvaluationResult) OptimizedVariant {
|
||||
deltas := original.Diff(variant)
|
||||
safety := ComputeSafetyScore(eval)
|
||||
utility := ComputeUtilityScore(original, variant)
|
||||
composite := ComputeCompositeScore(safety, utility, o.weights)
|
||||
return OptimizedVariant{
|
||||
Config: *variant,
|
||||
Evaluation: eval,
|
||||
Deltas: deltas,
|
||||
DeltaCount: len(deltas),
|
||||
SafetyScore: safety,
|
||||
UtilityScore: utility,
|
||||
CompositeScore: composite,
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(slice []string, val string) int {
|
||||
for i, v := range slice {
|
||||
if v == val {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func deduplicateVariants(variants []OptimizedVariant) []OptimizedVariant {
|
||||
seen := make(map[string]bool)
|
||||
var unique []OptimizedVariant
|
||||
for _, v := range variants {
|
||||
key := configKey(&v.Config)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
unique = append(unique, v)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func configKey(c *DimensionConfig) string {
|
||||
var key string
|
||||
for _, dim := range allDimensions {
|
||||
key += dim + "=" + c.GetValue(dim) + ";"
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package maximizer
|
||||
|
||||
import "testing"
|
||||
|
||||
func newTestOptimizer(t *testing.T) *Optimizer {
|
||||
t.Helper()
|
||||
rules := loadTestRules(t)
|
||||
eval := NewEvaluator(rules)
|
||||
return NewOptimizer(eval)
|
||||
}
|
||||
|
||||
// --- Golden Test Cases ---
|
||||
|
||||
func TestGC01_HRFullAutomationBlocked(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionBinding: BindingFullyBinding,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILNone,
|
||||
Explainability: ExplainNone,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalContract,
|
||||
ModelType: ModelBlackboxLLM,
|
||||
DeploymentScope: ScopeExternal,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if result.OriginalCompliant {
|
||||
t.Fatal("expected original to be non-compliant")
|
||||
}
|
||||
if result.MaxSafeConfig == nil {
|
||||
t.Fatal("expected an optimized variant")
|
||||
}
|
||||
|
||||
max := result.MaxSafeConfig
|
||||
if max.Config.AutomationLevel == AutoFull {
|
||||
t.Error("optimizer must change automation_level from full")
|
||||
}
|
||||
if max.Config.HumanInLoop != HILRequired {
|
||||
t.Errorf("expected human_in_loop=required, got %s", max.Config.HumanInLoop)
|
||||
}
|
||||
if max.Config.DecisionBinding == BindingFullyBinding {
|
||||
t.Error("expected decision_binding to change from fully_binding")
|
||||
}
|
||||
// Verify the optimized config is actually compliant
|
||||
if !max.Evaluation.IsCompliant {
|
||||
t.Errorf("MaxSafeConfig is not compliant: violations=%+v", max.Evaluation.Violations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC02_HRRankingWithHumanReviewAllowed(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
DecisionBinding: BindingHumanReview,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILRequired,
|
||||
Explainability: ExplainBasic,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalContract,
|
||||
TransparencyRequired: true,
|
||||
LoggingRequired: true,
|
||||
ModelType: ModelBlackboxLLM,
|
||||
DeploymentScope: ScopeExternal,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
// Should be allowed with conditions (requirements from high-risk classification)
|
||||
if result.MaxSafeConfig == nil {
|
||||
t.Fatal("expected a variant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC05_SensitiveDataWithoutLegalBasis(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
DataType: DataSensitive,
|
||||
LegalBasis: LegalLegitimateInterest,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
AutomationLevel: AutoAssistive,
|
||||
HumanInLoop: HILRequired,
|
||||
DecisionBinding: BindingHumanReview,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if result.OriginalCompliant {
|
||||
t.Error("expected non-compliant: sensitive data with legitimate_interest")
|
||||
}
|
||||
if result.MaxSafeConfig == nil {
|
||||
t.Fatal("expected optimized variant")
|
||||
}
|
||||
if result.MaxSafeConfig.Config.LegalBasis != LegalConsent {
|
||||
t.Errorf("expected legal_basis=consent, got %s", result.MaxSafeConfig.Config.LegalBasis)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC16_ProhibitedPracticeBlocked(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
RiskClassification: RiskProhibited,
|
||||
DeploymentScope: ScopePublic,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if result.OriginalCompliant {
|
||||
t.Error("expected non-compliant for prohibited")
|
||||
}
|
||||
// Prohibited = no optimization possible
|
||||
if len(result.Variants) > 0 {
|
||||
t.Error("expected no variants for prohibited classification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC18_OptimizerMinimalChange(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionBinding: BindingFullyBinding,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILNone,
|
||||
Explainability: ExplainBasic,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalContract,
|
||||
ModelType: ModelStatistical,
|
||||
DeploymentScope: ScopeInternal,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if result.MaxSafeConfig == nil {
|
||||
t.Fatal("expected optimized variant")
|
||||
}
|
||||
|
||||
max := result.MaxSafeConfig
|
||||
// Domain must NOT change
|
||||
if max.Config.Domain != DomainHR {
|
||||
t.Errorf("optimizer must not change domain: got %s", max.Config.Domain)
|
||||
}
|
||||
// Explainability was already basic, should stay
|
||||
if max.Config.Explainability != ExplainBasic {
|
||||
t.Errorf("optimizer should keep explainability=basic, got %s", max.Config.Explainability)
|
||||
}
|
||||
// Model type should not change unnecessarily
|
||||
if max.Config.ModelType != ModelStatistical {
|
||||
t.Errorf("optimizer should not change model_type unnecessarily, got %s", max.Config.ModelType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGC20_AlreadyCompliantNoChanges(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
DecisionBinding: BindingNonBinding,
|
||||
DecisionImpact: ImpactLow,
|
||||
Domain: DomainGeneral,
|
||||
DataType: DataNonPersonal,
|
||||
HumanInLoop: HILRequired,
|
||||
Explainability: ExplainBasic,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalContract,
|
||||
TransparencyRequired: false,
|
||||
LoggingRequired: false,
|
||||
ModelType: ModelRuleBased,
|
||||
DeploymentScope: ScopeInternal,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if !result.OriginalCompliant {
|
||||
t.Error("expected compliant")
|
||||
}
|
||||
if result.MaxSafeConfig == nil {
|
||||
t.Fatal("expected variant")
|
||||
}
|
||||
if result.MaxSafeConfig.DeltaCount != 0 {
|
||||
t.Errorf("expected 0 deltas for compliant config, got %d", result.MaxSafeConfig.DeltaCount)
|
||||
}
|
||||
if result.MaxSafeConfig.UtilityScore != 100 {
|
||||
t.Errorf("expected utility 100, got %d", result.MaxSafeConfig.UtilityScore)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Meta Tests ---
|
||||
|
||||
func TestMT01_Determinism(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILNone,
|
||||
}
|
||||
|
||||
r1 := opt.Optimize(config)
|
||||
r2 := opt.Optimize(config)
|
||||
|
||||
if r1.OriginalCompliant != r2.OriginalCompliant {
|
||||
t.Error("determinism failed: different compliance result")
|
||||
}
|
||||
if len(r1.Variants) != len(r2.Variants) {
|
||||
t.Errorf("determinism failed: %d vs %d variants", len(r1.Variants), len(r2.Variants))
|
||||
}
|
||||
if r1.MaxSafeConfig != nil && r2.MaxSafeConfig != nil {
|
||||
if r1.MaxSafeConfig.CompositeScore != r2.MaxSafeConfig.CompositeScore {
|
||||
t.Error("determinism failed: different composite scores")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMT03_ViolationsReferenceObligations(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionImpact: ImpactHigh,
|
||||
DataType: DataSensitive,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
for _, v := range result.OriginalEval.Violations {
|
||||
if v.ObligationID == "" {
|
||||
t.Errorf("violation %s missing obligation reference", v.RuleID)
|
||||
}
|
||||
}
|
||||
for _, tr := range result.OriginalEval.TriggeredRules {
|
||||
if tr.ObligationID == "" {
|
||||
t.Errorf("triggered rule %s missing obligation reference", tr.RuleID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMT05_OptimizerMinimality(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
// Config that only violates one dimension
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
DecisionBinding: BindingHumanReview,
|
||||
DecisionImpact: ImpactLow,
|
||||
Domain: DomainGeneral,
|
||||
DataType: DataSensitive, // only violation: needs consent
|
||||
HumanInLoop: HILRequired,
|
||||
Explainability: ExplainBasic,
|
||||
RiskClassification: RiskMinimal,
|
||||
LegalBasis: LegalLegitimateInterest, // must change to consent
|
||||
TransparencyRequired: false,
|
||||
LoggingRequired: false,
|
||||
ModelType: ModelRuleBased,
|
||||
DeploymentScope: ScopeInternal,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if result.MaxSafeConfig == nil {
|
||||
t.Fatal("expected optimized variant")
|
||||
}
|
||||
|
||||
// Check that only compliance-related dimensions changed
|
||||
for _, d := range result.MaxSafeConfig.Deltas {
|
||||
switch d.Dimension {
|
||||
case "legal_basis", "transparency_required", "logging_required", "data_type":
|
||||
// Expected: legal_basis→consent, transparency, logging for sensitive data
|
||||
// data_type→personal is from optimizer meta-rule (reduce unnecessary sensitivity)
|
||||
default:
|
||||
t.Errorf("unexpected dimension change: %s (%s → %s)", d.Dimension, d.From, d.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizeProducesRankedVariants(t *testing.T) {
|
||||
opt := newTestOptimizer(t)
|
||||
config := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
DecisionImpact: ImpactHigh,
|
||||
Domain: DomainHR,
|
||||
DataType: DataPersonal,
|
||||
HumanInLoop: HILNone,
|
||||
Explainability: ExplainNone,
|
||||
ModelType: ModelBlackboxLLM,
|
||||
DeploymentScope: ScopeExternal,
|
||||
}
|
||||
|
||||
result := opt.Optimize(config)
|
||||
if len(result.Variants) < 2 {
|
||||
t.Skipf("only %d variants generated", len(result.Variants))
|
||||
}
|
||||
|
||||
// Verify descending composite score order
|
||||
for i := 1; i < len(result.Variants); i++ {
|
||||
if result.Variants[i].CompositeScore > result.Variants[i-1].CompositeScore {
|
||||
t.Errorf("variants not sorted: [%d]=%.1f > [%d]=%.1f",
|
||||
i, result.Variants[i].CompositeScore,
|
||||
i-1, result.Variants[i-1].CompositeScore)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package maximizer
|
||||
|
||||
// ScoreWeights controls the balance between safety and business utility.
|
||||
type ScoreWeights struct {
|
||||
Safety float64 `json:"safety"`
|
||||
Utility float64 `json:"utility"`
|
||||
}
|
||||
|
||||
// DefaultWeights prioritizes business utility slightly over safety margin
|
||||
// since the optimizer already ensures compliance.
|
||||
var DefaultWeights = ScoreWeights{Safety: 0.4, Utility: 0.6}
|
||||
|
||||
// dimensionBusinessWeight indicates how much business value each dimension
|
||||
// contributes. Higher = more costly to change for the business.
|
||||
var dimensionBusinessWeight = map[string]int{
|
||||
"automation_level": 15,
|
||||
"decision_binding": 12,
|
||||
"deployment_scope": 10,
|
||||
"model_type": 8,
|
||||
"decision_impact": 7,
|
||||
"explainability": 5,
|
||||
"data_type": 5,
|
||||
"human_in_loop": 5,
|
||||
"legal_basis": 4,
|
||||
"domain": 3,
|
||||
"risk_classification": 3,
|
||||
"transparency_required": 2,
|
||||
"logging_required": 2,
|
||||
}
|
||||
|
||||
// ComputeSafetyScore returns 0-100 where 100 = completely safe (no restrictions).
|
||||
// Decreases with each RESTRICTED or FORBIDDEN zone.
|
||||
func ComputeSafetyScore(eval *EvaluationResult) int {
|
||||
if eval == nil {
|
||||
return 0
|
||||
}
|
||||
total := len(allDimensions)
|
||||
safe := 0
|
||||
for _, zi := range eval.ZoneMap {
|
||||
if zi.Zone == ZoneSafe {
|
||||
safe++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 100
|
||||
}
|
||||
return (safe * 100) / total
|
||||
}
|
||||
|
||||
// ComputeUtilityScore returns 0-100 where 100 = no changes from original.
|
||||
// Decreases based on the business weight of each changed dimension.
|
||||
func ComputeUtilityScore(original, variant *DimensionConfig) int {
|
||||
if original == nil || variant == nil {
|
||||
return 0
|
||||
}
|
||||
deltas := original.Diff(variant)
|
||||
if len(deltas) == 0 {
|
||||
return 100
|
||||
}
|
||||
|
||||
maxCost := 0
|
||||
for _, w := range dimensionBusinessWeight {
|
||||
maxCost += w
|
||||
}
|
||||
|
||||
cost := 0
|
||||
for _, d := range deltas {
|
||||
w := dimensionBusinessWeight[d.Dimension]
|
||||
if w == 0 {
|
||||
w = 3 // default
|
||||
}
|
||||
cost += w
|
||||
}
|
||||
|
||||
if cost >= maxCost {
|
||||
return 0
|
||||
}
|
||||
return 100 - (cost*100)/maxCost
|
||||
}
|
||||
|
||||
// ComputeCompositeScore combines safety and utility into a single ranking score.
|
||||
func ComputeCompositeScore(safety, utility int, weights ScoreWeights) float64 {
|
||||
return weights.Safety*float64(safety) + weights.Utility*float64(utility)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package maximizer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSafetyScoreAllSafe(t *testing.T) {
|
||||
zm := make(map[string]ZoneInfo)
|
||||
for _, dim := range allDimensions {
|
||||
zm[dim] = ZoneInfo{Zone: ZoneSafe}
|
||||
}
|
||||
eval := &EvaluationResult{ZoneMap: zm}
|
||||
score := ComputeSafetyScore(eval)
|
||||
if score != 100 {
|
||||
t.Errorf("expected 100, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyScoreWithRestrictions(t *testing.T) {
|
||||
zm := make(map[string]ZoneInfo)
|
||||
for _, dim := range allDimensions {
|
||||
zm[dim] = ZoneInfo{Zone: ZoneSafe}
|
||||
}
|
||||
// Mark 3 as restricted
|
||||
zm["automation_level"] = ZoneInfo{Zone: ZoneRestricted}
|
||||
zm["human_in_loop"] = ZoneInfo{Zone: ZoneRestricted}
|
||||
zm["logging_required"] = ZoneInfo{Zone: ZoneForbidden}
|
||||
|
||||
eval := &EvaluationResult{ZoneMap: zm}
|
||||
score := ComputeSafetyScore(eval)
|
||||
|
||||
safe := len(allDimensions) - 3
|
||||
expected := (safe * 100) / len(allDimensions)
|
||||
if score != expected {
|
||||
t.Errorf("expected %d, got %d", expected, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyScoreNil(t *testing.T) {
|
||||
if s := ComputeSafetyScore(nil); s != 0 {
|
||||
t.Errorf("expected 0 for nil, got %d", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUtilityScoreNoChanges(t *testing.T) {
|
||||
config := &DimensionConfig{AutomationLevel: AutoFull}
|
||||
score := ComputeUtilityScore(config, config)
|
||||
if score != 100 {
|
||||
t.Errorf("expected 100 for identical configs, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUtilityScoreWithChanges(t *testing.T) {
|
||||
original := &DimensionConfig{
|
||||
AutomationLevel: AutoFull,
|
||||
HumanInLoop: HILNone,
|
||||
}
|
||||
variant := &DimensionConfig{
|
||||
AutomationLevel: AutoAssistive,
|
||||
HumanInLoop: HILRequired,
|
||||
}
|
||||
score := ComputeUtilityScore(original, variant)
|
||||
if score >= 100 {
|
||||
t.Errorf("expected < 100 with changes, got %d", score)
|
||||
}
|
||||
if score <= 0 {
|
||||
t.Errorf("expected > 0 for moderate changes, got %d", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUtilityScoreNil(t *testing.T) {
|
||||
if s := ComputeUtilityScore(nil, nil); s != 0 {
|
||||
t.Errorf("expected 0 for nil, got %d", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeScore(t *testing.T) {
|
||||
score := ComputeCompositeScore(80, 60, DefaultWeights)
|
||||
expected := 0.4*80.0 + 0.6*60.0 // 32 + 36 = 68
|
||||
if score != expected {
|
||||
t.Errorf("expected %.1f, got %.1f", expected, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeScoreCustomWeights(t *testing.T) {
|
||||
score := ComputeCompositeScore(100, 0, ScoreWeights{Safety: 1.0, Utility: 0.0})
|
||||
if score != 100.0 {
|
||||
t.Errorf("expected 100, got %.1f", score)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package maximizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service contains the business logic for the Compliance Maximizer.
|
||||
type Service struct {
|
||||
store *Store
|
||||
evaluator *Evaluator
|
||||
optimizer *Optimizer
|
||||
uccaStore *ucca.Store
|
||||
rules *ConstraintRuleSet
|
||||
}
|
||||
|
||||
// NewService creates a maximizer service.
|
||||
func NewService(store *Store, uccaStore *ucca.Store, rules *ConstraintRuleSet) *Service {
|
||||
eval := NewEvaluator(rules)
|
||||
opt := NewOptimizer(eval)
|
||||
return &Service{
|
||||
store: store,
|
||||
evaluator: eval,
|
||||
optimizer: opt,
|
||||
uccaStore: uccaStore,
|
||||
rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
// OptimizeInput is the request to optimize a dimension config.
|
||||
type OptimizeInput struct {
|
||||
Config DimensionConfig `json:"config"`
|
||||
Title string `json:"title"`
|
||||
TenantID uuid.UUID `json:"-"`
|
||||
UserID uuid.UUID `json:"-"`
|
||||
}
|
||||
|
||||
// OptimizeFromIntakeInput wraps a UCCA intake for optimization.
|
||||
type OptimizeFromIntakeInput struct {
|
||||
Intake ucca.UseCaseIntake `json:"intake"`
|
||||
Title string `json:"title"`
|
||||
TenantID uuid.UUID `json:"-"`
|
||||
UserID uuid.UUID `json:"-"`
|
||||
}
|
||||
|
||||
// Optimize evaluates and optimizes a dimension config.
|
||||
func (s *Service) Optimize(ctx context.Context, in *OptimizeInput) (*Optimization, error) {
|
||||
result := s.optimizer.Optimize(&in.Config)
|
||||
|
||||
o := &Optimization{
|
||||
TenantID: in.TenantID,
|
||||
Title: in.Title,
|
||||
InputConfig: in.Config,
|
||||
IsCompliant: result.OriginalCompliant,
|
||||
OriginalEvaluation: *result.OriginalEval,
|
||||
Variants: result.Variants,
|
||||
ZoneMap: result.OriginalEval.ZoneMap,
|
||||
ConstraintVersion: s.rules.Version,
|
||||
CreatedBy: in.UserID,
|
||||
}
|
||||
if result.MaxSafeConfig != nil {
|
||||
o.MaxSafeConfig = result.MaxSafeConfig
|
||||
}
|
||||
|
||||
if err := s.store.CreateOptimization(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("optimize: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// OptimizeFromIntake maps a UCCA intake to dimensions and optimizes.
|
||||
func (s *Service) OptimizeFromIntake(ctx context.Context, in *OptimizeFromIntakeInput) (*Optimization, error) {
|
||||
config := MapIntakeToDimensions(&in.Intake)
|
||||
return s.Optimize(ctx, &OptimizeInput{
|
||||
Config: *config,
|
||||
Title: in.Title,
|
||||
TenantID: in.TenantID,
|
||||
UserID: in.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// OptimizeFromAssessment loads an existing UCCA assessment and optimizes it.
|
||||
func (s *Service) OptimizeFromAssessment(ctx context.Context, assessmentID, tenantID, userID uuid.UUID) (*Optimization, error) {
|
||||
assessment, err := s.uccaStore.GetAssessment(ctx, assessmentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load assessment %s: %w", assessmentID, err)
|
||||
}
|
||||
config := MapIntakeToDimensions(&assessment.Intake)
|
||||
result := s.optimizer.Optimize(config)
|
||||
|
||||
o := &Optimization{
|
||||
TenantID: tenantID,
|
||||
AssessmentID: &assessmentID,
|
||||
Title: assessment.Title,
|
||||
InputConfig: *config,
|
||||
IsCompliant: result.OriginalCompliant,
|
||||
OriginalEvaluation: *result.OriginalEval,
|
||||
Variants: result.Variants,
|
||||
ZoneMap: result.OriginalEval.ZoneMap,
|
||||
ConstraintVersion: s.rules.Version,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
if result.MaxSafeConfig != nil {
|
||||
o.MaxSafeConfig = result.MaxSafeConfig
|
||||
}
|
||||
|
||||
if err := s.store.CreateOptimization(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("optimize from assessment: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// OptimizeFromIntakeWithProfileInput wraps intake + optional company profile.
|
||||
type OptimizeFromIntakeWithProfileInput struct {
|
||||
Intake ucca.UseCaseIntake `json:"intake"`
|
||||
CompanyProfile *ucca.CompanyProfileInput `json:"company_profile,omitempty"`
|
||||
Title string `json:"title"`
|
||||
TenantID uuid.UUID `json:"-"`
|
||||
UserID uuid.UUID `json:"-"`
|
||||
}
|
||||
|
||||
// OptimizeFromIntakeWithProfile maps intake to dimensions, enriches from profile, and optimizes.
|
||||
func (s *Service) OptimizeFromIntakeWithProfile(ctx context.Context, in *OptimizeFromIntakeWithProfileInput) (*Optimization, error) {
|
||||
config := MapIntakeToDimensions(&in.Intake)
|
||||
if in.CompanyProfile != nil {
|
||||
EnrichDimensionsFromProfile(config, in.CompanyProfile)
|
||||
}
|
||||
return s.Optimize(ctx, &OptimizeInput{
|
||||
Config: *config,
|
||||
Title: in.Title,
|
||||
TenantID: in.TenantID,
|
||||
UserID: in.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// Evaluate only evaluates without persisting (3-zone analysis).
|
||||
func (s *Service) Evaluate(config *DimensionConfig) *EvaluationResult {
|
||||
return s.evaluator.Evaluate(config)
|
||||
}
|
||||
|
||||
// GetOptimization retrieves a stored optimization.
|
||||
func (s *Service) GetOptimization(ctx context.Context, id uuid.UUID) (*Optimization, error) {
|
||||
return s.store.GetOptimization(ctx, id)
|
||||
}
|
||||
|
||||
// ListOptimizations returns optimizations for a tenant.
|
||||
func (s *Service) ListOptimizations(ctx context.Context, tenantID uuid.UUID, f *OptimizationFilters) ([]Optimization, int, error) {
|
||||
return s.store.ListOptimizations(ctx, tenantID, f)
|
||||
}
|
||||
|
||||
// DeleteOptimization removes an optimization.
|
||||
func (s *Service) DeleteOptimization(ctx context.Context, id uuid.UUID) error {
|
||||
return s.store.DeleteOptimization(ctx, id)
|
||||
}
|
||||
|
||||
// GetDimensionSchema returns the dimension schema for the frontend.
|
||||
func (s *Service) GetDimensionSchema() map[string][]string {
|
||||
return AllValues
|
||||
}
|
||||
|
||||
// GetConstraintRules returns the loaded rules for transparency.
|
||||
func (s *Service) GetConstraintRules() *ConstraintRuleSet {
|
||||
return s.rules
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package maximizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Optimization is the DB entity for a maximizer optimization result.
|
||||
type Optimization struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
AssessmentID *uuid.UUID `json:"assessment_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
InputConfig DimensionConfig `json:"input_config"`
|
||||
IsCompliant bool `json:"is_compliant"`
|
||||
OriginalEvaluation EvaluationResult `json:"original_evaluation"`
|
||||
MaxSafeConfig *OptimizedVariant `json:"max_safe_config,omitempty"`
|
||||
Variants []OptimizedVariant `json:"variants"`
|
||||
ZoneMap map[string]ZoneInfo `json:"zone_map"`
|
||||
ConstraintVersion string `json:"constraint_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
// Store handles maximizer data persistence.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewStore creates a new maximizer store.
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// CreateOptimization persists a new optimization result.
|
||||
func (s *Store) CreateOptimization(ctx context.Context, o *Optimization) error {
|
||||
o.ID = uuid.New()
|
||||
o.CreatedAt = time.Now().UTC()
|
||||
o.UpdatedAt = o.CreatedAt
|
||||
if o.Status == "" {
|
||||
o.Status = "completed"
|
||||
}
|
||||
if o.ConstraintVersion == "" {
|
||||
o.ConstraintVersion = "1.0.0"
|
||||
}
|
||||
|
||||
inputConfig, _ := json.Marshal(o.InputConfig)
|
||||
originalEval, _ := json.Marshal(o.OriginalEvaluation)
|
||||
maxSafe, _ := json.Marshal(o.MaxSafeConfig)
|
||||
variants, _ := json.Marshal(o.Variants)
|
||||
zoneMap, _ := json.Marshal(o.ZoneMap)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO maximizer_optimizations (
|
||||
id, tenant_id, assessment_id, title, status,
|
||||
input_config, is_compliant, original_evaluation,
|
||||
max_safe_config, variants, zone_map,
|
||||
constraint_version, created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10, $11,
|
||||
$12, $13, $14, $15
|
||||
)`,
|
||||
o.ID, o.TenantID, o.AssessmentID, o.Title, o.Status,
|
||||
inputConfig, o.IsCompliant, originalEval,
|
||||
maxSafe, variants, zoneMap,
|
||||
o.ConstraintVersion, o.CreatedAt, o.UpdatedAt, o.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create optimization: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOptimization retrieves a single optimization by ID.
|
||||
func (s *Store) GetOptimization(ctx context.Context, id uuid.UUID) (*Optimization, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, assessment_id, title, status,
|
||||
input_config, is_compliant, original_evaluation,
|
||||
max_safe_config, variants, zone_map,
|
||||
constraint_version, created_at, updated_at, created_by
|
||||
FROM maximizer_optimizations WHERE id = $1`, id)
|
||||
return s.scanOptimization(row)
|
||||
}
|
||||
|
||||
// OptimizationFilters for list queries.
|
||||
type OptimizationFilters struct {
|
||||
IsCompliant *bool
|
||||
Search string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ListOptimizations returns optimizations for a tenant.
|
||||
func (s *Store) ListOptimizations(ctx context.Context, tenantID uuid.UUID, f *OptimizationFilters) ([]Optimization, int, error) {
|
||||
if f == nil {
|
||||
f = &OptimizationFilters{}
|
||||
}
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = 20
|
||||
}
|
||||
|
||||
where := "WHERE tenant_id = $1"
|
||||
args := []interface{}{tenantID}
|
||||
idx := 2
|
||||
|
||||
if f.IsCompliant != nil {
|
||||
where += fmt.Sprintf(" AND is_compliant = $%d", idx)
|
||||
args = append(args, *f.IsCompliant)
|
||||
idx++
|
||||
}
|
||||
if f.Search != "" {
|
||||
where += fmt.Sprintf(" AND title ILIKE $%d", idx)
|
||||
args = append(args, "%"+f.Search+"%")
|
||||
idx++
|
||||
}
|
||||
|
||||
// Count
|
||||
var total int
|
||||
countQuery := "SELECT COUNT(*) FROM maximizer_optimizations " + where
|
||||
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count optimizations: %w", err)
|
||||
}
|
||||
|
||||
// Fetch
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, tenant_id, assessment_id, title, status,
|
||||
input_config, is_compliant, original_evaluation,
|
||||
max_safe_config, variants, zone_map,
|
||||
constraint_version, created_at, updated_at, created_by
|
||||
FROM maximizer_optimizations %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $%d OFFSET $%d`, where, idx, idx+1)
|
||||
args = append(args, f.Limit, f.Offset)
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list optimizations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []Optimization
|
||||
for rows.Next() {
|
||||
o, err := s.scanOptimizationRows(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
results = append(results, *o)
|
||||
}
|
||||
return results, total, nil
|
||||
}
|
||||
|
||||
// DeleteOptimization removes an optimization.
|
||||
func (s *Store) DeleteOptimization(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM maximizer_optimizations WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete optimization: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) scanOptimization(row pgx.Row) (*Optimization, error) {
|
||||
var o Optimization
|
||||
var inputConfig, originalEval, maxSafe, variants, zoneMap []byte
|
||||
err := row.Scan(
|
||||
&o.ID, &o.TenantID, &o.AssessmentID, &o.Title, &o.Status,
|
||||
&inputConfig, &o.IsCompliant, &originalEval,
|
||||
&maxSafe, &variants, &zoneMap,
|
||||
&o.ConstraintVersion, &o.CreatedAt, &o.UpdatedAt, &o.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan optimization: %w", err)
|
||||
}
|
||||
json.Unmarshal(inputConfig, &o.InputConfig)
|
||||
json.Unmarshal(originalEval, &o.OriginalEvaluation)
|
||||
json.Unmarshal(maxSafe, &o.MaxSafeConfig)
|
||||
json.Unmarshal(variants, &o.Variants)
|
||||
json.Unmarshal(zoneMap, &o.ZoneMap)
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
func (s *Store) scanOptimizationRows(rows pgx.Rows) (*Optimization, error) {
|
||||
var o Optimization
|
||||
var inputConfig, originalEval, maxSafe, variants, zoneMap []byte
|
||||
err := rows.Scan(
|
||||
&o.ID, &o.TenantID, &o.AssessmentID, &o.Title, &o.Status,
|
||||
&inputConfig, &o.IsCompliant, &originalEval,
|
||||
&maxSafe, &variants, &zoneMap,
|
||||
&o.ConstraintVersion, &o.CreatedAt, &o.UpdatedAt, &o.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan optimization row: %w", err)
|
||||
}
|
||||
json.Unmarshal(inputConfig, &o.InputConfig)
|
||||
json.Unmarshal(originalEval, &o.OriginalEvaluation)
|
||||
json.Unmarshal(maxSafe, &o.MaxSafeConfig)
|
||||
json.Unmarshal(variants, &o.Variants)
|
||||
json.Unmarshal(zoneMap, &o.ZoneMap)
|
||||
return &o, nil
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//go:build betrvg_fields
|
||||
// +build betrvg_fields
|
||||
|
||||
// NOTE: These tests depend on BetrVG-specific fields (EmployeeMonitoring,
|
||||
// HRDecisionSupport, DomainIT) that were not merged into the refactored
|
||||
// UseCaseIntake struct. Skipped until those fields are re-added.
|
||||
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG Conflict Score Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCalculateBetrvgConflictScore_NoEmployeeData(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "Chatbot fuer Kunden-FAQ",
|
||||
Domain: DomainUtilities,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: false,
|
||||
PublicData: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.BetrvgConflictScore != 0 {
|
||||
t.Errorf("Expected BetrvgConflictScore 0 for non-employee case, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=false for non-employee case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_EmployeeMonitoring(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "Teams Analytics mit Nutzungsstatistiken pro Mitarbeiter",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// employee_data(+10) + employee_monitoring(+20) + not_consulted(+5) = 35
|
||||
if result.BetrvgConflictScore < 30 {
|
||||
t.Errorf("Expected BetrvgConflictScore >= 30 for employee monitoring, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if !result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=true for employee monitoring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_HRDecisionSupport(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI-gestuetztes Bewerber-Screening",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
HRDecisionSupport: true,
|
||||
Automation: "fully_automated",
|
||||
Outputs: Outputs{
|
||||
Rankings: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// employee_data(+10) + monitoring(+20) + hr(+20) + rankings(+10) + fully_auto(+10) + not_consulted(+5) = 75
|
||||
if result.BetrvgConflictScore < 70 {
|
||||
t.Errorf("Expected BetrvgConflictScore >= 70 for HR+monitoring+automated, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if !result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_ConsultedReducesScore(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
// Same as above but works council consulted
|
||||
intakeNotConsulted := &UseCaseIntake{
|
||||
UseCaseText: "Teams mit Nutzungsstatistiken",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
WorksCouncilConsulted: false,
|
||||
}
|
||||
|
||||
intakeConsulted := &UseCaseIntake{
|
||||
UseCaseText: "Teams mit Nutzungsstatistiken",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
WorksCouncilConsulted: true,
|
||||
}
|
||||
|
||||
resultNot := engine.Evaluate(intakeNotConsulted)
|
||||
resultYes := engine.Evaluate(intakeConsulted)
|
||||
|
||||
if resultYes.BetrvgConflictScore >= resultNot.BetrvgConflictScore {
|
||||
t.Errorf("Expected consulted score (%d) < not-consulted score (%d)",
|
||||
resultYes.BetrvgConflictScore, resultNot.BetrvgConflictScore)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG Escalation Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEscalation_BetrvgHighConflict_E3(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityCONDITIONAL,
|
||||
RiskLevel: RiskLevelMEDIUM,
|
||||
RiskScore: 45,
|
||||
BetrvgConflictScore: 80,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: false,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{
|
||||
{Code: "R-WARN-001", Severity: "WARN"},
|
||||
},
|
||||
}
|
||||
|
||||
level, reason := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
if level != EscalationLevelE3 {
|
||||
t.Errorf("Expected E3 for high BR conflict without consultation, got %s (reason: %s)", level, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalation_BetrvgMediumConflict_E2(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityCONDITIONAL,
|
||||
RiskLevel: RiskLevelLOW,
|
||||
RiskScore: 25,
|
||||
BetrvgConflictScore: 55,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: false,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{
|
||||
{Code: "R-WARN-001", Severity: "WARN"},
|
||||
},
|
||||
}
|
||||
|
||||
level, reason := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
if level != EscalationLevelE2 {
|
||||
t.Errorf("Expected E2 for medium BR conflict without consultation, got %s (reason: %s)", level, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalation_BetrvgConsulted_NoEscalation(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityYES,
|
||||
RiskLevel: RiskLevelLOW,
|
||||
RiskScore: 15,
|
||||
BetrvgConflictScore: 55,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: true,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{},
|
||||
}
|
||||
|
||||
level, _ := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
// With consultation done and low risk, should not escalate for BR reasons
|
||||
if level == EscalationLevelE3 {
|
||||
t.Error("Should not escalate to E3 when works council is consulted")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG V2 Obligations Loading Test
|
||||
// ============================================================================
|
||||
|
||||
func TestBetrvgV2_LoadsFromManifest(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
v2Dir := filepath.Join(root, "policies", "obligations", "v2")
|
||||
|
||||
// Check file exists
|
||||
betrvgPath := filepath.Join(v2Dir, "betrvg_v2.json")
|
||||
if _, err := os.Stat(betrvgPath); os.IsNotExist(err) {
|
||||
t.Fatal("betrvg_v2.json not found in policies/obligations/v2/")
|
||||
}
|
||||
|
||||
// Load all v2 regulations
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
betrvg, ok := regs["betrvg"]
|
||||
if !ok {
|
||||
t.Fatal("betrvg not found in loaded regulations")
|
||||
}
|
||||
|
||||
if betrvg.Regulation != "betrvg" {
|
||||
t.Errorf("Expected regulation 'betrvg', got '%s'", betrvg.Regulation)
|
||||
}
|
||||
|
||||
if len(betrvg.Obligations) < 10 {
|
||||
t.Errorf("Expected at least 10 BetrVG obligations, got %d", len(betrvg.Obligations))
|
||||
}
|
||||
|
||||
// Check first obligation has correct structure
|
||||
obl := betrvg.Obligations[0]
|
||||
if obl.ID != "BETRVG-OBL-001" {
|
||||
t.Errorf("Expected first obligation ID 'BETRVG-OBL-001', got '%s'", obl.ID)
|
||||
}
|
||||
if len(obl.LegalBasis) == 0 {
|
||||
t.Error("Expected legal basis for first obligation")
|
||||
}
|
||||
if obl.LegalBasis[0].Norm != "BetrVG" {
|
||||
t.Errorf("Expected norm 'BetrVG', got '%s'", obl.LegalBasis[0].Norm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetrvgApplicability_Germany(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
betrvgReg := regs["betrvg"]
|
||||
module := NewJSONRegulationModule(betrvgReg)
|
||||
|
||||
// German company with 50 employees — should be applicable
|
||||
factsDE := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "DE",
|
||||
EmployeeCount: 50,
|
||||
},
|
||||
}
|
||||
if !module.IsApplicable(factsDE) {
|
||||
t.Error("BetrVG should be applicable for German company with 50 employees")
|
||||
}
|
||||
|
||||
// US company — should NOT be applicable
|
||||
factsUS := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "US",
|
||||
EmployeeCount: 50,
|
||||
},
|
||||
}
|
||||
if module.IsApplicable(factsUS) {
|
||||
t.Error("BetrVG should NOT be applicable for US company")
|
||||
}
|
||||
|
||||
// German company with 3 employees — should NOT be applicable (threshold 5)
|
||||
factsSmall := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "DE",
|
||||
EmployeeCount: 3,
|
||||
},
|
||||
}
|
||||
if module.IsApplicable(factsSmall) {
|
||||
t.Error("BetrVG should NOT be applicable for company with < 5 employees")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package ucca
|
||||
|
||||
import "strings"
|
||||
|
||||
// CompanyProfileInput contains the regulatory-relevant subset of a company profile.
|
||||
// Mirrors the Python CompanyProfileRequest schema for the fields that affect assessment.
|
||||
type CompanyProfileInput struct {
|
||||
CompanyName string `json:"company_name,omitempty"`
|
||||
LegalForm string `json:"legal_form,omitempty"`
|
||||
Industry string `json:"industry,omitempty"`
|
||||
EmployeeCount string `json:"employee_count,omitempty"` // "1-9", "10-49", "50-249", "250-999", "1000+"
|
||||
AnnualRevenue string `json:"annual_revenue,omitempty"` // "< 2 Mio", "2-10 Mio", "10-50 Mio", "50+ Mio"
|
||||
HeadquartersCountry string `json:"headquarters_country,omitempty"`
|
||||
IsDataController bool `json:"is_data_controller"`
|
||||
IsDataProcessor bool `json:"is_data_processor"`
|
||||
UsesAI bool `json:"uses_ai"`
|
||||
AIUseCases []string `json:"ai_use_cases,omitempty"`
|
||||
DPOName *string `json:"dpo_name,omitempty"`
|
||||
SubjectToNIS2 bool `json:"subject_to_nis2"`
|
||||
SubjectToAIAct bool `json:"subject_to_ai_act"`
|
||||
SubjectToISO27001 bool `json:"subject_to_iso27001"`
|
||||
}
|
||||
|
||||
// EnrichmentHint tells the frontend which missing company data would improve the assessment.
|
||||
type EnrichmentHint struct {
|
||||
Field string `json:"field"`
|
||||
Label string `json:"label"`
|
||||
Impact string `json:"impact"`
|
||||
Regulation string `json:"regulation"`
|
||||
Priority string `json:"priority"` // "high", "medium", "low"
|
||||
}
|
||||
|
||||
// CompanyContextSummary is a compact view of the company's regulatory position.
|
||||
type CompanyContextSummary struct {
|
||||
SizeCategory string `json:"size_category"`
|
||||
NIS2Applicable bool `json:"nis2_applicable"`
|
||||
DPORequired bool `json:"dpo_required"`
|
||||
Sector string `json:"sector"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// MapCompanyProfileToFacts converts a company profile to UnifiedFacts.
|
||||
func MapCompanyProfileToFacts(profile *CompanyProfileInput) *UnifiedFacts {
|
||||
if profile == nil {
|
||||
return NewUnifiedFacts()
|
||||
}
|
||||
|
||||
facts := NewUnifiedFacts()
|
||||
|
||||
// Organization
|
||||
facts.Organization.Name = profile.CompanyName
|
||||
facts.Organization.LegalForm = profile.LegalForm
|
||||
facts.Organization.EmployeeCount = parseEmployeeRangeGo(profile.EmployeeCount)
|
||||
facts.Organization.AnnualRevenue = parseRevenueRangeGo(profile.AnnualRevenue)
|
||||
if profile.HeadquartersCountry != "" {
|
||||
facts.Organization.Country = profile.HeadquartersCountry
|
||||
}
|
||||
facts.Organization.EUMember = isEUCountry(profile.HeadquartersCountry)
|
||||
facts.Organization.CalculateSizeCategory()
|
||||
|
||||
// Sector
|
||||
if profile.Industry != "" {
|
||||
facts.MapDomainToSector(mapIndustryToDomain(profile.Industry))
|
||||
}
|
||||
|
||||
// Data Protection
|
||||
facts.DataProtection.IsController = profile.IsDataController
|
||||
facts.DataProtection.IsProcessor = profile.IsDataProcessor
|
||||
facts.DataProtection.ProcessesPersonalData = true // assumed for all compliance customers
|
||||
facts.DataProtection.OffersToEU = facts.Organization.EUMember
|
||||
|
||||
// DPO requirement: BDSG §6 — ≥20 employees processing personal data in DE
|
||||
if facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 20 && facts.DataProtection.ProcessesPersonalData {
|
||||
facts.DataProtection.RequiresDSBByLaw = true
|
||||
}
|
||||
|
||||
// Personnel
|
||||
if profile.DPOName != nil && *profile.DPOName != "" {
|
||||
facts.Personnel.HasDPO = true
|
||||
}
|
||||
|
||||
// AI Usage
|
||||
facts.AIUsage.UsesAI = profile.UsesAI
|
||||
if len(profile.AIUseCases) > 0 {
|
||||
facts.AIUsage.IsAIDeployer = true
|
||||
}
|
||||
|
||||
// IT Security
|
||||
facts.ITSecurity.ISO27001Certified = profile.SubjectToISO27001
|
||||
|
||||
// NIS2 flags
|
||||
if profile.SubjectToNIS2 {
|
||||
if facts.Sector.NIS2Classification == "" {
|
||||
facts.Sector.NIS2Classification = "wichtige_einrichtung"
|
||||
}
|
||||
}
|
||||
|
||||
return facts
|
||||
}
|
||||
|
||||
// MergeCompanyFactsIntoIntakeFacts merges company-level facts with use-case-level facts.
|
||||
// Company wins for: Organization, Sector, Personnel, Financial, ITSecurity, SupplyChain.
|
||||
// Intake wins for: DataProtection details, AIUsage details, UCCAFacts.
|
||||
func MergeCompanyFactsIntoIntakeFacts(companyFacts, intakeFacts *UnifiedFacts) *UnifiedFacts {
|
||||
if companyFacts == nil {
|
||||
return intakeFacts
|
||||
}
|
||||
if intakeFacts == nil {
|
||||
return companyFacts
|
||||
}
|
||||
|
||||
merged := NewUnifiedFacts()
|
||||
|
||||
// Company-level fields (from company profile)
|
||||
merged.Organization = companyFacts.Organization
|
||||
merged.Sector = companyFacts.Sector
|
||||
merged.Personnel = companyFacts.Personnel
|
||||
merged.Financial = companyFacts.Financial
|
||||
merged.ITSecurity = companyFacts.ITSecurity
|
||||
merged.SupplyChain = companyFacts.SupplyChain
|
||||
|
||||
// Use-case-level fields (from intake)
|
||||
merged.DataProtection = intakeFacts.DataProtection
|
||||
merged.AIUsage = intakeFacts.AIUsage
|
||||
merged.UCCAFacts = intakeFacts.UCCAFacts
|
||||
|
||||
// Preserve company-level data protection facts that intake doesn't override
|
||||
merged.DataProtection.IsController = companyFacts.DataProtection.IsController
|
||||
merged.DataProtection.IsProcessor = companyFacts.DataProtection.IsProcessor
|
||||
merged.DataProtection.RequiresDSBByLaw = companyFacts.DataProtection.RequiresDSBByLaw
|
||||
merged.DataProtection.OffersToEU = companyFacts.DataProtection.OffersToEU
|
||||
|
||||
// Preserve company AI flags alongside intake AI flags
|
||||
if companyFacts.AIUsage.UsesAI {
|
||||
merged.AIUsage.UsesAI = true
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ComputeEnrichmentHints returns hints for fields that would improve the assessment.
|
||||
func ComputeEnrichmentHints(profile *CompanyProfileInput) []EnrichmentHint {
|
||||
if profile == nil {
|
||||
return allCriticalHints()
|
||||
}
|
||||
|
||||
var hints []EnrichmentHint
|
||||
|
||||
if profile.EmployeeCount == "" {
|
||||
hints = append(hints, EnrichmentHint{
|
||||
Field: "employee_count", Label: "Mitarbeiterzahl",
|
||||
Impact: "NIS2-Schwellenwert (>=50 MA) und BDSG DPO-Pflicht (>=20 MA in DE) koennen nicht geprueft werden",
|
||||
Regulation: "NIS2, BDSG §6", Priority: "high",
|
||||
})
|
||||
}
|
||||
|
||||
if profile.AnnualRevenue == "" {
|
||||
hints = append(hints, EnrichmentHint{
|
||||
Field: "annual_revenue", Label: "Jahresumsatz",
|
||||
Impact: "NIS2-Schwellenwert (>=10 Mio EUR) und KMU-Einstufung nicht pruefbar",
|
||||
Regulation: "NIS2, EU KMU-Definition", Priority: "high",
|
||||
})
|
||||
}
|
||||
|
||||
if profile.Industry == "" {
|
||||
hints = append(hints, EnrichmentHint{
|
||||
Field: "industry", Label: "Branche",
|
||||
Impact: "NIS2 Annex I/II Sektor-Klassifikation und AI Act Hochrisiko-Sektoren nicht bestimmbar",
|
||||
Regulation: "NIS2, AI Act Annex III", Priority: "high",
|
||||
})
|
||||
}
|
||||
|
||||
if profile.HeadquartersCountry == "" {
|
||||
hints = append(hints, EnrichmentHint{
|
||||
Field: "headquarters_country", Label: "Land des Hauptsitzes",
|
||||
Impact: "BDSG-spezifische Regeln (z.B. HR-Daten §26 BDSG) koennen nicht angewandt werden",
|
||||
Regulation: "BDSG", Priority: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
if profile.DPOName == nil || *profile.DPOName == "" {
|
||||
hints = append(hints, EnrichmentHint{
|
||||
Field: "dpo_name", Label: "Datenschutzbeauftragter",
|
||||
Impact: "DPO-Pflicht kann nicht gegen vorhandene Benennung abgeglichen werden",
|
||||
Regulation: "DSGVO Art. 37, BDSG §6", Priority: "medium",
|
||||
})
|
||||
}
|
||||
|
||||
return hints
|
||||
}
|
||||
|
||||
// BuildCompanyContext creates a summary of the company's regulatory position.
|
||||
func BuildCompanyContext(profile *CompanyProfileInput) *CompanyContextSummary {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
facts := MapCompanyProfileToFacts(profile)
|
||||
return &CompanyContextSummary{
|
||||
SizeCategory: facts.Organization.SizeCategory,
|
||||
NIS2Applicable: facts.Organization.MeetsNIS2SizeThreshold() || profile.SubjectToNIS2,
|
||||
DPORequired: facts.DataProtection.RequiresDSBByLaw,
|
||||
Sector: facts.Sector.PrimarySector,
|
||||
Country: facts.Organization.Country,
|
||||
}
|
||||
}
|
||||
|
||||
func allCriticalHints() []EnrichmentHint {
|
||||
return []EnrichmentHint{
|
||||
{Field: "employee_count", Label: "Mitarbeiterzahl", Impact: "NIS2/BDSG DPO nicht pruefbar", Regulation: "NIS2, BDSG", Priority: "high"},
|
||||
{Field: "annual_revenue", Label: "Jahresumsatz", Impact: "NIS2/KMU nicht pruefbar", Regulation: "NIS2", Priority: "high"},
|
||||
{Field: "industry", Label: "Branche", Impact: "Sektor-Klassifikation nicht moeglich", Regulation: "NIS2, AI Act", Priority: "high"},
|
||||
{Field: "headquarters_country", Label: "Land", Impact: "BDSG-Regeln nicht anwendbar", Regulation: "BDSG", Priority: "medium"},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Parsers (matching TypeScript parseEmployeeRange/parseRevenueRange) ---
|
||||
|
||||
func parseEmployeeRangeGo(r string) int {
|
||||
switch r {
|
||||
case "1-9":
|
||||
return 5
|
||||
case "10-49":
|
||||
return 30
|
||||
case "50-249":
|
||||
return 150
|
||||
case "250-999":
|
||||
return 625
|
||||
case "1000+":
|
||||
return 1500
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseRevenueRangeGo(r string) float64 {
|
||||
switch r {
|
||||
case "< 2 Mio":
|
||||
return 1_000_000
|
||||
case "2-10 Mio":
|
||||
return 6_000_000
|
||||
case "10-50 Mio":
|
||||
return 30_000_000
|
||||
case "50+ Mio":
|
||||
return 75_000_000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func isEUCountry(code string) bool {
|
||||
eu := map[string]bool{
|
||||
"DE": true, "AT": true, "FR": true, "IT": true, "ES": true, "NL": true,
|
||||
"BE": true, "LU": true, "IE": true, "PT": true, "GR": true, "FI": true,
|
||||
"SE": true, "DK": true, "PL": true, "CZ": true, "SK": true, "HU": true,
|
||||
"RO": true, "BG": true, "HR": true, "SI": true, "LT": true, "LV": true,
|
||||
"EE": true, "CY": true, "MT": true,
|
||||
}
|
||||
return eu[strings.ToUpper(code)]
|
||||
}
|
||||
|
||||
func mapIndustryToDomain(industry string) string {
|
||||
lower := strings.ToLower(industry)
|
||||
switch {
|
||||
case strings.Contains(lower, "gesundheit") || strings.Contains(lower, "health") || strings.Contains(lower, "pharma"):
|
||||
return "healthcare"
|
||||
case strings.Contains(lower, "finanz") || strings.Contains(lower, "bank") || strings.Contains(lower, "versicherung"):
|
||||
return "finance"
|
||||
case strings.Contains(lower, "bildung") || strings.Contains(lower, "schule") || strings.Contains(lower, "universit"):
|
||||
return "education"
|
||||
case strings.Contains(lower, "energie") || strings.Contains(lower, "energy"):
|
||||
return "energy"
|
||||
case strings.Contains(lower, "logistik") || strings.Contains(lower, "transport"):
|
||||
return "logistics"
|
||||
case strings.Contains(lower, "it") || strings.Contains(lower, "software") || strings.Contains(lower, "tech"):
|
||||
return "it_services"
|
||||
default:
|
||||
return lower
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package ucca
|
||||
|
||||
import "testing"
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func TestMapCompanyProfileToFacts_FullProfile(t *testing.T) {
|
||||
profile := &CompanyProfileInput{
|
||||
CompanyName: "Test GmbH",
|
||||
LegalForm: "GmbH",
|
||||
Industry: "Gesundheitswesen",
|
||||
EmployeeCount: "50-249",
|
||||
AnnualRevenue: "10-50 Mio",
|
||||
HeadquartersCountry: "DE",
|
||||
IsDataController: true,
|
||||
IsDataProcessor: false,
|
||||
UsesAI: true,
|
||||
AIUseCases: []string{"Diagnostik"},
|
||||
DPOName: strPtr("Dr. Datenschutz"),
|
||||
SubjectToNIS2: true,
|
||||
SubjectToAIAct: true,
|
||||
}
|
||||
|
||||
facts := MapCompanyProfileToFacts(profile)
|
||||
|
||||
if facts.Organization.Name != "Test GmbH" {
|
||||
t.Errorf("Name: got %q", facts.Organization.Name)
|
||||
}
|
||||
if facts.Organization.EmployeeCount != 150 {
|
||||
t.Errorf("EmployeeCount: got %d, want 150", facts.Organization.EmployeeCount)
|
||||
}
|
||||
if facts.Organization.AnnualRevenue != 30_000_000 {
|
||||
t.Errorf("AnnualRevenue: got %f", facts.Organization.AnnualRevenue)
|
||||
}
|
||||
if facts.Organization.Country != "DE" {
|
||||
t.Errorf("Country: got %q", facts.Organization.Country)
|
||||
}
|
||||
if !facts.Organization.EUMember {
|
||||
t.Error("expected EUMember=true for DE")
|
||||
}
|
||||
if facts.Sector.PrimarySector != "health" && facts.Sector.PrimarySector != "healthcare" {
|
||||
t.Errorf("Sector: got %q, want health or healthcare", facts.Sector.PrimarySector)
|
||||
}
|
||||
if !facts.DataProtection.IsController {
|
||||
t.Error("expected IsController=true")
|
||||
}
|
||||
if !facts.Personnel.HasDPO {
|
||||
t.Error("expected HasDPO=true")
|
||||
}
|
||||
if !facts.AIUsage.UsesAI {
|
||||
t.Error("expected UsesAI=true")
|
||||
}
|
||||
if !facts.DataProtection.RequiresDSBByLaw {
|
||||
t.Error("expected DPO requirement for DE with 150 employees")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapCompanyProfileToFacts_NilProfile(t *testing.T) {
|
||||
facts := MapCompanyProfileToFacts(nil)
|
||||
if facts == nil {
|
||||
t.Fatal("expected non-nil UnifiedFacts for nil profile")
|
||||
}
|
||||
if facts.Organization.Country != "DE" {
|
||||
t.Errorf("expected default country DE, got %q", facts.Organization.Country)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmployeeRangeGo(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected int
|
||||
}{
|
||||
{"1-9", 5},
|
||||
{"10-49", 30},
|
||||
{"50-249", 150},
|
||||
{"250-999", 625},
|
||||
{"1000+", 1500},
|
||||
{"", 0},
|
||||
{"unknown", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := parseEmployeeRangeGo(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("parseEmployeeRangeGo(%q) = %d, want %d", tc.input, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRevenueRangeGo(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected float64
|
||||
}{
|
||||
{"< 2 Mio", 1_000_000},
|
||||
{"2-10 Mio", 6_000_000},
|
||||
{"10-50 Mio", 30_000_000},
|
||||
{"50+ Mio", 75_000_000},
|
||||
{"", 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := parseRevenueRangeGo(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("parseRevenueRangeGo(%q) = %f, want %f", tc.input, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCompanyFactsIntoIntakeFacts(t *testing.T) {
|
||||
company := NewUnifiedFacts()
|
||||
company.Organization.Name = "ACME"
|
||||
company.Organization.EmployeeCount = 200
|
||||
company.Organization.Country = "DE"
|
||||
company.DataProtection.IsController = true
|
||||
company.Sector.PrimarySector = "health"
|
||||
|
||||
intake := NewUnifiedFacts()
|
||||
intake.DataProtection.ProcessesPersonalData = true
|
||||
intake.DataProtection.Profiling = true
|
||||
intake.AIUsage.UsesAI = true
|
||||
intake.UCCAFacts = &UseCaseIntake{Domain: "hr"}
|
||||
|
||||
merged := MergeCompanyFactsIntoIntakeFacts(company, intake)
|
||||
|
||||
// Company-level fields should come from company
|
||||
if merged.Organization.Name != "ACME" {
|
||||
t.Errorf("expected company Name, got %q", merged.Organization.Name)
|
||||
}
|
||||
if merged.Organization.EmployeeCount != 200 {
|
||||
t.Errorf("expected company EmployeeCount=200, got %d", merged.Organization.EmployeeCount)
|
||||
}
|
||||
if merged.Sector.PrimarySector != "health" {
|
||||
t.Errorf("expected company sector=health, got %q", merged.Sector.PrimarySector)
|
||||
}
|
||||
|
||||
// Use-case-level fields should come from intake
|
||||
if !merged.DataProtection.Profiling {
|
||||
t.Error("expected intake Profiling=true")
|
||||
}
|
||||
if !merged.AIUsage.UsesAI {
|
||||
t.Error("expected intake UsesAI=true")
|
||||
}
|
||||
if merged.UCCAFacts == nil || merged.UCCAFacts.Domain != "hr" {
|
||||
t.Error("expected intake UCCAFacts preserved")
|
||||
}
|
||||
|
||||
// Company-level data protection preserved
|
||||
if !merged.DataProtection.IsController {
|
||||
t.Error("expected company IsController=true in merged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeWithNilCompanyFacts(t *testing.T) {
|
||||
intake := NewUnifiedFacts()
|
||||
intake.AIUsage.UsesAI = true
|
||||
|
||||
merged := MergeCompanyFactsIntoIntakeFacts(nil, intake)
|
||||
if !merged.AIUsage.UsesAI {
|
||||
t.Error("expected intake-only merge to preserve AIUsage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNIS2ThresholdTriggered(t *testing.T) {
|
||||
profile := &CompanyProfileInput{
|
||||
EmployeeCount: "50-249",
|
||||
AnnualRevenue: "10-50 Mio",
|
||||
HeadquartersCountry: "DE",
|
||||
Industry: "Energie",
|
||||
}
|
||||
|
||||
facts := MapCompanyProfileToFacts(profile)
|
||||
if !facts.Organization.MeetsNIS2SizeThreshold() {
|
||||
t.Error("expected NIS2 size threshold met for 150 employees")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBDSG_DPOTriggered(t *testing.T) {
|
||||
profile := &CompanyProfileInput{
|
||||
EmployeeCount: "10-49",
|
||||
HeadquartersCountry: "DE",
|
||||
IsDataController: true,
|
||||
}
|
||||
|
||||
facts := MapCompanyProfileToFacts(profile)
|
||||
// 30 employees in DE processing personal data → DPO required
|
||||
if !facts.DataProtection.RequiresDSBByLaw {
|
||||
t.Error("expected BDSG DPO requirement for 30 employees in DE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBDSG_DPONotTriggeredSmallCompany(t *testing.T) {
|
||||
profile := &CompanyProfileInput{
|
||||
EmployeeCount: "1-9",
|
||||
HeadquartersCountry: "DE",
|
||||
IsDataController: true,
|
||||
}
|
||||
|
||||
facts := MapCompanyProfileToFacts(profile)
|
||||
// 5 employees → DPO NOT required
|
||||
if facts.DataProtection.RequiresDSBByLaw {
|
||||
t.Error("expected no DPO requirement for 5 employees")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEnrichmentHints_AllMissing(t *testing.T) {
|
||||
profile := &CompanyProfileInput{}
|
||||
hints := ComputeEnrichmentHints(profile)
|
||||
if len(hints) < 4 {
|
||||
t.Errorf("expected at least 4 hints for empty profile, got %d", len(hints))
|
||||
}
|
||||
// Check high priority hints
|
||||
highCount := 0
|
||||
for _, h := range hints {
|
||||
if h.Priority == "high" {
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
if highCount < 3 {
|
||||
t.Errorf("expected at least 3 high-priority hints, got %d", highCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEnrichmentHints_Complete(t *testing.T) {
|
||||
profile := &CompanyProfileInput{
|
||||
EmployeeCount: "50-249",
|
||||
AnnualRevenue: "10-50 Mio",
|
||||
Industry: "IT",
|
||||
HeadquartersCountry: "DE",
|
||||
DPOName: strPtr("Max Mustermann"),
|
||||
}
|
||||
hints := ComputeEnrichmentHints(profile)
|
||||
if len(hints) != 0 {
|
||||
t.Errorf("expected 0 hints for complete profile, got %d: %+v", len(hints), hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEnrichmentHints_NilProfile(t *testing.T) {
|
||||
hints := ComputeEnrichmentHints(nil)
|
||||
if len(hints) < 4 {
|
||||
t.Errorf("expected all critical hints for nil profile, got %d", len(hints))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEUCountry(t *testing.T) {
|
||||
if !isEUCountry("DE") {
|
||||
t.Error("DE should be EU")
|
||||
}
|
||||
if !isEUCountry("at") {
|
||||
t.Error("AT should be EU (case insensitive)")
|
||||
}
|
||||
if isEUCountry("US") {
|
||||
t.Error("US should not be EU")
|
||||
}
|
||||
if isEUCountry("CH") {
|
||||
t.Error("CH should not be EU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompanyContext(t *testing.T) {
|
||||
profile := &CompanyProfileInput{
|
||||
EmployeeCount: "250-999",
|
||||
AnnualRevenue: "50+ Mio",
|
||||
HeadquartersCountry: "DE",
|
||||
Industry: "Finanzdienstleistungen",
|
||||
SubjectToNIS2: true,
|
||||
}
|
||||
|
||||
ctx := BuildCompanyContext(profile)
|
||||
if ctx == nil {
|
||||
t.Fatal("expected non-nil context")
|
||||
}
|
||||
if ctx.Country != "DE" {
|
||||
t.Errorf("Country: got %q", ctx.Country)
|
||||
}
|
||||
if !ctx.NIS2Applicable {
|
||||
t.Error("expected NIS2 applicable")
|
||||
}
|
||||
if !ctx.DPORequired {
|
||||
t.Error("expected DPO required for large DE company")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package ucca
|
||||
|
||||
// ============================================================================
|
||||
// AI Act Decision Tree Engine
|
||||
// ============================================================================
|
||||
//
|
||||
// Two-axis classification:
|
||||
// Axis 1 (Q1–Q7): High-Risk classification based on Annex III
|
||||
// Axis 2 (Q8–Q12): GPAI classification based on Art. 51–56
|
||||
//
|
||||
// Deterministic evaluation — no LLM involved.
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
// Question IDs
|
||||
const (
|
||||
Q1 = "Q1" // Uses AI?
|
||||
Q2 = "Q2" // Biometric identification?
|
||||
Q3 = "Q3" // Critical infrastructure?
|
||||
Q4 = "Q4" // Education / employment / HR?
|
||||
Q5 = "Q5" // Essential services (credit, insurance)?
|
||||
Q6 = "Q6" // Law enforcement / migration / justice?
|
||||
Q7 = "Q7" // Autonomous decisions with legal effect?
|
||||
Q8 = "Q8" // Foundation Model / GPAI?
|
||||
Q9 = "Q9" // Generates content (text, image, code, audio)?
|
||||
Q10 = "Q10" // Trained with >10^25 FLOP?
|
||||
Q11 = "Q11" // Model provided as API/service for third parties?
|
||||
Q12 = "Q12" // Significant EU market penetration?
|
||||
)
|
||||
|
||||
// BuildDecisionTreeDefinition returns the full decision tree structure for the frontend
|
||||
func BuildDecisionTreeDefinition() *DecisionTreeDefinition {
|
||||
return &DecisionTreeDefinition{
|
||||
ID: "ai_act_two_axis",
|
||||
Name: "AI Act Zwei-Achsen-Klassifikation",
|
||||
Version: "1.0.0",
|
||||
Questions: []DecisionTreeQuestion{
|
||||
// === Axis 1: High-Risk (Annex III) ===
|
||||
{
|
||||
ID: Q1,
|
||||
Axis: "high_risk",
|
||||
Question: "Setzt Ihr System KI-Technologie ein?",
|
||||
Description: "KI im Sinne des AI Act umfasst maschinelles Lernen, logik- und wissensbasierte Ansätze sowie statistische Methoden, die für eine gegebene Reihe von Zielen Ergebnisse wie Inhalte, Vorhersagen, Empfehlungen oder Entscheidungen erzeugen.",
|
||||
ArticleRef: "Art. 3 Nr. 1",
|
||||
},
|
||||
{
|
||||
ID: Q2,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System für biometrische Identifikation oder Kategorisierung natürlicher Personen verwendet?",
|
||||
Description: "Dazu zählen Gesichtserkennung, Stimmerkennung, Fingerabdruck-Analyse, Gangerkennung oder andere biometrische Merkmale zur Identifikation oder Kategorisierung.",
|
||||
ArticleRef: "Anhang III Nr. 1",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q3,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System in kritischer Infrastruktur eingesetzt (Energie, Verkehr, Wasser, digitale Infrastruktur)?",
|
||||
Description: "Betrifft KI-Systeme als Sicherheitskomponenten in der Verwaltung und dem Betrieb kritischer digitaler Infrastruktur, des Straßenverkehrs oder der Wasser-, Gas-, Heizungs- oder Stromversorgung.",
|
||||
ArticleRef: "Anhang III Nr. 2",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q4,
|
||||
Axis: "high_risk",
|
||||
Question: "Betrifft das System Bildung, Beschäftigung oder Personalmanagement?",
|
||||
Description: "KI zur Festlegung des Zugangs zu Bildungseinrichtungen, Bewertung von Prüfungsleistungen, Bewerbungsauswahl, Beförderungsentscheidungen oder Überwachung von Arbeitnehmern.",
|
||||
ArticleRef: "Anhang III Nr. 3–4",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q5,
|
||||
Axis: "high_risk",
|
||||
Question: "Betrifft das System den Zugang zu wesentlichen Diensten (Kreditvergabe, Versicherung, öffentliche Leistungen)?",
|
||||
Description: "KI zur Bonitätsbewertung, Risikobewertung bei Versicherungen, Bewertung der Anspruchsberechtigung für öffentliche Unterstützungsleistungen oder Notdienste.",
|
||||
ArticleRef: "Anhang III Nr. 5",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q6,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System in Strafverfolgung, Migration, Asyl oder Justiz eingesetzt?",
|
||||
Description: "KI für Lügendetektoren, Beweisbewertung, Rückfallprognose, Asylentscheidungen, Grenzkontrolle, Risikobewertung bei Migration oder Unterstützung der Rechtspflege.",
|
||||
ArticleRef: "Anhang III Nr. 6–8",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q7,
|
||||
Axis: "high_risk",
|
||||
Question: "Trifft das System autonome Entscheidungen mit rechtlicher Wirkung für natürliche Personen?",
|
||||
Description: "Entscheidungen, die Rechtsverhältnisse begründen, ändern oder aufheben, z.B. Kreditablehnungen, Kündigungen, Sozialleistungsentscheidungen — ohne menschliche Überprüfung im Einzelfall.",
|
||||
ArticleRef: "Art. 22 DSGVO / Art. 14 AI Act",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
|
||||
// === Axis 2: GPAI (Art. 51–56) ===
|
||||
{
|
||||
ID: Q8,
|
||||
Axis: "gpai",
|
||||
Question: "Stellst du ein KI-Modell fuer Dritte bereit (API / Plattform / SDK), das fuer viele verschiedene Zwecke einsetzbar ist?",
|
||||
Description: "GPAI-Pflichten (Art. 51-56) gelten fuer den Modellanbieter, nicht den API-Nutzer. Wenn du nur eine API nutzt (z.B. OpenAI, Claude), bist du kein GPAI-Anbieter. GPAI-Anbieter ist, wer ein Modell trainiert/fine-tuned und Dritten zur Verfuegung stellt. Beispiele: GPT, Claude, LLaMA, Gemini, Stable Diffusion.",
|
||||
ArticleRef: "Art. 3 Nr. 63 / Art. 51",
|
||||
},
|
||||
{
|
||||
ID: Q9,
|
||||
Axis: "gpai",
|
||||
Question: "Kann das System Inhalte generieren (Text, Bild, Code, Audio, Video)?",
|
||||
Description: "Generative KI erzeugt neue Inhalte auf Basis von Eingaben — dazu zählen Chatbots, Bild-/Videogeneratoren, Code-Assistenten, Sprachsynthese und ähnliche Systeme.",
|
||||
ArticleRef: "Art. 50 / Art. 52",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q10,
|
||||
Axis: "gpai",
|
||||
Question: "Wurde das Modell mit mehr als 10²⁵ FLOP trainiert oder hat es gleichwertige Fähigkeiten?",
|
||||
Description: "GPAI-Modelle mit einem kumulativen Rechenaufwand von mehr als 10²⁵ Gleitkommaoperationen gelten als Modelle mit systemischem Risiko (Art. 51 Abs. 2).",
|
||||
ArticleRef: "Art. 51 Abs. 2",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q11,
|
||||
Axis: "gpai",
|
||||
Question: "Wird das Modell als API oder Service für Dritte bereitgestellt?",
|
||||
Description: "Stellen Sie das Modell anderen Unternehmen oder Entwicklern zur Nutzung bereit (API, SaaS, Plattform-Integration)?",
|
||||
ArticleRef: "Art. 53",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q12,
|
||||
Axis: "gpai",
|
||||
Question: "Hat das Modell eine signifikante Marktdurchdringung in der EU (>10.000 registrierte Geschäftsnutzer)?",
|
||||
Description: "Modelle mit hoher Marktdurchdringung können auch ohne 10²⁵ FLOP als systemisches Risiko eingestuft werden, wenn die EU-Kommission dies feststellt.",
|
||||
ArticleRef: "Art. 51 Abs. 3",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateDecisionTree evaluates the answers and returns the combined result
|
||||
func EvaluateDecisionTree(req *DecisionTreeEvalRequest) *DecisionTreeResult {
|
||||
result := &DecisionTreeResult{
|
||||
SystemName: req.SystemName,
|
||||
SystemDescription: req.SystemDescription,
|
||||
Answers: req.Answers,
|
||||
}
|
||||
|
||||
// Evaluate Axis 1: High-Risk
|
||||
result.HighRiskResult = evaluateHighRiskAxis(req.Answers)
|
||||
|
||||
// Evaluate Axis 2: GPAI
|
||||
result.GPAIResult = evaluateGPAIAxis(req.Answers)
|
||||
|
||||
// Combine obligations and articles
|
||||
result.CombinedObligations = combineObligations(result.HighRiskResult, result.GPAIResult)
|
||||
result.ApplicableArticles = combineArticles(result.HighRiskResult, result.GPAIResult)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// evaluateHighRiskAxis determines the AI Act risk level from Q1–Q7
|
||||
func evaluateHighRiskAxis(answers map[string]DecisionTreeAnswer) AIActRiskLevel {
|
||||
// Q1: Uses AI at all?
|
||||
if !answerIsYes(answers, Q1) {
|
||||
return AIActNotApplicable
|
||||
}
|
||||
|
||||
// Q2–Q6: Annex III high-risk categories
|
||||
if answerIsYes(answers, Q2) || answerIsYes(answers, Q3) ||
|
||||
answerIsYes(answers, Q4) || answerIsYes(answers, Q5) ||
|
||||
answerIsYes(answers, Q6) {
|
||||
return AIActHighRisk
|
||||
}
|
||||
|
||||
// Q7: Autonomous decisions with legal effect
|
||||
if answerIsYes(answers, Q7) {
|
||||
return AIActHighRisk
|
||||
}
|
||||
|
||||
// AI is used but no high-risk category triggered
|
||||
return AIActMinimalRisk
|
||||
}
|
||||
|
||||
// evaluateGPAIAxis determines the GPAI classification from Q8–Q12
|
||||
func evaluateGPAIAxis(answers map[string]DecisionTreeAnswer) GPAIClassification {
|
||||
gpai := GPAIClassification{
|
||||
Category: GPAICategoryNone,
|
||||
ApplicableArticles: []string{},
|
||||
Obligations: []string{},
|
||||
}
|
||||
|
||||
// Q8: Is GPAI?
|
||||
if !answerIsYes(answers, Q8) {
|
||||
return gpai
|
||||
}
|
||||
|
||||
gpai.IsGPAI = true
|
||||
gpai.Category = GPAICategoryStandard
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 51", "Art. 53")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Technische Dokumentation erstellen (Art. 53 Abs. 1a)",
|
||||
"Informationen für nachgelagerte Anbieter bereitstellen (Art. 53 Abs. 1b)",
|
||||
"Urheberrechtsrichtlinie einhalten (Art. 53 Abs. 1c)",
|
||||
"Trainingsdaten-Zusammenfassung veröffentlichen (Art. 53 Abs. 1d)",
|
||||
)
|
||||
|
||||
// Q9: Generative AI — adds transparency obligations
|
||||
if answerIsYes(answers, Q9) {
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 50")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"KI-generierte Inhalte kennzeichnen (Art. 50 Abs. 2)",
|
||||
"Maschinenlesbare Kennzeichnung synthetischer Inhalte (Art. 50 Abs. 2)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q10: Systemic risk threshold (>10^25 FLOP)
|
||||
if answerIsYes(answers, Q10) {
|
||||
gpai.IsSystemicRisk = true
|
||||
gpai.Category = GPAICategorySystemic
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 55")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Modellbewertung nach Stand der Technik durchführen (Art. 55 Abs. 1a)",
|
||||
"Systemische Risiken bewerten und mindern (Art. 55 Abs. 1b)",
|
||||
"Schwerwiegende Vorfälle melden (Art. 55 Abs. 1c)",
|
||||
"Angemessenes Cybersicherheitsniveau gewährleisten (Art. 55 Abs. 1d)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q11: API/Service provider — additional downstream obligations
|
||||
if answerIsYes(answers, Q11) {
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Downstream-Informationspflichten erfüllen (Art. 53 Abs. 1b)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q12: Significant market penetration — potential systemic risk
|
||||
if answerIsYes(answers, Q12) && !gpai.IsSystemicRisk {
|
||||
// EU Commission can designate as systemic risk
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 51 Abs. 3")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Achtung: EU-Kommission kann GPAI mit hoher Marktdurchdringung als systemisches Risiko einstufen (Art. 51 Abs. 3)",
|
||||
)
|
||||
}
|
||||
|
||||
return gpai
|
||||
}
|
||||
|
||||
// combineObligations merges obligations from both axes
|
||||
func combineObligations(highRisk AIActRiskLevel, gpai GPAIClassification) []string {
|
||||
var obligations []string
|
||||
|
||||
// High-Risk obligations
|
||||
switch highRisk {
|
||||
case AIActHighRisk:
|
||||
obligations = append(obligations,
|
||||
"Risikomanagementsystem einrichten (Art. 9)",
|
||||
"Daten-Governance sicherstellen (Art. 10)",
|
||||
"Technische Dokumentation erstellen (Art. 11)",
|
||||
"Protokollierungsfunktion implementieren (Art. 12)",
|
||||
"Transparenz und Nutzerinformation (Art. 13)",
|
||||
"Menschliche Aufsicht ermöglichen (Art. 14)",
|
||||
"Genauigkeit, Robustheit und Cybersicherheit (Art. 15)",
|
||||
"EU-Datenbank-Registrierung (Art. 49)",
|
||||
)
|
||||
case AIActMinimalRisk:
|
||||
obligations = append(obligations,
|
||||
"Freiwillige Verhaltenskodizes empfohlen (Art. 95)",
|
||||
)
|
||||
case AIActNotApplicable:
|
||||
// No obligations
|
||||
}
|
||||
|
||||
// GPAI obligations
|
||||
obligations = append(obligations, gpai.Obligations...)
|
||||
|
||||
// Universal obligation for all AI users
|
||||
if highRisk != AIActNotApplicable {
|
||||
obligations = append(obligations,
|
||||
"KI-Kompetenz sicherstellen (Art. 4)",
|
||||
"Verbotene Praktiken vermeiden (Art. 5)",
|
||||
)
|
||||
}
|
||||
|
||||
return obligations
|
||||
}
|
||||
|
||||
// combineArticles merges applicable articles from both axes
|
||||
func combineArticles(highRisk AIActRiskLevel, gpai GPAIClassification) []string {
|
||||
articles := map[string]bool{}
|
||||
|
||||
// Universal
|
||||
if highRisk != AIActNotApplicable {
|
||||
articles["Art. 4"] = true
|
||||
articles["Art. 5"] = true
|
||||
}
|
||||
|
||||
// High-Risk
|
||||
switch highRisk {
|
||||
case AIActHighRisk:
|
||||
for _, a := range []string{"Art. 9", "Art. 10", "Art. 11", "Art. 12", "Art. 13", "Art. 14", "Art. 15", "Art. 26", "Art. 49"} {
|
||||
articles[a] = true
|
||||
}
|
||||
case AIActMinimalRisk:
|
||||
articles["Art. 95"] = true
|
||||
}
|
||||
|
||||
// GPAI
|
||||
for _, a := range gpai.ApplicableArticles {
|
||||
articles[a] = true
|
||||
}
|
||||
|
||||
var result []string
|
||||
for a := range articles {
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// answerIsYes checks if a question was answered with "yes" (true)
|
||||
func answerIsYes(answers map[string]DecisionTreeAnswer, questionID string) bool {
|
||||
a, ok := answers[questionID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return a.Value
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDecisionTreeDefinition_ReturnsValidTree(t *testing.T) {
|
||||
tree := BuildDecisionTreeDefinition()
|
||||
|
||||
if tree == nil {
|
||||
t.Fatal("Expected non-nil tree definition")
|
||||
}
|
||||
if tree.ID != "ai_act_two_axis" {
|
||||
t.Errorf("Expected ID 'ai_act_two_axis', got '%s'", tree.ID)
|
||||
}
|
||||
if tree.Version != "1.0.0" {
|
||||
t.Errorf("Expected version '1.0.0', got '%s'", tree.Version)
|
||||
}
|
||||
if len(tree.Questions) != 12 {
|
||||
t.Errorf("Expected 12 questions, got %d", len(tree.Questions))
|
||||
}
|
||||
|
||||
// Check axis distribution
|
||||
hrCount := 0
|
||||
gpaiCount := 0
|
||||
for _, q := range tree.Questions {
|
||||
switch q.Axis {
|
||||
case "high_risk":
|
||||
hrCount++
|
||||
case "gpai":
|
||||
gpaiCount++
|
||||
default:
|
||||
t.Errorf("Unexpected axis '%s' for question %s", q.Axis, q.ID)
|
||||
}
|
||||
}
|
||||
if hrCount != 7 {
|
||||
t.Errorf("Expected 7 high_risk questions, got %d", hrCount)
|
||||
}
|
||||
if gpaiCount != 5 {
|
||||
t.Errorf("Expected 5 gpai questions, got %d", gpaiCount)
|
||||
}
|
||||
|
||||
// Check all questions have required fields
|
||||
for _, q := range tree.Questions {
|
||||
if q.ID == "" {
|
||||
t.Error("Question has empty ID")
|
||||
}
|
||||
if q.Question == "" {
|
||||
t.Errorf("Question %s has empty question text", q.ID)
|
||||
}
|
||||
if q.Description == "" {
|
||||
t.Errorf("Question %s has empty description", q.ID)
|
||||
}
|
||||
if q.ArticleRef == "" {
|
||||
t.Errorf("Question %s has empty article_ref", q.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_NotApplicable(t *testing.T) {
|
||||
// Q1=No → AI Act not applicable
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Test System",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActNotApplicable {
|
||||
t.Errorf("Expected not_applicable, got %s", result.HighRiskResult)
|
||||
}
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be false when Q8 is not answered")
|
||||
}
|
||||
if result.SystemName != "Test System" {
|
||||
t.Errorf("Expected system name 'Test System', got '%s'", result.SystemName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_MinimalRisk(t *testing.T) {
|
||||
// Q1=Yes, Q2-Q7=No → minimal risk
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Simple Tool",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
Q8: {QuestionID: Q8, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActMinimalRisk {
|
||||
t.Errorf("Expected minimal_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be false")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryNone {
|
||||
t.Errorf("Expected GPAI category 'none', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_Biometric(t *testing.T) {
|
||||
// Q1=Yes, Q2=Yes → high risk (biometric)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Face Recognition",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: true},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
|
||||
// Should have high-risk obligations
|
||||
if len(result.CombinedObligations) == 0 {
|
||||
t.Error("Expected non-empty obligations for high-risk system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_CriticalInfrastructure(t *testing.T) {
|
||||
// Q1=Yes, Q3=Yes → high risk (critical infrastructure)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Energy Grid AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: true},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_Education(t *testing.T) {
|
||||
// Q1=Yes, Q4=Yes → high risk (education/employment)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Exam Grading AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_AutonomousDecisions(t *testing.T) {
|
||||
// Q1=Yes, Q7=Yes → high risk (autonomous decisions)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Credit Scoring AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_Standard(t *testing.T) {
|
||||
// Q8=Yes, Q10=No → GPAI standard
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Custom LLM",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: false},
|
||||
Q12: {QuestionID: Q12, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected category 'standard', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if result.GPAIResult.IsSystemicRisk {
|
||||
t.Error("Expected IsSystemicRisk to be false")
|
||||
}
|
||||
|
||||
// Should have Art. 51, 53, 50 (generative)
|
||||
hasArt51 := false
|
||||
hasArt53 := false
|
||||
hasArt50 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 51" {
|
||||
hasArt51 = true
|
||||
}
|
||||
if a == "Art. 53" {
|
||||
hasArt53 = true
|
||||
}
|
||||
if a == "Art. 50" {
|
||||
hasArt50 = true
|
||||
}
|
||||
}
|
||||
if !hasArt51 {
|
||||
t.Error("Expected Art. 51 in applicable articles")
|
||||
}
|
||||
if !hasArt53 {
|
||||
t.Error("Expected Art. 53 in applicable articles")
|
||||
}
|
||||
if !hasArt50 {
|
||||
t.Error("Expected Art. 50 in applicable articles (generative AI)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_SystemicRisk(t *testing.T) {
|
||||
// Q8=Yes, Q10=Yes → GPAI systemic risk
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "GPT-5",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: true},
|
||||
Q11: {QuestionID: Q11, Value: true},
|
||||
Q12: {QuestionID: Q12, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategorySystemic {
|
||||
t.Errorf("Expected category 'systemic', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if !result.GPAIResult.IsSystemicRisk {
|
||||
t.Error("Expected IsSystemicRisk to be true")
|
||||
}
|
||||
|
||||
// Should have Art. 55
|
||||
hasArt55 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 55" {
|
||||
hasArt55 = true
|
||||
}
|
||||
}
|
||||
if !hasArt55 {
|
||||
t.Error("Expected Art. 55 in applicable articles (systemic risk)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_Combined_HighRiskAndGPAI(t *testing.T) {
|
||||
// Q1=Yes, Q4=Yes (high risk) + Q8=Yes, Q9=Yes (GPAI standard)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "HR Screening with LLM",
|
||||
SystemDescription: "LLM-based applicant screening system",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: true},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: false},
|
||||
Q12: {QuestionID: Q12, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
// Both axes should be triggered
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected GPAI category 'standard', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
|
||||
// Combined obligations should include both axes
|
||||
if len(result.CombinedObligations) < 5 {
|
||||
t.Errorf("Expected at least 5 combined obligations, got %d", len(result.CombinedObligations))
|
||||
}
|
||||
|
||||
// Should have articles from both axes
|
||||
if len(result.ApplicableArticles) < 3 {
|
||||
t.Errorf("Expected at least 3 applicable articles, got %d", len(result.ApplicableArticles))
|
||||
}
|
||||
|
||||
// Check system name preserved
|
||||
if result.SystemName != "HR Screening with LLM" {
|
||||
t.Errorf("Expected system name preserved, got '%s'", result.SystemName)
|
||||
}
|
||||
if result.SystemDescription != "LLM-based applicant screening system" {
|
||||
t.Errorf("Expected description preserved, got '%s'", result.SystemDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_MarketPenetration(t *testing.T) {
|
||||
// Q8=Yes, Q10=No, Q12=Yes → GPAI standard with market penetration warning
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Popular Chatbot",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: true},
|
||||
Q12: {QuestionID: Q12, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected category 'standard' (not systemic because Q10=No), got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
|
||||
// Should have Art. 51 Abs. 3 warning
|
||||
hasArt51_3 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 51 Abs. 3" {
|
||||
hasArt51_3 = true
|
||||
}
|
||||
}
|
||||
if !hasArt51_3 {
|
||||
t.Error("Expected Art. 51 Abs. 3 in applicable articles for high market penetration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_NoGPAI(t *testing.T) {
|
||||
// Q8=No → No GPAI classification
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Traditional ML",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be false")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryNone {
|
||||
t.Errorf("Expected category 'none', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if len(result.GPAIResult.Obligations) != 0 {
|
||||
t.Errorf("Expected 0 GPAI obligations, got %d", len(result.GPAIResult.Obligations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerIsYes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
answers map[string]DecisionTreeAnswer
|
||||
qID string
|
||||
expected bool
|
||||
}{
|
||||
{"yes answer", map[string]DecisionTreeAnswer{"Q1": {Value: true}}, "Q1", true},
|
||||
{"no answer", map[string]DecisionTreeAnswer{"Q1": {Value: false}}, "Q1", false},
|
||||
{"missing answer", map[string]DecisionTreeAnswer{}, "Q1", false},
|
||||
{"different question", map[string]DecisionTreeAnswer{"Q2": {Value: true}}, "Q1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := answerIsYes(tt.answers, tt.qID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//go:build domain_context_fields
|
||||
// +build domain_context_fields
|
||||
|
||||
// NOTE: Depends on domain-specific context fields not in refactored UseCaseIntake.
|
||||
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// HR Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestHRContext_AutomatedRejection_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert und versendet Absagen automatisch",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{
|
||||
AutomatedScreening: true,
|
||||
AutomatedRejection: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO feasibility for automated rejection, got %s", result.Feasibility)
|
||||
}
|
||||
if !result.Art22Risk {
|
||||
t.Error("Expected Art22Risk=true for automated rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHRContext_ScreeningWithHumanReview_OK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI sortiert Bewerber vor, Mensch prueft jeden Vorschlag",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{
|
||||
AutomatedScreening: true,
|
||||
AutomatedRejection: false,
|
||||
HumanReviewEnforced: true,
|
||||
BiasAuditsDone: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// Should NOT block — human review is enforced
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when human review is enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHRContext_AGGVisible_RiskIncrease(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intakeWithAGG := &UseCaseIntake{
|
||||
UseCaseText: "CV-Screening mit Foto und Name sichtbar",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{AGGCategoriesVisible: true},
|
||||
}
|
||||
intakeWithout := &UseCaseIntake{
|
||||
UseCaseText: "CV-Screening anonymisiert",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{AGGCategoriesVisible: false},
|
||||
}
|
||||
|
||||
resultWith := engine.Evaluate(intakeWithAGG)
|
||||
resultWithout := engine.Evaluate(intakeWithout)
|
||||
|
||||
if resultWith.RiskScore <= resultWithout.RiskScore {
|
||||
t.Errorf("Expected higher risk with AGG visible (%d) vs without (%d)",
|
||||
resultWith.RiskScore, resultWithout.RiskScore)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Education Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEducationContext_MinorsWithoutTeacher_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI bewertet Schuelerarbeiten ohne Lehrkraft-Pruefung",
|
||||
Domain: DomainEducation,
|
||||
DataTypes: DataTypes{PersonalData: true, MinorData: true},
|
||||
EducationContext: &EducationContext{
|
||||
GradeInfluence: true,
|
||||
MinorsInvolved: true,
|
||||
TeacherReviewRequired: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO feasibility for minors without teacher review, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationContext_WithTeacherReview_Allowed(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI schlaegt Noten vor, Lehrkraft prueft und entscheidet",
|
||||
Domain: DomainEducation,
|
||||
DataTypes: DataTypes{PersonalData: true, MinorData: true},
|
||||
EducationContext: &EducationContext{
|
||||
GradeInfluence: true,
|
||||
MinorsInvolved: true,
|
||||
TeacherReviewRequired: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when teacher review is required")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Healthcare Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestHealthcareContext_MDRWithoutValidation_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI-Diagnosetool als Medizinprodukt ohne klinische Validierung",
|
||||
Domain: DomainHealthcare,
|
||||
DataTypes: DataTypes{PersonalData: true, Article9Data: true},
|
||||
HealthcareContext: &HealthcareContext{
|
||||
DiagnosisSupport: true,
|
||||
MedicalDevice: true,
|
||||
ClinicalValidation: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for medical device without clinical validation, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcareContext_Triage_HighRisk(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI priorisiert Patienten in der Notaufnahme",
|
||||
Domain: DomainHealthcare,
|
||||
DataTypes: DataTypes{PersonalData: true, Article9Data: true},
|
||||
HealthcareContext: &HealthcareContext{
|
||||
TriageDecision: true,
|
||||
PatientDataProcessed: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.RiskScore < 40 {
|
||||
t.Errorf("Expected high risk score for triage, got %d", result.RiskScore)
|
||||
}
|
||||
if !result.DSFARecommended {
|
||||
t.Error("Expected DSFA recommended for triage")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Critical Infrastructure Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCriticalInfra_SafetyCriticalNoRedundancy_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI steuert Stromnetz ohne Fallback",
|
||||
Domain: DomainEnergy,
|
||||
CriticalInfraContext: &CriticalInfraContext{
|
||||
GridControl: true,
|
||||
SafetyCritical: true,
|
||||
RedundancyExists: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for safety-critical without redundancy, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Marketing — Deepfake BLOCK Test
|
||||
// ============================================================================
|
||||
|
||||
func TestMarketing_DeepfakeUnlabeled_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert Werbevideos ohne Kennzeichnung",
|
||||
Domain: DomainMarketing,
|
||||
MarketingContext: &MarketingContext{
|
||||
DeepfakeContent: true,
|
||||
AIContentLabeled: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for unlabeled deepfakes, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketing_DeepfakeLabeled_OK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert Werbevideos mit Kennzeichnung",
|
||||
Domain: DomainMarketing,
|
||||
MarketingContext: &MarketingContext{
|
||||
DeepfakeContent: true,
|
||||
AIContentLabeled: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when deepfakes are properly labeled")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Manufacturing — Safety BLOCK Test
|
||||
// ============================================================================
|
||||
|
||||
func TestManufacturing_SafetyUnvalidated_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI in Maschinensicherheit ohne Validierung",
|
||||
Domain: DomainMechanicalEngineering,
|
||||
ManufacturingContext: &ManufacturingContext{
|
||||
MachineSafety: true,
|
||||
SafetyValidated: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for unvalidated machine safety, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGG V2 Obligations Loading Test
|
||||
// ============================================================================
|
||||
|
||||
func TestAGGV2_LoadsFromManifest(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
agg, ok := regs["agg"]
|
||||
if !ok {
|
||||
t.Fatal("agg not found in loaded regulations")
|
||||
}
|
||||
|
||||
if len(agg.Obligations) < 8 {
|
||||
t.Errorf("Expected at least 8 AGG obligations, got %d", len(agg.Obligations))
|
||||
}
|
||||
|
||||
// Check first obligation
|
||||
if agg.Obligations[0].ID != "AGG-OBL-001" {
|
||||
t.Errorf("Expected first ID 'AGG-OBL-001', got '%s'", agg.Obligations[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGGApplicability_Germany(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
module := NewJSONRegulationModule(regs["agg"])
|
||||
|
||||
factsDE := &UnifiedFacts{Organization: OrganizationFacts{Country: "DE"}}
|
||||
if !module.IsApplicable(factsDE) {
|
||||
t.Error("AGG should be applicable for German company")
|
||||
}
|
||||
|
||||
factsUS := &UnifiedFacts{Organization: OrganizationFacts{Country: "US"}}
|
||||
if module.IsApplicable(factsUS) {
|
||||
t.Error("AGG should NOT be applicable for US company")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Act V2 Extended Obligations Test
|
||||
// ============================================================================
|
||||
|
||||
func TestAIActV2_ExtendedObligations(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
aiAct, ok := regs["ai_act"]
|
||||
if !ok {
|
||||
t.Fatal("ai_act not found in loaded regulations")
|
||||
}
|
||||
|
||||
if len(aiAct.Obligations) < 75 {
|
||||
t.Errorf("Expected at least 75 AI Act obligations (expanded), got %d", len(aiAct.Obligations))
|
||||
}
|
||||
|
||||
// Check GPAI obligations exist (Art. 51-56)
|
||||
hasGPAI := false
|
||||
for _, obl := range aiAct.Obligations {
|
||||
if obl.ID == "AIACT-OBL-078" { // GPAI classification
|
||||
hasGPAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGPAI {
|
||||
t.Error("Expected GPAI obligation AIACT-OBL-078 in expanded AI Act")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Field Resolver Tests — Domain Contexts
|
||||
// ============================================================================
|
||||
|
||||
func TestFieldResolver_HRContext(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
HRContext: &HRContext{AutomatedScreening: true},
|
||||
}
|
||||
|
||||
val := engine.getFieldValue("hr_context.automated_screening", intake)
|
||||
if val != true {
|
||||
t.Errorf("Expected true for hr_context.automated_screening, got %v", val)
|
||||
}
|
||||
|
||||
val2 := engine.getFieldValue("hr_context.automated_rejection", intake)
|
||||
if val2 != false {
|
||||
t.Errorf("Expected false for hr_context.automated_rejection, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResolver_NilContext(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{} // No HR context
|
||||
|
||||
val := engine.getFieldValue("hr_context.automated_screening", intake)
|
||||
if val != nil {
|
||||
t.Errorf("Expected nil for nil HR context, got %v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResolver_HealthcareContext(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
HealthcareContext: &HealthcareContext{
|
||||
TriageDecision: true,
|
||||
MedicalDevice: false,
|
||||
},
|
||||
}
|
||||
|
||||
val := engine.getFieldValue("healthcare_context.triage_decision", intake)
|
||||
if val != true {
|
||||
t.Errorf("Expected true, got %v", val)
|
||||
}
|
||||
|
||||
val2 := engine.getFieldValue("healthcare_context.medical_device", intake)
|
||||
if val2 != false {
|
||||
t.Errorf("Expected false, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hospitality — Review Manipulation BLOCK
|
||||
// ============================================================================
|
||||
|
||||
func TestHospitality_ReviewManipulation_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert Fake-Bewertungen",
|
||||
Domain: DomainHospitality,
|
||||
HospitalityContext: &HospitalityContext{
|
||||
ReviewManipulation: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for review manipulation, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Total Obligations Count
|
||||
// ============================================================================
|
||||
|
||||
func TestTotalObligationsCount(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, reg := range regs {
|
||||
total += len(reg.Obligations)
|
||||
}
|
||||
|
||||
// We expect at least 350 obligations across all regulations
|
||||
if total < 350 {
|
||||
t.Errorf("Expected at least 350 total obligations, got %d", total)
|
||||
}
|
||||
|
||||
t.Logf("Total obligations across all regulations: %d", total)
|
||||
for id, reg := range regs {
|
||||
t.Logf(" %s: %d obligations", id, len(reg.Obligations))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Domain constant existence checks
|
||||
// ============================================================================
|
||||
|
||||
func TestDomainConstants_Exist(t *testing.T) {
|
||||
domains := []Domain{
|
||||
DomainHR, DomainEducation, DomainHealthcare,
|
||||
DomainFinance, DomainBanking, DomainInsurance,
|
||||
DomainEnergy, DomainUtilities,
|
||||
DomainAutomotive, DomainAerospace,
|
||||
DomainRetail, DomainEcommerce,
|
||||
DomainMarketing, DomainMedia,
|
||||
DomainLogistics, DomainConstruction,
|
||||
DomainPublicSector, DomainDefense,
|
||||
DomainMechanicalEngineering,
|
||||
}
|
||||
|
||||
for _, d := range domains {
|
||||
if d == "" {
|
||||
t.Error("Empty domain constant found")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -187,6 +188,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
||||
}
|
||||
}
|
||||
|
||||
// BetrVG E3: Very high conflict score without consultation
|
||||
if result.BetrvgConflictScore >= 75 && !result.Intake.WorksCouncilConsulted {
|
||||
reasons = append(reasons, "BetrVG-Konfliktpotenzial sehr hoch (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+") ohne BR-Konsultation")
|
||||
return EscalationLevelE3, joinReasons(reasons, "E3 erforderlich: ")
|
||||
}
|
||||
|
||||
if hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold {
|
||||
if result.DSFARecommended {
|
||||
reasons = append(reasons, "DSFA empfohlen")
|
||||
@@ -197,6 +204,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
||||
return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ")
|
||||
}
|
||||
|
||||
// BetrVG E2: High conflict score
|
||||
if result.BetrvgConflictScore >= 50 && result.BetrvgConsultationRequired && !result.Intake.WorksCouncilConsulted {
|
||||
reasons = append(reasons, "BetrVG-Mitbestimmung erforderlich (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+"), BR nicht konsultiert")
|
||||
return EscalationLevelE2, joinReasons(reasons, "BR-Konsultation erforderlich: ")
|
||||
}
|
||||
|
||||
// E1: Low priority checks
|
||||
// - WARN rules triggered
|
||||
// - Risk 20-40
|
||||
|
||||
@@ -56,6 +56,10 @@ func (m *JSONRegulationModule) defaultApplicability(facts *UnifiedFacts) bool {
|
||||
return facts.Organization.EUMember && facts.AIUsage.UsesAI
|
||||
case "dora":
|
||||
return facts.Financial.DORAApplies || facts.Financial.IsRegulated
|
||||
case "betrvg":
|
||||
return facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 5
|
||||
case "agg":
|
||||
return facts.Organization.Country == "DE"
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Keep imports used by DecisionTreeResult.
|
||||
var (
|
||||
_ uuid.UUID
|
||||
_ time.Time
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Constants / Enums
|
||||
// ============================================================================
|
||||
@@ -178,3 +190,73 @@ const (
|
||||
ExportFormatJSON ExportFormat = "json"
|
||||
ExportFormatMarkdown ExportFormat = "md"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// AI Act Decision Tree Types
|
||||
// ============================================================================
|
||||
|
||||
// GPAICategory represents the GPAI classification result
|
||||
type GPAICategory string
|
||||
|
||||
const (
|
||||
GPAICategoryNone GPAICategory = "none"
|
||||
GPAICategoryStandard GPAICategory = "standard"
|
||||
GPAICategorySystemic GPAICategory = "systemic"
|
||||
)
|
||||
|
||||
// GPAIClassification represents the result of the GPAI axis evaluation
|
||||
type GPAIClassification struct {
|
||||
IsGPAI bool `json:"is_gpai"`
|
||||
IsSystemicRisk bool `json:"is_systemic_risk"`
|
||||
Category GPAICategory `json:"gpai_category"`
|
||||
ApplicableArticles []string `json:"applicable_articles"`
|
||||
Obligations []string `json:"obligations"`
|
||||
}
|
||||
|
||||
// DecisionTreeAnswer represents a user's answer to a decision tree question
|
||||
type DecisionTreeAnswer struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
Value bool `json:"value"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionTreeQuestion represents a single question in the decision tree
|
||||
type DecisionTreeQuestion struct {
|
||||
ID string `json:"id"`
|
||||
Axis string `json:"axis"` // "high_risk" or "gpai"
|
||||
Question string `json:"question"`
|
||||
Description string `json:"description"` // Additional context
|
||||
ArticleRef string `json:"article_ref"` // e.g., "Art. 5", "Anhang III"
|
||||
SkipIf string `json:"skip_if,omitempty"` // Question ID — skip if that was answered "no"
|
||||
}
|
||||
|
||||
// DecisionTreeDefinition represents the full decision tree structure for the frontend
|
||||
type DecisionTreeDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Questions []DecisionTreeQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
// DecisionTreeEvalRequest is the API request for evaluating the decision tree
|
||||
type DecisionTreeEvalRequest struct {
|
||||
SystemName string `json:"system_name"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
Answers map[string]DecisionTreeAnswer `json:"answers"`
|
||||
}
|
||||
|
||||
// DecisionTreeResult represents the combined evaluation result
|
||||
type DecisionTreeResult struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SystemName string `json:"system_name"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
Answers map[string]DecisionTreeAnswer `json:"answers"`
|
||||
HighRiskResult AIActRiskLevel `json:"high_risk_result"`
|
||||
GPAIResult GPAIClassification `json:"gpai_result"`
|
||||
CombinedObligations []string `json:"combined_obligations"`
|
||||
ApplicableArticles []string `json:"applicable_articles"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -38,6 +38,13 @@ type AssessmentResult struct {
|
||||
Art22Risk bool `json:"art22_risk"` // Art. 22 GDPR automated decision risk
|
||||
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
||||
|
||||
// BetrVG (Works Council) assessment
|
||||
BetrvgConflictScore int `json:"betrvg_conflict_score,omitempty"`
|
||||
BetrvgConsultationRequired bool `json:"betrvg_consultation_required,omitempty"`
|
||||
|
||||
// Intake reference for escalation logic
|
||||
Intake *UseCaseIntake `json:"intake,omitempty"`
|
||||
|
||||
// Summary for humans
|
||||
Summary string `json:"summary"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
|
||||
@@ -40,6 +40,9 @@ type UseCaseIntake struct {
|
||||
// Only applicable for financial domains (banking, finance, insurance, investment)
|
||||
FinancialContext *FinancialContext `json:"financial_context,omitempty"`
|
||||
|
||||
// BetrVG: Works council consultation status
|
||||
WorksCouncilConsulted bool `json:"works_council_consulted,omitempty"`
|
||||
|
||||
// Opt-in to store raw text (otherwise only hash)
|
||||
StoreRawText bool `json:"store_raw_text,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegulatoryNewsItem is a single news item for dashboard display.
|
||||
type RegulatoryNewsItem struct {
|
||||
ID string `json:"id"`
|
||||
Headline string `json:"headline"`
|
||||
Summary string `json:"summary"`
|
||||
LegalReference string `json:"legal_reference"`
|
||||
Deadline string `json:"deadline"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
Urgency string `json:"urgency"` // critical, high, medium, low
|
||||
Affected string `json:"affected"`
|
||||
ActionRequired string `json:"action_required"`
|
||||
ActionLink string `json:"action_link"`
|
||||
Regulation string `json:"regulation"`
|
||||
Sanctions string `json:"sanctions,omitempty"`
|
||||
}
|
||||
|
||||
// RegulatoryNewsFilter controls which news items are returned.
|
||||
type RegulatoryNewsFilter struct {
|
||||
BusinessModel string `json:"business_model,omitempty"`
|
||||
HorizonDays int `json:"horizon_days,omitempty"` // default 365
|
||||
Limit int `json:"limit,omitempty"` // default 5
|
||||
}
|
||||
|
||||
// GetRegulatoryNews scans all v2 obligations for upcoming deadlines
|
||||
// and returns formatted news items sorted by urgency.
|
||||
func GetRegulatoryNews(regulations map[string]*V2RegulationFile, filter RegulatoryNewsFilter) []RegulatoryNewsItem {
|
||||
if filter.HorizonDays <= 0 {
|
||||
filter.HorizonDays = 365
|
||||
}
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 5
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Truncate(24 * time.Hour)
|
||||
horizon := today.AddDate(0, 0, filter.HorizonDays)
|
||||
var items []RegulatoryNewsItem
|
||||
|
||||
for _, reg := range regulations {
|
||||
for _, obl := range reg.Obligations {
|
||||
deadline, ok := resolveDeadline(obl)
|
||||
if !ok || deadline.Before(today) || deadline.After(horizon) {
|
||||
continue
|
||||
}
|
||||
|
||||
days := int(deadline.Sub(today).Hours() / 24)
|
||||
item := buildNewsItem(obl, reg.Regulation, deadline, days)
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].DaysRemaining < items[j].DaysRemaining
|
||||
})
|
||||
|
||||
if len(items) > filter.Limit {
|
||||
items = items[:filter.Limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildNewsItem(obl V2Obligation, regulation string, deadline time.Time, days int) RegulatoryNewsItem {
|
||||
item := RegulatoryNewsItem{
|
||||
ID: obl.ID,
|
||||
Deadline: deadline.Format("2006-01-02"),
|
||||
DaysRemaining: days,
|
||||
Urgency: computeUrgency(days),
|
||||
Regulation: regulation,
|
||||
}
|
||||
|
||||
// Use hand-crafted news if available
|
||||
if obl.News != nil {
|
||||
item.Headline = obl.News.Headline
|
||||
item.Summary = obl.News.Summary
|
||||
item.ActionRequired = obl.News.ActionRequired
|
||||
item.Affected = obl.News.Affected
|
||||
item.ActionLink = obl.News.ActionLink
|
||||
} else {
|
||||
// Auto-generate from obligation data
|
||||
item.Headline = fmt.Sprintf("%s — ab %s", obl.Title, deadline.Format("02.01.2006"))
|
||||
item.Summary = obl.Description
|
||||
item.ActionRequired = "Pruefen Sie die Anforderungen und ergreifen Sie Massnahmen."
|
||||
item.ActionLink = obl.BreakpilotFeature
|
||||
}
|
||||
|
||||
item.LegalReference = formatLegalReference(obl.LegalBasis)
|
||||
|
||||
if obl.Sanctions != nil {
|
||||
item.Sanctions = obl.Sanctions.MaxFine
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func resolveDeadline(obl V2Obligation) (time.Time, bool) {
|
||||
// Check explicit deadline.date first
|
||||
if obl.Deadline != nil && obl.Deadline.Date != "" {
|
||||
t, err := time.Parse("2006-01-02", obl.Deadline.Date)
|
||||
if err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
// Fallback to valid_from
|
||||
if obl.ValidFrom != "" {
|
||||
t, err := time.Parse("2006-01-02", obl.ValidFrom)
|
||||
if err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func computeUrgency(daysRemaining int) string {
|
||||
switch {
|
||||
case daysRemaining <= 30:
|
||||
return "critical"
|
||||
case daysRemaining <= 90:
|
||||
return "high"
|
||||
case daysRemaining <= 180:
|
||||
return "medium"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
|
||||
func formatLegalReference(bases []V2LegalBasis) string {
|
||||
if len(bases) == 0 {
|
||||
return ""
|
||||
}
|
||||
var refs []string
|
||||
for _, b := range bases {
|
||||
ref := b.Article
|
||||
if b.Norm != "" {
|
||||
ref = b.Article + " " + b.Norm
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return strings.Join(refs, ", ")
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTestRegulations() map[string]*V2RegulationFile {
|
||||
future30 := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
|
||||
future90 := time.Now().AddDate(0, 0, 90).Format("2006-01-02")
|
||||
past := time.Now().AddDate(0, 0, -10).Format("2006-01-02")
|
||||
|
||||
return map[string]*V2RegulationFile{
|
||||
"TestReg": {
|
||||
Regulation: "TestReg",
|
||||
Obligations: []V2Obligation{
|
||||
{
|
||||
ID: "TR-001", Title: "Upcoming Critical",
|
||||
Deadline: &V2Deadline{Date: future30},
|
||||
News: &V2ObligationNews{
|
||||
Headline: "Critical Deadline", Summary: "Test summary",
|
||||
ActionRequired: "Do something", Affected: "All", ActionLink: "/sdk/test",
|
||||
},
|
||||
LegalBasis: []V2LegalBasis{{Norm: "TestLaw", Article: "Art. 1"}},
|
||||
},
|
||||
{
|
||||
ID: "TR-002", Title: "Upcoming Medium",
|
||||
Description: "Medium priority regulation change.",
|
||||
Deadline: &V2Deadline{Date: future90},
|
||||
LegalBasis: []V2LegalBasis{{Norm: "TestLaw", Article: "Art. 2"}},
|
||||
},
|
||||
{
|
||||
ID: "TR-003", Title: "Past Deadline",
|
||||
Deadline: &V2Deadline{Date: past},
|
||||
},
|
||||
{
|
||||
ID: "TR-004", Title: "No Deadline",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegulatoryNews_SortedByUrgency(t *testing.T) {
|
||||
regs := makeTestRegulations()
|
||||
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items (future only), got %d", len(items))
|
||||
}
|
||||
// First item should be the more urgent one (30 days)
|
||||
if items[0].ID != "TR-001" {
|
||||
t.Errorf("expected TR-001 first (most urgent), got %s", items[0].ID)
|
||||
}
|
||||
if items[1].ID != "TR-002" {
|
||||
t.Errorf("expected TR-002 second, got %s", items[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegulatoryNews_UsesNewsField(t *testing.T) {
|
||||
regs := makeTestRegulations()
|
||||
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
|
||||
|
||||
// TR-001 has hand-crafted news
|
||||
if items[0].Headline != "Critical Deadline" {
|
||||
t.Errorf("expected hand-crafted headline, got %q", items[0].Headline)
|
||||
}
|
||||
if items[0].ActionLink != "/sdk/test" {
|
||||
t.Errorf("expected /sdk/test, got %q", items[0].ActionLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegulatoryNews_AutoGeneratesWithoutNews(t *testing.T) {
|
||||
regs := makeTestRegulations()
|
||||
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
|
||||
|
||||
// TR-002 has no news field — should auto-generate
|
||||
if items[1].Headline == "" {
|
||||
t.Error("expected auto-generated headline")
|
||||
}
|
||||
if items[1].Summary == "" {
|
||||
t.Error("expected auto-generated summary from description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegulatoryNews_ExcludesPastDeadlines(t *testing.T) {
|
||||
regs := makeTestRegulations()
|
||||
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 10})
|
||||
|
||||
for _, item := range items {
|
||||
if item.ID == "TR-003" {
|
||||
t.Error("past deadline should be excluded")
|
||||
}
|
||||
if item.ID == "TR-004" {
|
||||
t.Error("no-deadline obligation should be excluded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegulatoryNews_LimitWorks(t *testing.T) {
|
||||
regs := makeTestRegulations()
|
||||
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 1})
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Errorf("expected 1 item with limit=1, got %d", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUrgency(t *testing.T) {
|
||||
tests := []struct {
|
||||
days int
|
||||
expected string
|
||||
}{
|
||||
{5, "critical"},
|
||||
{30, "critical"},
|
||||
{31, "high"},
|
||||
{90, "high"},
|
||||
{91, "medium"},
|
||||
{180, "medium"},
|
||||
{181, "low"},
|
||||
{365, "low"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := computeUrgency(tc.days)
|
||||
if got != tc.expected {
|
||||
t.Errorf("computeUrgency(%d) = %q, want %q", tc.days, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeadline_DeadlineDate(t *testing.T) {
|
||||
obl := V2Obligation{Deadline: &V2Deadline{Date: "2026-06-19"}}
|
||||
d, ok := resolveDeadline(obl)
|
||||
if !ok {
|
||||
t.Fatal("expected deadline resolved")
|
||||
}
|
||||
if d.Format("2006-01-02") != "2026-06-19" {
|
||||
t.Errorf("got %s", d.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeadline_ValidFrom(t *testing.T) {
|
||||
obl := V2Obligation{ValidFrom: "2026-08-02"}
|
||||
d, ok := resolveDeadline(obl)
|
||||
if !ok {
|
||||
t.Fatal("expected deadline resolved from valid_from")
|
||||
}
|
||||
if d.Format("2006-01-02") != "2026-08-02" {
|
||||
t.Errorf("got %s", d.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeadline_NoDate(t *testing.T) {
|
||||
obl := V2Obligation{}
|
||||
_, ok := resolveDeadline(obl)
|
||||
if ok {
|
||||
t.Error("expected no deadline for empty obligation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLegalReference(t *testing.T) {
|
||||
bases := []V2LegalBasis{
|
||||
{Norm: "DSGVO", Article: "Art. 22"},
|
||||
{Norm: "BGB", Article: "§ 356a"},
|
||||
}
|
||||
ref := formatLegalReference(bases)
|
||||
if ref != "Art. 22 DSGVO, § 356a BGB" {
|
||||
t.Errorf("got %q", ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegulatoryNews_FromRealFiles(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Skipf("could not load v2 regulations: %v", err)
|
||||
}
|
||||
items := GetRegulatoryNews(regs, RegulatoryNewsFilter{Limit: 20, HorizonDays: 730})
|
||||
// Should find at least the Widerrufsbutton obligation
|
||||
found := false
|
||||
for _, item := range items {
|
||||
if item.ID == "VBR-OBL-001" {
|
||||
found = true
|
||||
if item.Headline != "Widerrufsbutton-Pflicht ab 19. Juni 2026" {
|
||||
t.Errorf("unexpected headline: %q", item.Headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected VBR-OBL-001 in regulatory news")
|
||||
}
|
||||
}
|
||||
@@ -358,6 +358,128 @@ type AssessmentFilters struct {
|
||||
Offset int // OFFSET for pagination
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decision Tree Result CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateDecisionTreeResult stores a new decision tree result
|
||||
func (s *Store) CreateDecisionTreeResult(ctx context.Context, r *DecisionTreeResult) error {
|
||||
r.ID = uuid.New()
|
||||
r.CreatedAt = time.Now().UTC()
|
||||
r.UpdatedAt = r.CreatedAt
|
||||
|
||||
answers, _ := json.Marshal(r.Answers)
|
||||
gpaiResult, _ := json.Marshal(r.GPAIResult)
|
||||
obligations, _ := json.Marshal(r.CombinedObligations)
|
||||
articles, _ := json.Marshal(r.ApplicableArticles)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ai_act_decision_tree_results (
|
||||
id, tenant_id, project_id, system_name, system_description,
|
||||
answers, high_risk_level, gpai_result,
|
||||
combined_obligations, applicable_articles,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10,
|
||||
$11, $12
|
||||
)
|
||||
`,
|
||||
r.ID, r.TenantID, r.ProjectID, r.SystemName, r.SystemDescription,
|
||||
answers, string(r.HighRiskResult), gpaiResult,
|
||||
obligations, articles,
|
||||
r.CreatedAt, r.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDecisionTreeResult retrieves a decision tree result by ID
|
||||
func (s *Store) GetDecisionTreeResult(ctx context.Context, id uuid.UUID) (*DecisionTreeResult, error) {
|
||||
var r DecisionTreeResult
|
||||
var answersBytes, gpaiBytes, oblBytes, artBytes []byte
|
||||
var highRiskLevel string
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, project_id, system_name, system_description,
|
||||
answers, high_risk_level, gpai_result,
|
||||
combined_obligations, applicable_articles,
|
||||
created_at, updated_at
|
||||
FROM ai_act_decision_tree_results WHERE id = $1
|
||||
`, id).Scan(
|
||||
&r.ID, &r.TenantID, &r.ProjectID, &r.SystemName, &r.SystemDescription,
|
||||
&answersBytes, &highRiskLevel, &gpaiBytes,
|
||||
&oblBytes, &artBytes,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(answersBytes, &r.Answers)
|
||||
json.Unmarshal(gpaiBytes, &r.GPAIResult)
|
||||
json.Unmarshal(oblBytes, &r.CombinedObligations)
|
||||
json.Unmarshal(artBytes, &r.ApplicableArticles)
|
||||
r.HighRiskResult = AIActRiskLevel(highRiskLevel)
|
||||
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ListDecisionTreeResults lists all decision tree results for a tenant
|
||||
func (s *Store) ListDecisionTreeResults(ctx context.Context, tenantID uuid.UUID) ([]DecisionTreeResult, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, project_id, system_name, system_description,
|
||||
answers, high_risk_level, gpai_result,
|
||||
combined_obligations, applicable_articles,
|
||||
created_at, updated_at
|
||||
FROM ai_act_decision_tree_results
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []DecisionTreeResult
|
||||
for rows.Next() {
|
||||
var r DecisionTreeResult
|
||||
var answersBytes, gpaiBytes, oblBytes, artBytes []byte
|
||||
var highRiskLevel string
|
||||
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.TenantID, &r.ProjectID, &r.SystemName, &r.SystemDescription,
|
||||
&answersBytes, &highRiskLevel, &gpaiBytes,
|
||||
&oblBytes, &artBytes,
|
||||
&r.CreatedAt, &r.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(answersBytes, &r.Answers)
|
||||
json.Unmarshal(gpaiBytes, &r.GPAIResult)
|
||||
json.Unmarshal(oblBytes, &r.CombinedObligations)
|
||||
json.Unmarshal(artBytes, &r.ApplicableArticles)
|
||||
r.HighRiskResult = AIActRiskLevel(highRiskLevel)
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DeleteDecisionTreeResult deletes a decision tree result by ID
|
||||
func (s *Store) DeleteDecisionTreeResult(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, "DELETE FROM ai_act_decision_tree_results WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
@@ -58,6 +58,17 @@ type V2Obligation struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
ISO27001Mapping []string `json:"iso27001_mapping,omitempty"`
|
||||
HowToImplement string `json:"how_to_implement,omitempty"`
|
||||
News *V2ObligationNews `json:"news,omitempty"`
|
||||
}
|
||||
|
||||
// V2ObligationNews is news metadata for dashboard display.
|
||||
// Own text referencing legal basis — never copied from external sources.
|
||||
type V2ObligationNews struct {
|
||||
Headline string `json:"headline"`
|
||||
Summary string `json:"summary"`
|
||||
ActionRequired string `json:"action_required"`
|
||||
Affected string `json:"affected,omitempty"`
|
||||
ActionLink string `json:"action_link,omitempty"`
|
||||
}
|
||||
|
||||
// V2LegalBasis is a legal reference in v2 format
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Migration 024: Payment Compliance Schema
|
||||
-- Tracks payment terminal compliance assessments against control library
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_compliance_assessments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Project / Tender
|
||||
project_name VARCHAR(500) NOT NULL,
|
||||
tender_reference VARCHAR(200),
|
||||
customer_name VARCHAR(500),
|
||||
description TEXT,
|
||||
|
||||
-- Scope
|
||||
system_type VARCHAR(100), -- terminal, backend, both, full_stack
|
||||
payment_methods JSONB DEFAULT '[]'::jsonb, -- ["card", "nfc", "girocard", "credit"]
|
||||
protocols JSONB DEFAULT '[]'::jsonb, -- ["zvt", "opi", "emv"]
|
||||
|
||||
-- Assessment
|
||||
total_controls INT DEFAULT 0,
|
||||
controls_passed INT DEFAULT 0,
|
||||
controls_failed INT DEFAULT 0,
|
||||
controls_partial INT DEFAULT 0,
|
||||
controls_not_applicable INT DEFAULT 0,
|
||||
controls_not_checked INT DEFAULT 0,
|
||||
compliance_score NUMERIC(5,2) DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) DEFAULT 'draft',
|
||||
-- CHECK (status IN ('draft', 'in_progress', 'completed', 'approved'))
|
||||
|
||||
-- Results (per control)
|
||||
control_results JSONB DEFAULT '[]'::jsonb,
|
||||
-- Each entry: {"control_id": "PAY-001", "verdict": "passed|failed|partial|na|unchecked", "evidence": "...", "notes": "..."}
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by VARCHAR(200),
|
||||
approved_by VARCHAR(200),
|
||||
approved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pca_tenant ON payment_compliance_assessments (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pca_status ON payment_compliance_assessments (status);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Migration 025: Tender Analysis Schema
|
||||
-- Stores uploaded tender documents, extracted requirements, and control matching results
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tender_analyses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Document
|
||||
file_name VARCHAR(500) NOT NULL,
|
||||
file_size BIGINT DEFAULT 0,
|
||||
file_content BYTEA,
|
||||
|
||||
-- Project
|
||||
project_name VARCHAR(500),
|
||||
customer_name VARCHAR(500),
|
||||
|
||||
-- Status
|
||||
status VARCHAR(50) DEFAULT 'uploaded',
|
||||
-- CHECK (status IN ('uploaded', 'extracting', 'extracted', 'matched', 'completed', 'error'))
|
||||
|
||||
-- Extracted requirements
|
||||
requirements JSONB DEFAULT '[]'::jsonb,
|
||||
total_requirements INT DEFAULT 0,
|
||||
|
||||
-- Match results
|
||||
match_results JSONB DEFAULT '[]'::jsonb,
|
||||
matched_count INT DEFAULT 0,
|
||||
unmatched_count INT DEFAULT 0,
|
||||
partial_count INT DEFAULT 0,
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ta_tenant ON tender_analyses (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ta_status ON tender_analyses (status);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Compliance Maximizer: Regulatory Optimization Engine
|
||||
-- Stores optimization results with 3-zone analysis and compliant variants.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maximizer_optimizations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Optional link to existing UCCA assessment
|
||||
assessment_id UUID,
|
||||
|
||||
title VARCHAR(500) DEFAULT '',
|
||||
status VARCHAR(50) DEFAULT 'completed',
|
||||
|
||||
-- Input
|
||||
input_config JSONB NOT NULL,
|
||||
input_intake JSONB,
|
||||
|
||||
-- Results
|
||||
is_compliant BOOLEAN NOT NULL DEFAULT false,
|
||||
original_evaluation JSONB NOT NULL DEFAULT '{}',
|
||||
max_safe_config JSONB,
|
||||
variants JSONB DEFAULT '[]',
|
||||
zone_map JSONB DEFAULT '{}',
|
||||
|
||||
-- Metadata
|
||||
constraint_version VARCHAR(50) DEFAULT '1.0.0',
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_maximizer_tenant
|
||||
ON maximizer_optimizations(tenant_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_maximizer_tenant_created
|
||||
ON maximizer_optimizations(tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_maximizer_assessment
|
||||
ON maximizer_optimizations(assessment_id);
|
||||
@@ -0,0 +1,65 @@
|
||||
# Payment Compliance Pack
|
||||
|
||||
Ausfuehrbares Pruefpaket fuer Payment-Terminal-Systeme.
|
||||
|
||||
## Inhalt
|
||||
|
||||
### Semgrep-Regeln (25 Regeln)
|
||||
|
||||
| Datei | Regeln | Controls |
|
||||
|-------|--------|----------|
|
||||
| `payment_logging.yml` | 5 | LOG-001, LOG-002, LOG-014 |
|
||||
| `payment_crypto.yml` | 6 | CRYPTO-001, CRYPTO-008, CRYPTO-009, KEYMGMT-001 |
|
||||
| `payment_api.yml` | 5 | API-004, API-005, API-014, API-017 |
|
||||
| `payment_config.yml` | 5 | CONFIG-001 bis CONFIG-004 |
|
||||
| `payment_data.yml` | 5 | DATA-004, DATA-005, DATA-013, TELEMETRY-001 |
|
||||
|
||||
### CodeQL-Specs (5 Queries)
|
||||
|
||||
| Datei | Ziel | Controls |
|
||||
|-------|------|----------|
|
||||
| `sensitive-data-to-logs.md` | Datenfluss zu Loggern | LOG-001, LOG-002, DATA-013 |
|
||||
| `sensitive-data-to-response.md` | Datenfluss in HTTP-Responses | API-009, ERROR-005 |
|
||||
| `tenant-context-loss.md` | Mandantenkontext-Verlust | TENANT-001, TENANT-002 |
|
||||
| `sensitive-data-to-telemetry.md` | Datenfluss in Telemetrie | TELEMETRY-001, TELEMETRY-002 |
|
||||
| `cache-export-leak.md` | Leaks in Cache/Export | DATA-004, DATA-011 |
|
||||
|
||||
### State-Machine-Tests (10 Testfaelle)
|
||||
|
||||
| Datei | Inhalt |
|
||||
|-------|--------|
|
||||
| `terminal_states.md` | 11 Zustaende, 15 Events, Transitions |
|
||||
| `terminal_invariants.md` | 8 Invarianten |
|
||||
| `terminal_testcases.json` | 10 ausfuehrbare Testfaelle |
|
||||
|
||||
### Finding-Schema
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|-------------|
|
||||
| `finding.schema.json` | JSON Schema fuer Pruefergebnisse |
|
||||
|
||||
## Ausfuehrung
|
||||
|
||||
### Semgrep
|
||||
|
||||
```bash
|
||||
semgrep --config payment-compliance-pack/semgrep/ /path/to/source
|
||||
```
|
||||
|
||||
### State-Machine-Tests
|
||||
|
||||
Die Testfaelle in `terminal_testcases.json` definieren:
|
||||
- Ausgangszustand
|
||||
- Event-Sequenz
|
||||
- Erwarteten Endzustand
|
||||
- Zu pruefende Invarianten
|
||||
- Gemappte Controls
|
||||
|
||||
Diese koennen gegen einen Terminal-Adapter oder Simulator ausgefuehrt werden.
|
||||
|
||||
## Priorisierte Umsetzung
|
||||
|
||||
1. **Welle 1:** 25 Semgrep-Regeln sofort produktiv
|
||||
2. **Welle 2:** 5 CodeQL-Queries fuer Datenfluesse
|
||||
3. **Welle 3:** 10 State-Machine-Tests gegen Terminal-Simulator
|
||||
4. **Welle 4:** Tender-Mapping (Requirement → Control → Finding → Verdict)
|
||||
@@ -0,0 +1,20 @@
|
||||
# CodeQL Query: Cache and Export Leak
|
||||
|
||||
## Ziel
|
||||
Finde Leaks sensibler Daten in Caches, Files, Reports und Exportpfaden.
|
||||
|
||||
## Sources
|
||||
- Sensitive payment attributes (pan, cvv, track2)
|
||||
- Full transaction objects with sensitive fields
|
||||
|
||||
## Sinks
|
||||
- Redis/Memcache writes
|
||||
- Temp file writes
|
||||
- CSV/PDF/Excel exports
|
||||
- Report builders
|
||||
|
||||
## Mapped Controls
|
||||
- `DATA-004`: Temporaere Speicher ohne sensitive Daten
|
||||
- `DATA-005`: Sensitive Daten in Telemetrie nicht offengelegt
|
||||
- `DATA-011`: Batch/Queue ohne unnoetige sensitive Felder
|
||||
- `REPORT-005`: Berichte beruecksichtigen Zeitzonen konsistent
|
||||
@@ -0,0 +1,32 @@
|
||||
# CodeQL Query: Sensitive Data to Logs
|
||||
|
||||
## Ziel
|
||||
Finde Fluesse von sensitiven Zahlungsdaten zu Loggern.
|
||||
|
||||
## Sources
|
||||
Variablen, Felder, Parameter oder JSON-Felder mit Namen:
|
||||
- `pan`, `cardNumber`, `card_number`
|
||||
- `cvv`, `cvc`
|
||||
- `track2`, `track_2`
|
||||
- `pin`
|
||||
- `expiry`, `ablauf`
|
||||
|
||||
## Sinks
|
||||
- Logger-Aufrufe (`logging.*`, `logger.*`, `console.*`, `log.*`)
|
||||
- Telemetrie-/Tracing-Emitter (`span.set_attribute`, `tracer.*)
|
||||
- Audit-Logger (wenn nicht maskiert)
|
||||
|
||||
## Expected Result
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| file | string |
|
||||
| line | int |
|
||||
| source_name | string |
|
||||
| sink_call | string |
|
||||
| path | string[] |
|
||||
|
||||
## Mapped Controls
|
||||
- `LOG-001`: Keine sensitiven Zahlungsdaten im Log
|
||||
- `LOG-002`: PAN maskiert in Logs
|
||||
- `DATA-013`: Sensitive Daten in Telemetrie nicht offengelegt
|
||||
- `TELEMETRY-001`: Telemetriedaten ohne sensitive Zahlungsdaten
|
||||
@@ -0,0 +1,19 @@
|
||||
# CodeQL Query: Sensitive Data to HTTP Response
|
||||
|
||||
## Ziel
|
||||
Finde Fluesse sensibler Daten in HTTP-/API-Responses oder Exception-Bodies.
|
||||
|
||||
## Sources
|
||||
- Sensible Payment-Felder: pan, cvv, track2, cardNumber, pin, expiry
|
||||
- Interne Payment DTOs mit sensitiven Attributen
|
||||
|
||||
## Sinks
|
||||
- JSON serializer / response builder
|
||||
- Exception payload / error handler response
|
||||
- Template rendering output
|
||||
|
||||
## Mapped Controls
|
||||
- `API-009`: API-Antworten minimieren sensible Daten
|
||||
- `API-015`: Interne Fehler ohne sensitive Daten an Client
|
||||
- `ERROR-005`: Ausnahmebehandlung gibt keine sensitiven Rohdaten zurueck
|
||||
- `REPORT-006`: Reports offenbaren nur rollenerforderliche Daten
|
||||
@@ -0,0 +1,19 @@
|
||||
# CodeQL Query: Sensitive Data to Telemetry
|
||||
|
||||
## Ziel
|
||||
Finde Fluesse sensibler Daten in Metriken, Traces und Telemetrie-Events.
|
||||
|
||||
## Sources
|
||||
- Payment DTO fields (pan, cvv, track2, cardNumber)
|
||||
- Token/Session related fields
|
||||
|
||||
## Sinks
|
||||
- Span attributes / trace tags
|
||||
- Metric labels
|
||||
- Telemetry events / exporters
|
||||
|
||||
## Mapped Controls
|
||||
- `TELEMETRY-001`: Telemetriedaten ohne sensitive Zahlungsdaten
|
||||
- `TELEMETRY-002`: Tracing maskiert identifizierende Felder
|
||||
- `TELEMETRY-003`: Metriken ohne hochkartesische sensitive Labels
|
||||
- `DATA-013`: Sensitive Daten in Telemetrie nicht offengelegt
|
||||
@@ -0,0 +1,21 @@
|
||||
# CodeQL Query: Tenant Context Loss
|
||||
|
||||
## Ziel
|
||||
Finde Datenbank-, Cache- oder Exportpfade ohne durchgehenden Tenant-Kontext.
|
||||
|
||||
## Sources
|
||||
- Request tenant (header, token, session)
|
||||
- Device tenant
|
||||
- User tenant
|
||||
|
||||
## Danger Patterns
|
||||
- DB Query ohne tenant filter / WHERE clause
|
||||
- Cache key ohne tenant prefix
|
||||
- Export job ohne tenant binding
|
||||
- Report query ohne Mandanteneinschraenkung
|
||||
|
||||
## Mapped Controls
|
||||
- `TENANT-001`: Mandantenkontext serverseitig validiert
|
||||
- `TENANT-002`: Datenabfragen mandantenbeschraenkt
|
||||
- `TENANT-006`: Caching beruecksichtigt Mandantenkontext
|
||||
- `TENANT-008`: Datenexporte erzwingen Mandantenisolation
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Payment Compliance Finding",
|
||||
"type": "object",
|
||||
"required": ["control_id", "engine", "status", "confidence", "evidence", "verdict_text"],
|
||||
"properties": {
|
||||
"control_id": { "type": "string" },
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"enum": ["semgrep", "codeql", "contract_test", "state_machine_test", "integration_test", "manual"]
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["passed", "failed", "warning", "not_tested", "needs_manual_review"]
|
||||
},
|
||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "critical"]
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": { "type": "string" },
|
||||
"line": { "type": "integer" },
|
||||
"snippet_type": { "type": "string" },
|
||||
"scenario": { "type": "string" },
|
||||
"observed_state": { "type": "string" },
|
||||
"expected_state": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"mapped_requirements": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"verdict_text": { "type": "string" },
|
||||
"next_action": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
rules:
|
||||
- id: payment-debug-route
|
||||
message: Debug- oder Diagnosepfad im produktiven API-Code pruefen.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(/debug|/internal|/test|/actuator|/swagger|/openapi)
|
||||
|
||||
- id: payment-admin-route-without-auth
|
||||
message: Administrative Route ohne offensichtlichen Auth-Schutz pruefen.
|
||||
severity: WARNING
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern: |
|
||||
@app.$METHOD($ROUTE)
|
||||
def $FUNC(...):
|
||||
...
|
||||
- metavariable-pattern:
|
||||
metavariable: $ROUTE
|
||||
pattern-regex: (?i).*(admin|config|terminal|maintenance|device|key).*
|
||||
|
||||
- id: payment-raw-exception-response
|
||||
message: Roh-Exceptions duerfen nicht direkt an Clients zurueckgegeben werden.
|
||||
severity: ERROR
|
||||
languages: [python, javascript, typescript]
|
||||
pattern-regex: (?i)(return .*str\(e\)|res\.status\(500\)\.send\(e|json\(.*error.*e)
|
||||
|
||||
- id: payment-missing-input-validation
|
||||
message: Zahlungsrelevanter Endpunkt ohne offensichtliche Validierung pruefen.
|
||||
severity: INFO
|
||||
languages: [python, javascript, typescript]
|
||||
pattern-regex: (?i)(amount|currency|terminalId|transactionId)
|
||||
|
||||
- id: payment-idor-risk
|
||||
message: Direkter Zugriff ueber terminalId/transactionId ohne Pruefung.
|
||||
severity: WARNING
|
||||
languages: [python, javascript, typescript, java, go]
|
||||
pattern-regex: (?i)(get.*terminalId|find.*terminalId|get.*transactionId|find.*transactionId)
|
||||
@@ -0,0 +1,30 @@
|
||||
rules:
|
||||
- id: payment-prod-config-test-endpoint
|
||||
message: Test- oder Sandbox-Endpunkt in produktionsnaher Konfiguration erkannt.
|
||||
severity: ERROR
|
||||
languages: [yaml, json]
|
||||
pattern-regex: (?i)(sandbox|test-endpoint|mock-terminal|dummy-acquirer)
|
||||
|
||||
- id: payment-prod-debug-flag
|
||||
message: Unsicherer Debug-Flag in Konfiguration erkannt.
|
||||
severity: WARNING
|
||||
languages: [yaml, json]
|
||||
pattern-regex: (?i)(debug:\s*true|"debug"\s*:\s*true)
|
||||
|
||||
- id: payment-open-cors
|
||||
message: Offene CORS-Freigabe pruefen.
|
||||
severity: WARNING
|
||||
languages: [yaml, json, javascript, typescript]
|
||||
pattern-regex: (?i)(Access-Control-Allow-Origin.*\*|origin:\s*["']\*["'])
|
||||
|
||||
- id: payment-insecure-session-cookie
|
||||
message: Unsicher gesetzte Session-Cookies pruefen.
|
||||
severity: ERROR
|
||||
languages: [javascript, typescript, python]
|
||||
pattern-regex: (?i)(httpOnly\s*:\s*false|secure\s*:\s*false|sameSite\s*:\s*["']none["'])
|
||||
|
||||
- id: payment-unbounded-retry
|
||||
message: Retry-Konfiguration scheint unbegrenzt oder zu hoch.
|
||||
severity: WARNING
|
||||
languages: [yaml, json]
|
||||
pattern-regex: (?i)(retry.*(9999|infinite|unbounded))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user