Compare commits
48 Commits
6d3bdf8e74
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47d351c73 | ||
|
|
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 | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 |
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const body = await request.json()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||||
|
headers: { 'X-Tenant-ID': tenantId },
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const body = await request.json()
|
||||||
|
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
case 'controls': {
|
case 'controls': {
|
||||||
const controlParams = new URLSearchParams()
|
const controlParams = new URLSearchParams()
|
||||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||||
'target_audience', 'source', 'search', 'control_type', 'sort', 'order', 'limit', 'offset']
|
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
|
||||||
for (const key of passthrough) {
|
for (const key of passthrough) {
|
||||||
const val = searchParams.get(key)
|
const val = searchParams.get(key)
|
||||||
if (val) controlParams.set(key, val)
|
if (val) controlParams.set(key, val)
|
||||||
@@ -39,8 +39,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
case 'controls-count': {
|
case 'controls-count': {
|
||||||
const countParams = new URLSearchParams()
|
const countParams = new URLSearchParams()
|
||||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||||
'target_audience', 'source', 'search', 'control_type']
|
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||||
for (const key of countPassthrough) {
|
for (const key of countPassthrough) {
|
||||||
const val = searchParams.get(key)
|
const val = searchParams.get(key)
|
||||||
if (val) countParams.set(key, val)
|
if (val) countParams.set(key, val)
|
||||||
@@ -50,9 +50,18 @@ export async function GET(request: NextRequest) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'controls-meta':
|
case 'controls-meta': {
|
||||||
backendPath = '/api/compliance/v1/canonical/controls-meta'
|
const metaParams = new URLSearchParams()
|
||||||
|
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
|
||||||
|
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
|
||||||
|
for (const key of metaPassthrough) {
|
||||||
|
const val = searchParams.get(key)
|
||||||
|
if (val) metaParams.set(key, val)
|
||||||
|
}
|
||||||
|
const metaQs = metaParams.toString()
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'control': {
|
case 'control': {
|
||||||
const controlId = searchParams.get('id')
|
const controlId = searchParams.get('id')
|
||||||
@@ -135,6 +144,23 @@ export async function GET(request: NextRequest) {
|
|||||||
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
backendPath = '/api/compliance/v1/canonical/blocked-sources'
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'v1-matches': {
|
||||||
|
const matchId = searchParams.get('id')
|
||||||
|
if (!matchId) {
|
||||||
|
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||||
|
}
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'v1-enrichment-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'obligation-dedup-stats':
|
||||||
|
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
|
||||||
|
break
|
||||||
|
|
||||||
case 'controls-customer': {
|
case 'controls-customer': {
|
||||||
const custSeverity = searchParams.get('severity')
|
const custSeverity = searchParams.get('severity')
|
||||||
const custDomain = searchParams.get('domain')
|
const custDomain = searchParams.get('domain')
|
||||||
@@ -201,6 +227,16 @@ export async function POST(request: NextRequest) {
|
|||||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||||
|
} else if (endpoint === 'enrich-v1-matches') {
|
||||||
|
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||||
|
const batchSize = searchParams.get('batch_size') ?? '100'
|
||||||
|
const enrichOffset = searchParams.get('offset') ?? '0'
|
||||||
|
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
|
||||||
|
} else if (endpoint === 'obligation-dedup') {
|
||||||
|
const dryRun = searchParams.get('dry_run') ?? 'true'
|
||||||
|
const batchSize = searchParams.get('batch_size') ?? '0'
|
||||||
|
const dedupOffset = searchParams.get('offset') ?? '0'
|
||||||
|
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
|
||||||
} else if (endpoint === 'similarity-check') {
|
} else if (endpoint === 'similarity-check') {
|
||||||
const controlId = searchParams.get('id')
|
const controlId = searchParams.get('id')
|
||||||
if (!controlId) {
|
if (!controlId) {
|
||||||
|
|||||||
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
@@ -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,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
|
||||||
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,6 +333,71 @@ function AdvisoryBoardPageInner() {
|
|||||||
purposes: [] as string[],
|
purposes: [] as string[],
|
||||||
// Automation (single-select tile)
|
// Automation (single-select tile)
|
||||||
automation: '' as string,
|
automation: '' as string,
|
||||||
|
// 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 (single-select tile)
|
// Hosting (single-select tile)
|
||||||
hosting_provider: '' as string,
|
hosting_provider: '' as string,
|
||||||
hosting_region: '' as string,
|
hosting_region: '' as string,
|
||||||
@@ -420,7 +485,131 @@ function AdvisoryBoardPageInner() {
|
|||||||
retention_purpose: form.retention_purpose,
|
retention_purpose: form.retention_purpose,
|
||||||
contracts_list: form.contracts,
|
contracts_list: form.contracts,
|
||||||
subprocessors: form.subprocessors,
|
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,
|
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
|
const url = isEditMode
|
||||||
@@ -777,6 +966,567 @@ function AdvisoryBoardPageInner() {
|
|||||||
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
|
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* BetrVG Section */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Betriebsrat & Beschaeftigtendaten</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Relevant fuer deutsche Unternehmen mit Betriebsrat (§87 Abs.1 Nr.6 BetrVG).
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.employee_monitoring}
|
||||||
|
onChange={(e) => updateForm({ employee_monitoring: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900">System kann Verhalten/Leistung ueberwachen</span>
|
||||||
|
<p className="text-xs text-gray-500">Nutzungslogs, Produktivitaetskennzahlen, Kommunikationsanalyse</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.hr_decision_support}
|
||||||
|
onChange={(e) => updateForm({ hr_decision_support: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900">System unterstuetzt HR-Entscheidungen</span>
|
||||||
|
<p className="text-xs text-gray-500">Recruiting, Bewertung, Befoerderung, Kuendigung</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.works_council_consulted}
|
||||||
|
onChange={(e) => updateForm({ works_council_consulted: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-900">Betriebsrat wurde konsultiert</span>
|
||||||
|
<p className="text-xs text-gray-500">Betriebsvereinbarung liegt vor oder ist in Verhandlung</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain-specific questions — HR/Recruiting */}
|
||||||
|
{['hr', 'recruiting'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">HR & Recruiting — Hochrisiko-Pruefung</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 4 + AGG — Pflichtfragen bei KI im Personalbereich.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hr_automated_screening} onChange={(e) => updateForm({ hr_automated_screening: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Bewerber werden automatisch vorsortiert/gerankt</span><p className="text-xs text-gray-500">CV-Screening, Score-basierte Vorauswahl</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hr_automated_rejection} onChange={(e) => updateForm({ hr_automated_rejection: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Absagen werden automatisch versendet</span><p className="text-xs text-red-700">Art. 22 DSGVO: Vollautomatische Absagen grundsaetzlich unzulaessig!</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hr_agg_visible} onChange={(e) => updateForm({ hr_agg_visible: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">System kann AGG-Merkmale erkennen (Name, Foto, Alter)</span><p className="text-xs text-gray-500">Proxy-Diskriminierung: Name→Herkunft, Foto→Geschlecht</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hr_performance_eval} onChange={(e) => updateForm({ hr_performance_eval: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">System bewertet Mitarbeiterleistung</span><p className="text-xs text-gray-500">Performance Reviews, KPI-Tracking</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hr_bias_audits} onChange={(e) => updateForm({ hr_bias_audits: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Regelmaessige Bias-Audits durchgefuehrt</span><p className="text-xs text-green-700">Analyse nach Geschlecht, Alter, Herkunft</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hr_human_review} onChange={(e) => updateForm({ hr_human_review: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Mensch prueft jede KI-Empfehlung</span><p className="text-xs text-green-700">Kein Rubber Stamping — echte Pruefung</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Domain-specific questions — Education */}
|
||||||
|
{['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Bildung — Hochrisiko-Pruefung</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 3 — bei KI in Bildung und Ausbildung.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.edu_grade_influence} onChange={(e) => updateForm({ edu_grade_influence: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Noten oder Bewertungen</span><p className="text-xs text-gray-500">Notenvorschlaege, Bewertungsunterstuetzung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.edu_exam_evaluation} onChange={(e) => updateForm({ edu_exam_evaluation: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI bewertet Pruefungen/Klausuren</span><p className="text-xs text-gray-500">Automatische Korrektur, Bewertungsvorschlaege</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.edu_student_selection} onChange={(e) => updateForm({ edu_student_selection: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Zugang zu Bildungsangeboten</span><p className="text-xs text-gray-500">Zulassung, Kursempfehlungen, Einstufung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.edu_minors} onChange={(e) => updateForm({ edu_minors: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Minderjaehrige sind betroffen</span><p className="text-xs text-red-700">Besonderer Schutz (Art. 24 EU-Grundrechtecharta)</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.edu_teacher_review} onChange={(e) => updateForm({ edu_teacher_review: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Lehrkraft prueft jedes KI-Ergebnis</span><p className="text-xs text-green-700">Human Oversight vor Mitteilung an Schueler</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Domain-specific questions — Healthcare */}
|
||||||
|
{['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Gesundheitswesen — Hochrisiko-Pruefung</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 5 + MDR (EU) 2017/745.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hc_diagnosis} onChange={(e) => updateForm({ hc_diagnosis: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI unterstuetzt Diagnosen</span><p className="text-xs text-gray-500">Diagnosevorschlaege, Bildgebungsauswertung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hc_treatment} onChange={(e) => updateForm({ hc_treatment: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI empfiehlt Behandlungen</span><p className="text-xs text-gray-500">Therapievorschlaege, Medikation</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hc_triage} onChange={(e) => updateForm({ hc_triage: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">KI priorisiert Patienten (Triage)</span><p className="text-xs text-red-700">Lebenskritisch — erhoehte Anforderungen</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hc_patient_data} onChange={(e) => updateForm({ hc_patient_data: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Gesundheitsdaten verarbeitet</span><p className="text-xs text-gray-500">Art. 9 DSGVO — besondere Kategorie</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hc_medical_device} onChange={(e) => updateForm({ hc_medical_device: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">System ist Medizinprodukt (MDR)</span><p className="text-xs text-gray-500">MDR (EU) 2017/745 — Zertifizierung erforderlich</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hc_clinical_validation} onChange={(e) => updateForm({ hc_clinical_validation: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Klinisch validiert</span><p className="text-xs text-green-700">System wurde in klinischer Studie geprueft</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legal / Justice */}
|
||||||
|
{['legal', 'consulting', 'tax_advisory'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Recht & Beratung — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 8 — KI in Rechtspflege und Demokratie.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.leg_legal_advice} onChange={(e) => updateForm({ leg_legal_advice: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI gibt Rechtsberatung oder rechtliche Empfehlungen</span><p className="text-xs text-gray-500">Vertragsanalyse, rechtliche Einschaetzungen, Compliance-Checks</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.leg_court_prediction} onChange={(e) => updateForm({ leg_court_prediction: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI prognostiziert Verfahrensausgaenge</span><p className="text-xs text-gray-500">Urteilsprognosen, Risikoeinschaetzung von Rechtsstreitigkeiten</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.leg_client_confidential} onChange={(e) => updateForm({ leg_client_confidential: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Mandantengeheimnis betroffen</span><p className="text-xs text-gray-500">Vertrauliche Mandantendaten werden durch KI verarbeitet (§ 203 StGB)</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Public Sector */}
|
||||||
|
{['public_sector', 'defense', 'justice'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Oeffentlicher Sektor — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Art. 27 AI Act — FRIA-Pflicht fuer oeffentliche Stellen.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.pub_admin_decision} onChange={(e) => updateForm({ pub_admin_decision: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">KI beeinflusst Verwaltungsentscheidungen</span><p className="text-xs text-red-700">Bescheide, Bewilligungen, Genehmigungen — FRIA erforderlich</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.pub_benefit_allocation} onChange={(e) => updateForm({ pub_benefit_allocation: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI verteilt Leistungen oder Foerderung</span><p className="text-xs text-gray-500">Sozialleistungen, Subventionen, Zuteilungen</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.pub_transparency} onChange={(e) => updateForm({ pub_transparency: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Transparenz gegenueber Buergern sichergestellt</span><p className="text-xs text-green-700">Buerger werden ueber KI-Nutzung informiert</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Critical Infrastructure */}
|
||||||
|
{['energy', 'utilities', 'oil_gas'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Kritische Infrastruktur — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 2 + NIS2.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.crit_grid_control} onChange={(e) => updateForm({ crit_grid_control: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI steuert Netz oder Infrastruktur</span><p className="text-xs text-gray-500">Stromnetz, Wasserversorgung, Gasverteilung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.crit_safety_critical} onChange={(e) => updateForm({ crit_safety_critical: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Sicherheitskritische Steuerung</span><p className="text-xs text-red-700">Fehler koennen Menschenleben gefaehrden</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.crit_redundancy} onChange={(e) => updateForm({ crit_redundancy: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Redundante Systeme vorhanden</span><p className="text-xs text-green-700">Fallback bei KI-Ausfall sichergestellt</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Automotive / Aerospace */}
|
||||||
|
{['automotive', 'aerospace'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Automotive / Aerospace — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Safety-critical AI — Typgenehmigung + Functional Safety.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.auto_autonomous} onChange={(e) => updateForm({ auto_autonomous: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Autonomes Fahren / ADAS</span><p className="text-xs text-red-700">Hochrisiko — erfordert Typgenehmigung und extensive Validierung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.auto_safety} onChange={(e) => updateForm({ auto_safety: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Sicherheitsrelevante Funktion</span><p className="text-xs text-gray-500">Bremsen, Lenkung, Kollisionsvermeidung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.auto_functional_safety} onChange={(e) => updateForm({ auto_functional_safety: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">ISO 26262 Functional Safety beruecksichtigt</span><p className="text-xs text-green-700">ASIL-Einstufung und Sicherheitsvalidierung durchgefuehrt</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Retail / E-Commerce */}
|
||||||
|
{['retail', 'ecommerce', 'wholesale'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Handel & E-Commerce — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">DSA, Verbraucherrecht, DSGVO Art. 22.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ret_pricing} onChange={(e) => updateForm({ ret_pricing: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Personalisierte Preisgestaltung</span><p className="text-xs text-gray-500">Individuelle Preise basierend auf Nutzerprofil</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ret_credit_scoring} onChange={(e) => updateForm({ ret_credit_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Bonitaetspruefung bei Kauf auf Rechnung</span><p className="text-xs text-gray-500">Kredit-Scoring beeinflusst Zugang zu Zahlungsarten</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ret_dark_patterns} onChange={(e) => updateForm({ ret_dark_patterns: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Manipulative UI-Muster moeglich (Dark Patterns)</span><p className="text-xs text-gray-500">Kuenstliche Verknappung, Social Proof, versteckte Kosten</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* IT / Cybersecurity / Telecom */}
|
||||||
|
{['it_services', 'cybersecurity', 'telecom'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">IT & Cybersecurity — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">NIS2, DSGVO, BetrVG §87.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.its_surveillance} onChange={(e) => updateForm({ its_surveillance: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Mitarbeiterueberwachung (SIEM, DLP, UBA)</span><p className="text-xs text-gray-500">User Behavior Analytics, Data Loss Prevention mit Personenbezug</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.its_threat_detection} onChange={(e) => updateForm({ its_threat_detection: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI-gestuetzte Bedrohungserkennung</span><p className="text-xs text-gray-500">Anomalie-Erkennung, Intrusion Detection</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.its_data_retention} onChange={(e) => updateForm({ its_data_retention: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Umfangreiche Log-Speicherung</span><p className="text-xs text-gray-500">Security-Logs mit Personenbezug werden langfristig gespeichert</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logistics */}
|
||||||
|
{['logistics'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Logistik — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">BetrVG §87, DSGVO — Worker Tracking.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.log_driver_tracking} onChange={(e) => updateForm({ log_driver_tracking: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Fahrer-/Kurier-Tracking (GPS)</span><p className="text-xs text-gray-500">Standortverfolgung von Mitarbeitern</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.log_workload_scoring} onChange={(e) => updateForm({ log_workload_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Leistungsbewertung von Lager-/Liefermitarbeitern</span><p className="text-xs text-gray-500">Picks/Stunde, Liefergeschwindigkeit, Performance-Scores</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Construction / Real Estate */}
|
||||||
|
{['construction', 'real_estate', 'facility_management'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Bau & Immobilien — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">AGG, DSGVO, Arbeitsschutz.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.con_tenant_screening} onChange={(e) => updateForm({ con_tenant_screening: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI-gestuetzte Mieterauswahl</span><p className="text-xs text-gray-500">Bonitaetspruefung, Bewerber-Ranking fuer Wohnungen</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.con_worker_safety} onChange={(e) => updateForm({ con_worker_safety: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI-Arbeitsschutzueberwachung auf Baustellen</span><p className="text-xs text-gray-500">Kamera-basierte Sicherheitsueberwachung, Helm-Erkennung</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Marketing / Media */}
|
||||||
|
{['marketing', 'media', 'entertainment'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Marketing & Medien — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Art. 50 AI Act (Deepfakes), DSA, DSGVO.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mkt_deepfake} onChange={(e) => updateForm({ mkt_deepfake: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Synthetische Inhalte (Deepfakes)</span><p className="text-xs text-red-700">KI-generierte Bilder, Videos oder Stimmen — Kennzeichnungspflicht!</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mkt_targeting} onChange={(e) => updateForm({ mkt_targeting: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Verhaltensbasiertes Targeting</span><p className="text-xs text-gray-500">Personalisierte Werbung basierend auf Nutzerverhalten</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mkt_minors} onChange={(e) => updateForm({ mkt_minors: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Minderjaehrige als Zielgruppe</span><p className="text-xs text-red-700">Besonderer Schutz — DSA Art. 28 verbietet Profiling Minderjaehriger</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mkt_labeled} onChange={(e) => updateForm({ mkt_labeled: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">KI-Inhalte werden als solche gekennzeichnet</span><p className="text-xs text-green-700">Art. 50 AI Act: Pflicht zur Kennzeichnung synthetischer Inhalte</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manufacturing */}
|
||||||
|
{['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage', 'textiles', 'packaging'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Fertigung — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Maschinenverordnung (EU) 2023/1230, CE-Kennzeichnung.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mfg_machine_safety} onChange={(e) => updateForm({ mfg_machine_safety: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">KI in Maschinensicherheit</span><p className="text-xs text-red-700">Sicherheitsrelevante Steuerung — Validierung erforderlich</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mfg_ce_required} onChange={(e) => updateForm({ mfg_ce_required: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">CE-Kennzeichnung erforderlich</span><p className="text-xs text-gray-500">Maschinenverordnung (EU) 2023/1230</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.mfg_validated} onChange={(e) => updateForm({ mfg_validated: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
|
||||||
|
<div><span className="text-sm font-medium text-green-900">Sicherheitsvalidierung durchgefuehrt</span><p className="text-xs text-green-700">Konformitaetsbewertung nach Maschinenverordnung abgeschlossen</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agriculture */}
|
||||||
|
{['agriculture', 'forestry', 'fishing'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Landwirtschaft — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.agr_pesticide} onChange={(e) => updateForm({ agr_pesticide: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI steuert Pestizideinsatz</span><p className="text-xs text-gray-500">Precision Farming, automatisierte Ausbringung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.agr_animal_welfare} onChange={(e) => updateForm({ agr_animal_welfare: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Tierhaltungsentscheidungen</span><p className="text-xs text-gray-500">Fuetterung, Gesundheit, Stallmanagement</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.agr_environmental} onChange={(e) => updateForm({ agr_environmental: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Umweltdaten werden verarbeitet</span><p className="text-xs text-gray-500">Boden, Wasser, Emissionen</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Social Services */}
|
||||||
|
{['social_services', 'nonprofit'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Soziales — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.soc_vulnerable} onChange={(e) => updateForm({ soc_vulnerable: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Schutzbeduerftiger Personenkreis betroffen</span><p className="text-xs text-red-700">Kinder, Senioren, Gefluechtete, Menschen mit Behinderung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.soc_benefit} onChange={(e) => updateForm({ soc_benefit: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Leistungszuteilung</span><p className="text-xs text-gray-500">Sozialleistungen, Hilfsangebote, Foerderung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.soc_case_mgmt} onChange={(e) => updateForm({ soc_case_mgmt: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI in Fallmanagement</span><p className="text-xs text-gray-500">Priorisierung, Zuordnung, Verlaufsprognose</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hospitality / Tourism */}
|
||||||
|
{['hospitality', 'tourism'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Tourismus & Gastronomie — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hos_guest_profiling} onChange={(e) => updateForm({ hos_guest_profiling: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Gaeste-Profilbildung</span><p className="text-xs text-gray-500">Praeferenzen, Buchungsverhalten, Segmentierung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hos_dynamic_pricing} onChange={(e) => updateForm({ hos_dynamic_pricing: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Dynamische Preisgestaltung</span><p className="text-xs text-gray-500">Personalisierte Zimmer-/Flugreise</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.hos_review_manipulation} onChange={(e) => updateForm({ hos_review_manipulation: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">KI manipuliert oder generiert Bewertungen</span><p className="text-xs text-red-700">Fake Reviews sind unzulaessig (UWG, DSA)</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Insurance */}
|
||||||
|
{['insurance'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Versicherung — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ins_premium} onChange={(e) => updateForm({ ins_premium: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI berechnet individuelle Praemien</span><p className="text-xs text-gray-500">Risikoadjustierte Preisgestaltung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ins_claims} onChange={(e) => updateForm({ ins_claims: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Automatisierte Schadenbearbeitung</span><p className="text-xs text-gray-500">KI entscheidet ueber Schadenregulierung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ins_fraud} onChange={(e) => updateForm({ ins_fraud: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI-Betrugserkennung</span><p className="text-xs text-gray-500">Automatische Verdachtsfallerkennung</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Investment */}
|
||||||
|
{['investment'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Investment — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.inv_algo_trading} onChange={(e) => updateForm({ inv_algo_trading: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Algorithmischer Handel</span><p className="text-xs text-gray-500">Automated Trading, HFT — MiFID II relevant</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.inv_robo} onChange={(e) => updateForm({ inv_robo: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Robo Advisor / KI-Anlageberatung</span><p className="text-xs text-gray-500">Automatisierte Vermoegensberatung — WpHG-Pflichten</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Defense */}
|
||||||
|
{['defense'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Verteidigung — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.def_dual_use} onChange={(e) => updateForm({ def_dual_use: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
|
||||||
|
<div><span className="text-sm font-medium text-red-900">Dual-Use KI-Technologie</span><p className="text-xs text-red-700">Exportkontrolle (EU VO 2021/821) beachten</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.def_classified} onChange={(e) => updateForm({ def_classified: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Verschlusssachen werden verarbeitet</span><p className="text-xs text-gray-500">VS-NfD oder hoeher — besondere Schutzmassnahmen</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supply Chain (Textiles, Packaging) */}
|
||||||
|
{['textiles', 'packaging'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Lieferkette — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">LkSG — Lieferkettensorgfaltspflichtengesetz.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.sch_supplier} onChange={(e) => updateForm({ sch_supplier: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI ueberwacht Lieferanten</span><p className="text-xs text-gray-500">Lieferantenbewertung, Risikoanalyse</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.sch_human_rights} onChange={(e) => updateForm({ sch_human_rights: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI prueft Menschenrechte in Lieferkette</span><p className="text-xs text-gray-500">LkSG-Sorgfaltspflichten, Kinderarbeit, Zwangsarbeit</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sports */}
|
||||||
|
{['sports'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Sport — Compliance-Fragen</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.spo_athlete} onChange={(e) => updateForm({ spo_athlete: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Athleten-Performance-Tracking</span><p className="text-xs text-gray-500">GPS, Biometrie, Leistungsdaten</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.spo_fan} onChange={(e) => updateForm({ spo_fan: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Fan-/Zuschauer-Profilbildung</span><p className="text-xs text-gray-500">Ticketing, Merchandising, Stadion-Tracking</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finance / Banking */}
|
||||||
|
{['finance', 'banking'].includes(form.domain) && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Finanzdienstleistungen — Compliance-Fragen</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">DORA, MaRisk, BAIT, AI Act Annex III Nr. 5.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.fin_credit_scoring} onChange={(e) => updateForm({ fin_credit_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">KI-gestuetztes Kredit-Scoring</span><p className="text-xs text-gray-500">Bonitaetsbewertung, Kreditwuerdigkeitspruefung — Art. 22 DSGVO + AGG</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.fin_aml_kyc} onChange={(e) => updateForm({ fin_aml_kyc: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">AML/KYC Automatisierung</span><p className="text-xs text-gray-500">Geldwaeschebekacmpfung, Kundenidentifizierung durch KI</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.fin_algo_decisions} onChange={(e) => updateForm({ fin_algo_decisions: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Automatisierte Finanzentscheidungen</span><p className="text-xs text-gray-500">Kreditvergabe, Kontosperrung, Limitaenderungen</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.fin_customer_profiling} onChange={(e) => updateForm({ fin_customer_profiling: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Kunden-Profilbildung / Segmentierung</span><p className="text-xs text-gray-500">Risikoklassifikation, Produkt-Empfehlungen</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* General — universal AI governance questions */}
|
||||||
|
{form.domain === 'general' && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">Allgemeine KI-Governance</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Grundlegende Compliance-Fragen fuer jeden KI-Einsatz.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.gen_affects_people} onChange={(e) => updateForm({ gen_affects_people: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">System hat Auswirkungen auf natuerliche Personen</span><p className="text-xs text-gray-500">Entscheidungen, Empfehlungen oder Bewertungen betreffen Menschen direkt</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.gen_automated_decisions} onChange={(e) => updateForm({ gen_automated_decisions: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Automatisierte Entscheidungen werden getroffen</span><p className="text-xs text-gray-500">KI trifft oder beeinflusst Entscheidungen ohne menschliche Pruefung</p></div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.gen_sensitive_data} onChange={(e) => updateForm({ gen_sensitive_data: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||||
|
<div><span className="text-sm font-medium text-gray-900">Sensible oder vertrauliche Daten verarbeitet</span><p className="text-xs text-gray-500">Geschaeftsgeheimnisse, personenbezogene Daten, vertrauliche Informationen</p></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -21,6 +22,8 @@ interface AISystem {
|
|||||||
assessmentResult: Record<string, unknown> | null
|
assessmentResult: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// LOADING SKELETON
|
// LOADING SKELETON
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -306,12 +309,178 @@ function AISystemCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SAVED RESULTS TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface SavedResult {
|
||||||
|
id: string
|
||||||
|
system_name: string
|
||||||
|
system_description?: string
|
||||||
|
high_risk_result: string
|
||||||
|
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||||
|
combined_obligations: string[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedResultsTab() {
|
||||||
|
const [results, setResults] = useState<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
|
// MAIN PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export default function AIActPage() {
|
export default function AIActPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||||
const [systems, setSystems] = useState<AISystem[]>([])
|
const [systems, setSystems] = useState<AISystem[]>([])
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
@@ -354,7 +523,6 @@ export default function AIActPage() {
|
|||||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||||
setError(null)
|
setError(null)
|
||||||
if (editingSystem) {
|
if (editingSystem) {
|
||||||
// Edit existing system via PUT
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -380,14 +548,12 @@ export default function AIActPage() {
|
|||||||
setError('Speichern fehlgeschlagen')
|
setError('Speichern fehlgeschlagen')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: update locally
|
|
||||||
setSystems(prev => prev.map(s =>
|
setSystems(prev => prev.map(s =>
|
||||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
setEditingSystem(null)
|
setEditingSystem(null)
|
||||||
} else {
|
} else {
|
||||||
// Create new system via POST
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -415,7 +581,6 @@ export default function AIActPage() {
|
|||||||
setError('Registrierung fehlgeschlagen')
|
setError('Registrierung fehlgeschlagen')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: add locally
|
|
||||||
const newSystem: AISystem = {
|
const newSystem: AISystem = {
|
||||||
...data,
|
...data,
|
||||||
id: `ai-${Date.now()}`,
|
id: `ai-${Date.now()}`,
|
||||||
@@ -503,17 +668,37 @@ export default function AIActPage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
tips={stepInfo.tips}
|
||||||
>
|
>
|
||||||
<button
|
{activeTab === 'overview' && (
|
||||||
onClick={() => setShowAddForm(true)}
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
KI-System registrieren
|
</svg>
|
||||||
</button>
|
KI-System registrieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</StepHeader>
|
</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 Banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
@@ -522,90 +707,105 @@ export default function AIActPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add/Edit System Form */}
|
{/* Tab: Overview */}
|
||||||
{showAddForm && (
|
{activeTab === 'overview' && (
|
||||||
<AddSystemForm
|
<>
|
||||||
onSubmit={handleAddSystem}
|
{/* Add/Edit System Form */}
|
||||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
{showAddForm && (
|
||||||
initialData={editingSystem}
|
<AddSystemForm
|
||||||
/>
|
onSubmit={handleAddSystem}
|
||||||
)}
|
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||||
|
initialData={editingSystem}
|
||||||
{/* 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>
|
|
||||||
|
{/* 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 && (
|
{/* Tab: Decision Tree */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
{activeTab === 'decision-tree' && (
|
||||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
<DecisionTreeWizard />
|
||||||
<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>
|
{/* Tab: Results */}
|
||||||
</div>
|
{activeTab === 'results' && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
<SavedResultsTab />
|
||||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Registration {
|
||||||
|
id: string
|
||||||
|
system_name: string
|
||||||
|
system_version: string
|
||||||
|
risk_classification: string
|
||||||
|
gpai_classification: string
|
||||||
|
registration_status: string
|
||||||
|
eu_database_id: string
|
||||||
|
provider_name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||||
|
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||||
|
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||||
|
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||||
|
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||||
|
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||||
|
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||||
|
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||||
|
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
|
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM = {
|
||||||
|
system_name: '',
|
||||||
|
system_version: '1.0',
|
||||||
|
system_description: '',
|
||||||
|
intended_purpose: '',
|
||||||
|
provider_name: '',
|
||||||
|
provider_legal_form: '',
|
||||||
|
provider_address: '',
|
||||||
|
provider_country: 'DE',
|
||||||
|
eu_representative_name: '',
|
||||||
|
eu_representative_contact: '',
|
||||||
|
risk_classification: 'not_classified',
|
||||||
|
annex_iii_category: '',
|
||||||
|
gpai_classification: 'none',
|
||||||
|
conformity_assessment_type: 'internal',
|
||||||
|
notified_body_name: '',
|
||||||
|
notified_body_id: '',
|
||||||
|
ce_marking: false,
|
||||||
|
training_data_summary: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIRegistrationPage() {
|
||||||
|
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showWizard, setShowWizard] = useState(false)
|
||||||
|
const [wizardStep, setWizardStep] = useState(1)
|
||||||
|
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => { loadRegistrations() }, [])
|
||||||
|
|
||||||
|
async function loadRegistrations() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json()
|
||||||
|
setRegistrations(data.registrations || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Fehler beim Laden')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
setShowWizard(false)
|
||||||
|
setForm({ ...INITIAL_FORM })
|
||||||
|
setWizardStep(1)
|
||||||
|
loadRegistrations()
|
||||||
|
} else {
|
||||||
|
const data = await resp.json()
|
||||||
|
setError(data.error || 'Fehler beim Erstellen')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport(id: string) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||||
|
if (resp.ok) {
|
||||||
|
const reg = await resp.json()
|
||||||
|
// Build export JSON client-side
|
||||||
|
const exportData = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
submission_type: 'ai_system_registration',
|
||||||
|
regulation: 'EU AI Act (EU) 2024/1689',
|
||||||
|
article: 'Art. 49',
|
||||||
|
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||||
|
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||||
|
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||||
|
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||||
|
}
|
||||||
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Export fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(id: string, status: string) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
loadRegistrations()
|
||||||
|
} catch {
|
||||||
|
setError('Status-Aenderung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||||
|
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||||
|
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||||
|
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||||
|
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||||
|
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWizard(true)}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Neue Registrierung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||||
|
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||||
|
const count = registrations.filter(r => r.registration_status === status).length
|
||||||
|
const style = STATUS_STYLES[status]
|
||||||
|
return (
|
||||||
|
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||||
|
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||||
|
<div className="text-sm text-gray-600">{style.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registrations List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||||
|
) : registrations.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||||
|
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{registrations.map(reg => {
|
||||||
|
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||||
|
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||||
|
return (
|
||||||
|
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||||
|
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||||
|
{reg.gpai_classification !== 'none' && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||||
|
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||||
|
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
JSON Export
|
||||||
|
</button>
|
||||||
|
{reg.registration_status === 'draft' && (
|
||||||
|
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
Bereit markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{reg.registration_status === 'ready' && (
|
||||||
|
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||||
|
Als eingereicht markieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wizard Modal */}
|
||||||
|
{showWizard && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||||
|
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||||
|
</div>
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STEPS.map(step => (
|
||||||
|
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||||
|
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||||
|
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||||
|
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Step 1: Provider */}
|
||||||
|
{wizardStep === 1 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||||
|
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||||
|
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||||
|
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||||
|
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||||
|
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="DE">Deutschland</option>
|
||||||
|
<option value="AT">Oesterreich</option>
|
||||||
|
<option value="CH">Schweiz</option>
|
||||||
|
<option value="OTHER">Anderes Land</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||||
|
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: System */}
|
||||||
|
{wizardStep === 2 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||||
|
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||||
|
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||||
|
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||||
|
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Classification */}
|
||||||
|
{wizardStep === 3 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||||
|
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||||
|
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||||
|
<option value="minimal_risk">Minimal Risk</option>
|
||||||
|
<option value="limited_risk">Limited Risk</option>
|
||||||
|
<option value="high_risk">High Risk</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.risk_classification === 'high_risk' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||||
|
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="">Bitte waehlen...</option>
|
||||||
|
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||||
|
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||||
|
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||||
|
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||||
|
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||||
|
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||||
|
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||||
|
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||||
|
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="none">Kein GPAI</option>
|
||||||
|
<option value="standard">GPAI (Standard)</option>
|
||||||
|
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||||
|
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Conformity */}
|
||||||
|
{wizardStep === 4 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||||
|
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||||
|
<option value="not_required">Nicht erforderlich</option>
|
||||||
|
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||||
|
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form.conformity_assessment_type === 'third_party' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||||
|
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body ID</label>
|
||||||
|
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Training Data */}
|
||||||
|
{wizardStep === 5 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||||
|
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||||
|
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Review */}
|
||||||
|
{wizardStep === 6 && (
|
||||||
|
<>
|
||||||
|
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||||
|
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||||
|
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||||
|
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||||
|
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||||
|
</div>
|
||||||
|
{form.intended_purpose && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||||
|
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="p-6 border-t flex justify-between">
|
||||||
|
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||||
|
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||||
|
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
|
</button>
|
||||||
|
{wizardStep < 6 ? (
|
||||||
|
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||||
|
disabled={wizardStep === 2 && !form.system_name}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||||
|
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -308,7 +308,7 @@ export default function AtomicControlsPage() {
|
|||||||
<StateBadge state={ctrl.release_state} />
|
<StateBadge state={ctrl.release_state} />
|
||||||
<CategoryBadge category={ctrl.category} />
|
<CategoryBadge category={ctrl.category} />
|
||||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
|
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
|
||||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||||
ObligationTypeBadge, GenerationStrategyBadge,
|
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
|
||||||
ExtractionMethodBadge, RegulationCountBadge,
|
ExtractionMethodBadge, RegulationCountBadge,
|
||||||
VERIFICATION_METHODS, CATEGORY_OPTIONS,
|
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
|
||||||
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
|
|
||||||
@@ -65,6 +65,20 @@ interface TraceabilityData {
|
|||||||
regulations_summary?: RegulationSummary[]
|
regulations_summary?: RegulationSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface V1Match {
|
||||||
|
matched_control_id: string
|
||||||
|
matched_title: string
|
||||||
|
matched_objective: string
|
||||||
|
matched_severity: string
|
||||||
|
matched_category: string
|
||||||
|
matched_source: string | null
|
||||||
|
matched_article: string | null
|
||||||
|
matched_source_citation: Record<string, string> | null
|
||||||
|
similarity_score: number
|
||||||
|
match_rank: number
|
||||||
|
match_method: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ControlDetailProps {
|
interface ControlDetailProps {
|
||||||
ctrl: CanonicalControl
|
ctrl: CanonicalControl
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
@@ -73,6 +87,7 @@ interface ControlDetailProps {
|
|||||||
onReview: (controlId: string, action: string) => void
|
onReview: (controlId: string, action: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onNavigateToControl?: (controlId: string) => void
|
onNavigateToControl?: (controlId: string) => void
|
||||||
|
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
|
||||||
// Review mode navigation
|
// Review mode navigation
|
||||||
reviewMode?: boolean
|
reviewMode?: boolean
|
||||||
reviewIndex?: number
|
reviewIndex?: number
|
||||||
@@ -89,6 +104,7 @@ export function ControlDetail({
|
|||||||
onReview,
|
onReview,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onNavigateToControl,
|
onNavigateToControl,
|
||||||
|
onCompare,
|
||||||
reviewMode,
|
reviewMode,
|
||||||
reviewIndex = 0,
|
reviewIndex = 0,
|
||||||
reviewTotal = 0,
|
reviewTotal = 0,
|
||||||
@@ -101,6 +117,9 @@ export function ControlDetail({
|
|||||||
const [merging, setMerging] = useState(false)
|
const [merging, setMerging] = useState(false)
|
||||||
const [traceability, setTraceability] = useState<TraceabilityData | null>(null)
|
const [traceability, setTraceability] = useState<TraceabilityData | null>(null)
|
||||||
const [loadingTrace, setLoadingTrace] = useState(false)
|
const [loadingTrace, setLoadingTrace] = useState(false)
|
||||||
|
const [v1Matches, setV1Matches] = useState<V1Match[]>([])
|
||||||
|
const [loadingV1, setLoadingV1] = useState(false)
|
||||||
|
const eigenentwicklung = isEigenentwicklung(ctrl)
|
||||||
|
|
||||||
const loadTraceability = useCallback(async () => {
|
const loadTraceability = useCallback(async () => {
|
||||||
setLoadingTrace(true)
|
setLoadingTrace(true)
|
||||||
@@ -117,9 +136,21 @@ export function ControlDetail({
|
|||||||
finally { setLoadingTrace(false) }
|
finally { setLoadingTrace(false) }
|
||||||
}, [ctrl.control_id])
|
}, [ctrl.control_id])
|
||||||
|
|
||||||
|
const loadV1Matches = useCallback(async () => {
|
||||||
|
if (!eigenentwicklung) { setV1Matches([]); return }
|
||||||
|
setLoadingV1(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
|
||||||
|
if (res.ok) setV1Matches(await res.json())
|
||||||
|
else setV1Matches([])
|
||||||
|
} catch { setV1Matches([]) }
|
||||||
|
finally { setLoadingV1(false) }
|
||||||
|
}, [ctrl.control_id, eigenentwicklung])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSimilarControls()
|
loadSimilarControls()
|
||||||
loadTraceability()
|
loadTraceability()
|
||||||
|
loadV1Matches()
|
||||||
setSelectedDuplicates(new Set())
|
setSelectedDuplicates(new Set())
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ctrl.control_id])
|
}, [ctrl.control_id])
|
||||||
@@ -185,8 +216,9 @@ export function ControlDetail({
|
|||||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||||
<CategoryBadge category={ctrl.category} />
|
<CategoryBadge category={ctrl.category} />
|
||||||
|
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
|
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
|
||||||
@@ -302,6 +334,75 @@ export function ControlDetail({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Regulatorische Abdeckung (Eigenentwicklung) */}
|
||||||
|
{eigenentwicklung && (
|
||||||
|
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Scale className="w-4 h-4 text-orange-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-orange-900">
|
||||||
|
Regulatorische Abdeckung
|
||||||
|
</h3>
|
||||||
|
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
|
||||||
|
</div>
|
||||||
|
{v1Matches.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{v1Matches.map((match, i) => (
|
||||||
|
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
{match.matched_source && (
|
||||||
|
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">
|
||||||
|
{match.matched_source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{match.matched_article && (
|
||||||
|
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">
|
||||||
|
{match.matched_article}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||||
|
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||||
|
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{(match.similarity_score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-800">
|
||||||
|
{onNavigateToControl ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigateToControl(match.matched_control_id)}
|
||||||
|
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5"
|
||||||
|
>
|
||||||
|
{match.matched_control_id}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">
|
||||||
|
{match.matched_control_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{match.matched_title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onCompare && (
|
||||||
|
<button
|
||||||
|
onClick={() => onCompare(ctrl, v1Matches)}
|
||||||
|
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0"
|
||||||
|
>
|
||||||
|
Vergleichen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !loadingV1 ? (
|
||||||
|
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
|
||||||
{traceability && traceability.parent_links.length > 0 && (
|
{traceability && traceability.parent_links.length > 0 && (
|
||||||
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
// Compact Control Panel (used on both sides of the comparison)
|
// Compact Control Panel (used on both sides of the comparison)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
export function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
|
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
|
||||||
{/* Panel Header */}
|
{/* Panel Header */}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowLeft, ChevronLeft, SkipForward, Scale,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||||
|
import { ControlPanel } from './ReviewCompare'
|
||||||
|
|
||||||
|
interface V1Match {
|
||||||
|
matched_control_id: string
|
||||||
|
matched_title: string
|
||||||
|
matched_objective: string
|
||||||
|
matched_severity: string
|
||||||
|
matched_category: string
|
||||||
|
matched_source: string | null
|
||||||
|
matched_article: string | null
|
||||||
|
matched_source_citation: Record<string, string> | null
|
||||||
|
similarity_score: number
|
||||||
|
match_rank: number
|
||||||
|
match_method: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface V1CompareViewProps {
|
||||||
|
v1Control: CanonicalControl
|
||||||
|
matches: V1Match[]
|
||||||
|
onBack: () => void
|
||||||
|
onNavigateToControl?: (controlId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function V1CompareView({ v1Control, matches, onBack, onNavigateToControl }: V1CompareViewProps) {
|
||||||
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
||||||
|
const [matchedControl, setMatchedControl] = useState<CanonicalControl | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const currentMatch = matches[currentMatchIndex]
|
||||||
|
|
||||||
|
// Load the full matched control when index changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentMatch) return
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(currentMatch.matched_control_id)}`)
|
||||||
|
if (res.ok) {
|
||||||
|
setMatchedControl(await res.json())
|
||||||
|
} else {
|
||||||
|
setMatchedControl(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMatchedControl(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [currentMatch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Scale className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-semibold text-gray-900">V1-Vergleich</span>
|
||||||
|
{currentMatch && (
|
||||||
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||||
|
currentMatch.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||||
|
currentMatch.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{(currentMatch.similarity_score * 100).toFixed(1)}% Aehnlichkeit
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentMatchIndex(Math.max(0, currentMatchIndex - 1))}
|
||||||
|
disabled={currentMatchIndex === 0}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-500 font-medium">
|
||||||
|
{currentMatchIndex + 1} / {matches.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentMatchIndex(Math.min(matches.length - 1, currentMatchIndex + 1))}
|
||||||
|
disabled={currentMatchIndex >= matches.length - 1}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<SkipForward className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigate to matched control */}
|
||||||
|
{onNavigateToControl && matchedControl && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onBack(); onNavigateToControl(matchedControl.control_id) }}
|
||||||
|
className="px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50"
|
||||||
|
>
|
||||||
|
Zum Control
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source info bar */}
|
||||||
|
{currentMatch && (currentMatch.matched_source || currentMatch.matched_article) && (
|
||||||
|
<div className="px-6 py-2 bg-blue-50 border-b border-blue-200 flex items-center gap-2 text-sm">
|
||||||
|
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||||
|
{currentMatch.matched_source && (
|
||||||
|
<span className="font-semibold text-blue-900">{currentMatch.matched_source}</span>
|
||||||
|
)}
|
||||||
|
{currentMatch.matched_article && (
|
||||||
|
<span className="text-blue-700">{currentMatch.matched_article}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Side-by-Side Panels */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Left: V1 Eigenentwicklung */}
|
||||||
|
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||||
|
<ControlPanel ctrl={v1Control} label="Eigenentwicklung" highlight />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Regulatory match */}
|
||||||
|
<div className="w-1/2 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : matchedControl ? (
|
||||||
|
<ControlPanel ctrl={matchedControl} label="Regulatorisch gedeckt" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||||
|
Control konnte nicht geladen werden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export interface CanonicalControl {
|
|||||||
customer_visible?: boolean
|
customer_visible?: boolean
|
||||||
verification_method: string | null
|
verification_method: string | null
|
||||||
category: string | null
|
category: string | null
|
||||||
|
evidence_type: string | null
|
||||||
target_audience: string | string[] | null
|
target_audience: string | string[] | null
|
||||||
generation_metadata?: Record<string, unknown> | null
|
generation_metadata?: Record<string, unknown> | null
|
||||||
generation_strategy?: string | null
|
generation_strategy?: string | null
|
||||||
@@ -51,6 +52,7 @@ export interface CanonicalControl {
|
|||||||
parent_control_id?: string | null
|
parent_control_id?: string | null
|
||||||
parent_control_title?: string | null
|
parent_control_title?: string | null
|
||||||
decomposition_method?: string | null
|
decomposition_method?: string | null
|
||||||
|
pipeline_version?: number | string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ export const EMPTY_CONTROL = {
|
|||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
verification_method: null as string | null,
|
verification_method: null as string | null,
|
||||||
category: null as string | null,
|
category: null as string | null,
|
||||||
|
evidence_type: null as string | null,
|
||||||
target_audience: null as string | null,
|
target_audience: null as string | null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +148,18 @@ export const CATEGORY_OPTIONS = [
|
|||||||
{ value: 'identity', label: 'Identitaetsmanagement' },
|
{ value: 'identity', label: 'Identitaetsmanagement' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const EVIDENCE_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||||
|
code: { bg: 'bg-sky-100 text-sky-700', label: 'Code' },
|
||||||
|
process: { bg: 'bg-amber-100 text-amber-700', label: 'Prozess' },
|
||||||
|
hybrid: { bg: 'bg-violet-100 text-violet-700', label: 'Hybrid' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVIDENCE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'code', label: 'Code — Technisch (Source Code, IaC, CI/CD)' },
|
||||||
|
{ value: 'process', label: 'Prozess — Organisatorisch (Dokumente, Policies)' },
|
||||||
|
{ value: 'hybrid', label: 'Hybrid — Code + Prozess' },
|
||||||
|
]
|
||||||
|
|
||||||
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
||||||
// Legacy English keys
|
// Legacy English keys
|
||||||
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||||
@@ -244,6 +259,13 @@ export function CategoryBadge({ category }: { category: string | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EvidenceTypeBadge({ type }: { type: string | null }) {
|
||||||
|
if (!type) return null
|
||||||
|
const config = EVIDENCE_TYPE_CONFIG[type]
|
||||||
|
if (!config) return null
|
||||||
|
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
||||||
if (!audience) return null
|
if (!audience) return null
|
||||||
|
|
||||||
@@ -272,7 +294,29 @@ export function TargetAudienceBadge({ audience }: { audience: string | string[]
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GenerationStrategyBadge({ strategy }: { strategy: string | null | undefined }) {
|
export interface CanonicalControlPipelineInfo {
|
||||||
|
pipeline_version?: number | string | null
|
||||||
|
source_citation?: Record<string, string> | null
|
||||||
|
parent_control_uuid?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEigenentwicklung(ctrl: CanonicalControlPipelineInfo & { generation_strategy?: string | null }): boolean {
|
||||||
|
return (
|
||||||
|
(!ctrl.generation_strategy || ctrl.generation_strategy === 'ungrouped') &&
|
||||||
|
(!ctrl.pipeline_version || String(ctrl.pipeline_version) === '1') &&
|
||||||
|
!ctrl.source_citation &&
|
||||||
|
!ctrl.parent_control_uuid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerationStrategyBadge({ strategy, pipelineInfo }: {
|
||||||
|
strategy: string | null | undefined
|
||||||
|
pipelineInfo?: CanonicalControlPipelineInfo & { generation_strategy?: string | null }
|
||||||
|
}) {
|
||||||
|
// Eigenentwicklung detection: v1 + no source + no parent
|
||||||
|
if (pipelineInfo && isEigenentwicklung(pipelineInfo)) {
|
||||||
|
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-700">Eigenentwicklung</span>
|
||||||
|
}
|
||||||
if (!strategy || strategy === 'ungrouped') {
|
if (!strategy || strategy === 'ungrouped') {
|
||||||
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">v1</span>
|
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">v1</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL,
|
CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL,
|
||||||
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
|
||||||
GenerationStrategyBadge, ObligationTypeBadge,
|
GenerationStrategyBadge, ObligationTypeBadge,
|
||||||
VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS,
|
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS, TARGET_AUDIENCE_OPTIONS,
|
||||||
} from './components/helpers'
|
} from './components/helpers'
|
||||||
import { ControlForm } from './components/ControlForm'
|
import { ControlForm } from './components/ControlForm'
|
||||||
import { ControlDetail } from './components/ControlDetail'
|
import { ControlDetail } from './components/ControlDetail'
|
||||||
import { ReviewCompare } from './components/ReviewCompare'
|
import { ReviewCompare } from './components/ReviewCompare'
|
||||||
|
import { V1CompareView } from './components/V1CompareView'
|
||||||
import { GeneratorModal } from './components/GeneratorModal'
|
import { GeneratorModal } from './components/GeneratorModal'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -26,6 +27,16 @@ interface ControlsMeta {
|
|||||||
domains: Array<{ domain: string; count: number }>
|
domains: Array<{ domain: string; count: number }>
|
||||||
sources: Array<{ source: string; count: number }>
|
sources: Array<{ source: string; count: number }>
|
||||||
no_source_count: number
|
no_source_count: number
|
||||||
|
type_counts?: {
|
||||||
|
rich: number
|
||||||
|
atomic: number
|
||||||
|
eigenentwicklung: number
|
||||||
|
}
|
||||||
|
severity_counts?: Record<string, number>
|
||||||
|
verification_method_counts?: Record<string, number>
|
||||||
|
category_counts?: Record<string, number>
|
||||||
|
evidence_type_counts?: Record<string, number>
|
||||||
|
release_state_counts?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -51,9 +62,11 @@ export default function ControlLibraryPage() {
|
|||||||
const [stateFilter, setStateFilter] = useState<string>('')
|
const [stateFilter, setStateFilter] = useState<string>('')
|
||||||
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
const [verificationFilter, setVerificationFilter] = useState<string>('')
|
||||||
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
const [categoryFilter, setCategoryFilter] = useState<string>('')
|
||||||
|
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
|
||||||
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
const [audienceFilter, setAudienceFilter] = useState<string>('')
|
||||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||||
|
const [hideDuplicates, setHideDuplicates] = useState(true)
|
||||||
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
|
||||||
|
|
||||||
// CRUD state
|
// CRUD state
|
||||||
@@ -77,6 +90,21 @@ export default function ControlLibraryPage() {
|
|||||||
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
|
||||||
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
|
||||||
|
|
||||||
|
// V1 Compare mode
|
||||||
|
const [compareMode, setCompareMode] = useState(false)
|
||||||
|
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
|
||||||
|
const [compareMatches, setCompareMatches] = useState<Array<{
|
||||||
|
matched_control_id: string; matched_title: string; matched_objective: string
|
||||||
|
matched_severity: string; matched_category: string
|
||||||
|
matched_source: string | null; matched_article: string | null
|
||||||
|
matched_source_citation: Record<string, string> | null
|
||||||
|
similarity_score: number; match_rank: number; match_method: string
|
||||||
|
}>>([])
|
||||||
|
|
||||||
|
// Abort controllers for cancelling stale requests
|
||||||
|
const metaAbortRef = useRef<AbortController | null>(null)
|
||||||
|
const controlsAbortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,28 +121,43 @@ export default function ControlLibraryPage() {
|
|||||||
if (stateFilter) p.set('release_state', stateFilter)
|
if (stateFilter) p.set('release_state', stateFilter)
|
||||||
if (verificationFilter) p.set('verification_method', verificationFilter)
|
if (verificationFilter) p.set('verification_method', verificationFilter)
|
||||||
if (categoryFilter) p.set('category', categoryFilter)
|
if (categoryFilter) p.set('category', categoryFilter)
|
||||||
|
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
|
||||||
if (audienceFilter) p.set('target_audience', audienceFilter)
|
if (audienceFilter) p.set('target_audience', audienceFilter)
|
||||||
if (sourceFilter) p.set('source', sourceFilter)
|
if (sourceFilter) p.set('source', sourceFilter)
|
||||||
if (typeFilter) p.set('control_type', typeFilter)
|
if (typeFilter) p.set('control_type', typeFilter)
|
||||||
|
if (hideDuplicates) p.set('exclude_duplicates', 'true')
|
||||||
if (debouncedSearch) p.set('search', debouncedSearch)
|
if (debouncedSearch) p.set('search', debouncedSearch)
|
||||||
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
|
||||||
return p.toString()
|
return p.toString()
|
||||||
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, typeFilter, debouncedSearch])
|
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
|
||||||
|
|
||||||
// Load metadata (domains, sources — once + on refresh)
|
// Load frameworks (once)
|
||||||
const loadMeta = useCallback(async () => {
|
const loadFrameworks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [fwRes, metaRes] = await Promise.all([
|
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||||
fetch(`${BACKEND_URL}?endpoint=frameworks`),
|
if (res.ok) setFrameworks(await res.json())
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls-meta`),
|
|
||||||
])
|
|
||||||
if (fwRes.ok) setFrameworks(await fwRes.json())
|
|
||||||
if (metaRes.ok) setMeta(await metaRes.json())
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load controls page
|
// Load faceted metadata (reloads when filters change, cancels stale requests)
|
||||||
|
const loadMeta = useCallback(async () => {
|
||||||
|
if (metaAbortRef.current) metaAbortRef.current.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
metaAbortRef.current = controller
|
||||||
|
try {
|
||||||
|
const qs = buildParams()
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||||
|
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||||
|
}
|
||||||
|
}, [buildParams])
|
||||||
|
|
||||||
|
// Load controls page (cancels stale requests)
|
||||||
const loadControls = useCallback(async () => {
|
const loadControls = useCallback(async () => {
|
||||||
|
if (controlsAbortRef.current) controlsAbortRef.current.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
controlsAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
@@ -133,19 +176,22 @@ export default function ControlLibraryPage() {
|
|||||||
const countQs = buildParams()
|
const countQs = buildParams()
|
||||||
|
|
||||||
const [ctrlRes, countRes] = await Promise.all([
|
const [ctrlRes, countRes] = await Promise.all([
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`),
|
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
|
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
if (!controller.signal.aborted) {
|
||||||
if (countRes.ok) {
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||||
const data = await countRes.json()
|
if (countRes.ok) {
|
||||||
setTotalCount(data.total || 0)
|
const data = await countRes.json()
|
||||||
|
setTotalCount(data.total || 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (!controller.signal.aborted) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [buildParams, sortBy, currentPage])
|
}, [buildParams, sortBy, currentPage])
|
||||||
|
|
||||||
@@ -160,22 +206,25 @@ export default function ControlLibraryPage() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Initial load
|
// Initial load (frameworks only once)
|
||||||
useEffect(() => { loadMeta(); loadReviewCount() }, [loadMeta, loadReviewCount])
|
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
|
||||||
|
|
||||||
|
// Load faceted meta when filters change
|
||||||
|
useEffect(() => { loadMeta() }, [loadMeta])
|
||||||
|
|
||||||
// Load controls when filters/page/sort change
|
// Load controls when filters/page/sort change
|
||||||
useEffect(() => { loadControls() }, [loadControls])
|
useEffect(() => { loadControls() }, [loadControls])
|
||||||
|
|
||||||
// Reset page when filters change
|
// Reset page when filters change
|
||||||
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, typeFilter, debouncedSearch, sortBy])
|
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
||||||
|
|
||||||
// Full reload (after CRUD)
|
// Full reload (after CRUD)
|
||||||
const fullReload = useCallback(async () => {
|
const fullReload = useCallback(async () => {
|
||||||
await Promise.all([loadControls(), loadMeta(), loadReviewCount()])
|
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
|
||||||
}, [loadControls, loadMeta, loadReviewCount])
|
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
|
||||||
|
|
||||||
// CRUD handlers
|
// CRUD handlers
|
||||||
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
|
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
|
||||||
@@ -394,6 +443,27 @@ export default function ControlLibraryPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V1 COMPARE MODE
|
||||||
|
if (compareMode && compareV1Control) {
|
||||||
|
return (
|
||||||
|
<V1CompareView
|
||||||
|
v1Control={compareV1Control}
|
||||||
|
matches={compareMatches}
|
||||||
|
onBack={() => { setCompareMode(false) }}
|
||||||
|
onNavigateToControl={async (controlId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
setCompareMode(false)
|
||||||
|
setSelectedControl(await res.json())
|
||||||
|
setMode('detail')
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// DETAIL MODE
|
// DETAIL MODE
|
||||||
if (mode === 'detail' && selectedControl) {
|
if (mode === 'detail' && selectedControl) {
|
||||||
const isDuplicateReview = reviewMode && reviewTab === 'duplicates'
|
const isDuplicateReview = reviewMode && reviewTab === 'duplicates'
|
||||||
@@ -463,6 +533,11 @@ export default function ControlLibraryPage() {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onReview={handleReview}
|
onReview={handleReview}
|
||||||
onRefresh={fullReload}
|
onRefresh={fullReload}
|
||||||
|
onCompare={(ctrl, matches) => {
|
||||||
|
setCompareV1Control(ctrl)
|
||||||
|
setCompareMatches(matches)
|
||||||
|
setCompareMode(true)
|
||||||
|
}}
|
||||||
onNavigateToControl={async (controlId: string) => {
|
onNavigateToControl={async (controlId: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
|
||||||
@@ -580,7 +655,7 @@ export default function ControlLibraryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { loadControls(); loadMeta(); loadReviewCount() }}
|
onClick={() => { loadControls(); loadMeta(); loadFrameworks(); loadReviewCount() }}
|
||||||
className="p-2 text-gray-400 hover:text-purple-600"
|
className="p-2 text-gray-400 hover:text-purple-600"
|
||||||
title="Aktualisieren"
|
title="Aktualisieren"
|
||||||
>
|
>
|
||||||
@@ -595,10 +670,10 @@ export default function ControlLibraryPage() {
|
|||||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
>
|
>
|
||||||
<option value="">Schweregrad</option>
|
<option value="">Schweregrad</option>
|
||||||
<option value="critical">Kritisch</option>
|
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
|
||||||
<option value="high">Hoch</option>
|
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
|
||||||
<option value="medium">Mittel</option>
|
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
|
||||||
<option value="low">Niedrig</option>
|
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={domainFilter}
|
value={domainFilter}
|
||||||
@@ -616,13 +691,22 @@ export default function ControlLibraryPage() {
|
|||||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
>
|
>
|
||||||
<option value="">Status</option>
|
<option value="">Status</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
|
||||||
<option value="approved">Approved</option>
|
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
|
||||||
<option value="needs_review">Review noetig</option>
|
<option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
|
||||||
<option value="too_close">Zu aehnlich</option>
|
<option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
|
||||||
<option value="duplicate">Duplikat</option>
|
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
|
||||||
<option value="deprecated">Deprecated</option>
|
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideDuplicates}
|
||||||
|
onChange={e => setHideDuplicates(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
Duplikate ausblenden
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={verificationFilter}
|
value={verificationFilter}
|
||||||
onChange={e => setVerificationFilter(e.target.value)}
|
onChange={e => setVerificationFilter(e.target.value)}
|
||||||
@@ -630,8 +714,9 @@ export default function ControlLibraryPage() {
|
|||||||
>
|
>
|
||||||
<option value="">Nachweis</option>
|
<option value="">Nachweis</option>
|
||||||
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
|
||||||
<option key={k} value={k}>{v.label}</option>
|
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
|
||||||
))}
|
))}
|
||||||
|
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
@@ -640,8 +725,20 @@ export default function ControlLibraryPage() {
|
|||||||
>
|
>
|
||||||
<option value="">Kategorie</option>
|
<option value="">Kategorie</option>
|
||||||
{CATEGORY_OPTIONS.map(c => (
|
{CATEGORY_OPTIONS.map(c => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>
|
||||||
))}
|
))}
|
||||||
|
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={evidenceTypeFilter}
|
||||||
|
onChange={e => setEvidenceTypeFilter(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
|
>
|
||||||
|
<option value="">Nachweisart</option>
|
||||||
|
{EVIDENCE_TYPE_OPTIONS.map(c => (
|
||||||
|
<option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>
|
||||||
|
))}
|
||||||
|
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={audienceFilter}
|
value={audienceFilter}
|
||||||
@@ -682,8 +779,9 @@ export default function ControlLibraryPage() {
|
|||||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||||
>
|
>
|
||||||
<option value="">Alle Typen</option>
|
<option value="">Alle Typen</option>
|
||||||
<option value="rich">Rich Controls</option>
|
<option value="rich">Rich Controls{meta?.type_counts ? ` (${meta.type_counts.rich})` : ''}</option>
|
||||||
<option value="atomic">Atomare Controls</option>
|
<option value="atomic">Atomare Controls{meta?.type_counts ? ` (${meta.type_counts.atomic})` : ''}</option>
|
||||||
|
<option value="eigenentwicklung">Eigenentwicklung{meta?.type_counts ? ` (${meta.type_counts.eigenentwicklung})` : ''}</option>
|
||||||
</select>
|
</select>
|
||||||
<span className="text-gray-300 mx-1">|</span>
|
<span className="text-gray-300 mx-1">|</span>
|
||||||
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
<ArrowUpDown className="w-4 h-4 text-gray-400" />
|
||||||
@@ -781,8 +879,9 @@ export default function ControlLibraryPage() {
|
|||||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||||
<VerificationMethodBadge method={ctrl.verification_method} />
|
<VerificationMethodBadge method={ctrl.verification_method} />
|
||||||
<CategoryBadge category={ctrl.category} />
|
<CategoryBadge category={ctrl.category} />
|
||||||
|
<EvidenceTypeBadge type={ctrl.evidence_type} />
|
||||||
<TargetAudienceBadge audience={ctrl.target_audience} />
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
||||||
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
|
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
|
||||||
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
|
||||||
{ctrl.risk_score !== null && (
|
{ctrl.risk_score !== null && (
|
||||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||||
|
|||||||
496
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
496
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -142,8 +142,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
|||||||
checkpointId: 'CP-UC',
|
checkpointId: 'CP-UC',
|
||||||
checkpointType: 'REQUIRED',
|
checkpointType: 'REQUIRED',
|
||||||
checkpointReviewer: 'NONE',
|
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).',
|
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 + BetrVG — assistiv/teilautomatisiert/vollautomatisiert, plus 3 BetrVG-Toggles: Ueberwachungseignung, HR-Entscheidungsunterstuetzung, BR-Konsultation. Das SDK berechnet daraus einen Betriebsrats-Konflikt-Score (0-100) und leitet BetrVG-Pflichten ab (§87, §90, §94, §95). (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). Die Collection bp_compliance_datenschutz enthaelt 14 BAG-Urteile zu IT-Mitbestimmung (M365, SAP, SaaS, Video).',
|
||||||
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
||||||
inputs: ['companyProfile'],
|
inputs: ['companyProfile'],
|
||||||
outputs: ['useCases'],
|
outputs: ['useCases'],
|
||||||
@@ -155,6 +155,27 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
|||||||
isOptional: false,
|
isOptional: false,
|
||||||
url: '/sdk/use-cases',
|
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',
|
id: 'import',
|
||||||
name: 'Dokument-Import',
|
name: 'Dokument-Import',
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ interface FullAssessment {
|
|||||||
dsfa_recommended: boolean
|
dsfa_recommended: boolean
|
||||||
art22_risk: boolean
|
art22_risk: boolean
|
||||||
training_allowed: string
|
training_allowed: string
|
||||||
|
betrvg_conflict_score?: number
|
||||||
|
betrvg_consultation_required?: boolean
|
||||||
triggered_rules?: TriggeredRule[]
|
triggered_rules?: TriggeredRule[]
|
||||||
required_controls?: RequiredControl[]
|
required_controls?: RequiredControl[]
|
||||||
recommended_architecture?: PatternRecommendation[]
|
recommended_architecture?: PatternRecommendation[]
|
||||||
@@ -167,6 +169,8 @@ export default function AssessmentDetailPage() {
|
|||||||
dsfa_recommended: assessment.dsfa_recommended,
|
dsfa_recommended: assessment.dsfa_recommended,
|
||||||
art22_risk: assessment.art22_risk,
|
art22_risk: assessment.art22_risk,
|
||||||
training_allowed: assessment.training_allowed,
|
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
|
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||||
rule_code: r.code,
|
rule_code: r.code,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface Assessment {
|
|||||||
feasibility: string
|
feasibility: string
|
||||||
risk_level: string
|
risk_level: string
|
||||||
risk_score: number
|
risk_score: number
|
||||||
|
betrvg_conflict_score?: number
|
||||||
|
betrvg_consultation_required?: boolean
|
||||||
domain: string
|
domain: string
|
||||||
created_at: 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}`}>
|
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||||
{feasibility.label}
|
{feasibility.label}
|
||||||
</span>
|
</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>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
<span>{assessment.domain}</span>
|
<span>{assessment.domain}</span>
|
||||||
|
|||||||
@@ -546,6 +546,89 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment 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">
|
||||||
|
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 */}
|
{/* Additional Modules */}
|
||||||
<div className="border-t border-gray-100 py-2">
|
<div className="border-t border-gray-100 py-2">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
|||||||
554
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
Normal file
554
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface DecisionTreeQuestion {
|
||||||
|
id: string
|
||||||
|
axis: 'high_risk' | 'gpai'
|
||||||
|
question: string
|
||||||
|
description: string
|
||||||
|
article_ref: string
|
||||||
|
skip_if?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecisionTreeDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
questions: DecisionTreeQuestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecisionTreeAnswer {
|
||||||
|
question_id: string
|
||||||
|
value: boolean
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GPAIClassification {
|
||||||
|
is_gpai: boolean
|
||||||
|
is_systemic_risk: boolean
|
||||||
|
gpai_category: 'none' | 'standard' | 'systemic'
|
||||||
|
applicable_articles: string[]
|
||||||
|
obligations: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecisionTreeResult {
|
||||||
|
id: string
|
||||||
|
tenant_id: string
|
||||||
|
system_name: string
|
||||||
|
system_description?: string
|
||||||
|
answers: Record<string, DecisionTreeAnswer>
|
||||||
|
high_risk_result: string
|
||||||
|
gpai_result: GPAIClassification
|
||||||
|
combined_obligations: string[]
|
||||||
|
applicable_articles: string[]
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||||
|
unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' },
|
||||||
|
high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
|
||||||
|
limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
|
||||||
|
minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
|
||||||
|
not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const GPAI_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||||
|
none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||||
|
standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
|
||||||
|
systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export default function DecisionTreeWizard() {
|
||||||
|
const [definition, setDefinition] = useState<DecisionTreeDefinition | null>(null)
|
||||||
|
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
|
||||||
|
const [currentIdx, setCurrentIdx] = useState(0)
|
||||||
|
const [systemName, setSystemName] = useState('')
|
||||||
|
const [systemDescription, setSystemDescription] = useState('')
|
||||||
|
const [result, setResult] = useState<DecisionTreeResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro')
|
||||||
|
|
||||||
|
// Load decision tree definition
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/ucca/decision-tree')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setDefinition(data)
|
||||||
|
} else {
|
||||||
|
setError('Entscheidungsbaum konnte nicht geladen werden')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Verbindung zum Backend fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Get visible questions (respecting skip logic)
|
||||||
|
const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => {
|
||||||
|
if (!definition) return []
|
||||||
|
return definition.questions.filter(q => {
|
||||||
|
if (!q.skip_if) return true
|
||||||
|
// Skip this question if the gate question was answered "no"
|
||||||
|
const gateAnswer = answers[q.skip_if]
|
||||||
|
if (gateAnswer && !gateAnswer.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [definition, answers])
|
||||||
|
|
||||||
|
const visibleQuestions = getVisibleQuestions()
|
||||||
|
const currentQuestion = visibleQuestions[currentIdx]
|
||||||
|
const totalVisible = visibleQuestions.length
|
||||||
|
|
||||||
|
const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk')
|
||||||
|
const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai')
|
||||||
|
|
||||||
|
const handleAnswer = (value: boolean) => {
|
||||||
|
if (!currentQuestion) return
|
||||||
|
setAnswers(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentQuestion.id]: {
|
||||||
|
question_id: currentQuestion.id,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Auto-advance
|
||||||
|
if (currentIdx < totalVisible - 1) {
|
||||||
|
setCurrentIdx(prev => prev + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentIdx > 0) {
|
||||||
|
setCurrentIdx(prev => prev - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
system_name: systemName,
|
||||||
|
system_description: systemDescription,
|
||||||
|
answers,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setResult(data)
|
||||||
|
setPhase('result')
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' }))
|
||||||
|
setError(err.error || 'Auswertung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Verbindung zum Backend fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setAnswers({})
|
||||||
|
setCurrentIdx(0)
|
||||||
|
setSystemName('')
|
||||||
|
setSystemDescription('')
|
||||||
|
setResult(null)
|
||||||
|
setPhase('intro')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||||
|
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !definition) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||||
|
<p className="text-red-700">{error}</p>
|
||||||
|
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// INTRO PHASE
|
||||||
|
// =========================================================================
|
||||||
|
if (phase === 'intro') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
|
||||||
|
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ interface AssessmentResult {
|
|||||||
dsfa_recommended: boolean
|
dsfa_recommended: boolean
|
||||||
art22_risk: boolean
|
art22_risk: boolean
|
||||||
training_allowed: string
|
training_allowed: string
|
||||||
|
betrvg_conflict_score?: number
|
||||||
|
betrvg_consultation_required?: boolean
|
||||||
summary: string
|
summary: string
|
||||||
recommendation: string
|
recommendation: string
|
||||||
alternative_approach?: string
|
alternative_approach?: string
|
||||||
@@ -76,6 +78,21 @@ export function AssessmentResultCard({ result }: AssessmentResultCardProps) {
|
|||||||
Art. 22 Risiko
|
Art. 22 Risiko
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p className="text-gray-700">{result.summary}</p>
|
<p className="text-gray-700">{result.summary}</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>
|
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>
|
||||||
|
|||||||
53
admin-compliance/migrations/wiki_betrvg_article.sql
Normal file
53
admin-compliance/migrations/wiki_betrvg_article.sql
Normal file
@@ -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();
|
||||||
157
admin-compliance/migrations/wiki_domain_articles.sql
Normal file
157
admin-compliance/migrations/wiki_domain_articles.sql
Normal file
@@ -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();
|
||||||
@@ -104,6 +104,10 @@ func main() {
|
|||||||
auditHandlers := handlers.NewAuditHandlers(auditStore, exporter)
|
auditHandlers := handlers.NewAuditHandlers(auditStore, exporter)
|
||||||
uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry)
|
uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry)
|
||||||
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
|
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
|
||||||
|
registrationStore := ucca.NewRegistrationStore(pool)
|
||||||
|
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
|
||||||
|
paymentHandlers := handlers.NewPaymentHandlers(pool)
|
||||||
|
tenderHandlers := handlers.NewTenderHandlers(pool, paymentHandlers.GetControlLibrary())
|
||||||
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
|
||||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||||
@@ -270,10 +274,49 @@ func main() {
|
|||||||
uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview)
|
uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview)
|
||||||
uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation)
|
uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation)
|
||||||
|
|
||||||
|
// AI Act Decision Tree
|
||||||
|
dtRoutes := uccaRoutes.Group("/decision-tree")
|
||||||
|
{
|
||||||
|
dtRoutes.GET("", uccaHandlers.GetDecisionTree)
|
||||||
|
dtRoutes.POST("/evaluate", uccaHandlers.EvaluateDecisionTree)
|
||||||
|
dtRoutes.GET("/results", uccaHandlers.ListDecisionTreeResults)
|
||||||
|
dtRoutes.GET("/results/:id", uccaHandlers.GetDecisionTreeResult)
|
||||||
|
dtRoutes.DELETE("/results/:id", uccaHandlers.DeleteDecisionTreeResult)
|
||||||
|
}
|
||||||
|
|
||||||
// Obligations framework (v2 with TOM mapping)
|
// Obligations framework (v2 with TOM mapping)
|
||||||
obligationsHandlers.RegisterRoutes(uccaRoutes)
|
obligationsHandlers.RegisterRoutes(uccaRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Registration routes - EU AI Database (Art. 49)
|
||||||
|
regRoutes := v1.Group("/ai-registration")
|
||||||
|
{
|
||||||
|
regRoutes.POST("", registrationHandlers.Create)
|
||||||
|
regRoutes.GET("", registrationHandlers.List)
|
||||||
|
regRoutes.GET("/:id", registrationHandlers.Get)
|
||||||
|
regRoutes.PUT("/:id", registrationHandlers.Update)
|
||||||
|
regRoutes.PATCH("/:id/status", registrationHandlers.UpdateStatus)
|
||||||
|
regRoutes.POST("/prefill/:assessment_id", registrationHandlers.Prefill)
|
||||||
|
regRoutes.GET("/:id/export", registrationHandlers.Export)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment Compliance routes
|
||||||
|
payRoutes := v1.Group("/payment-compliance")
|
||||||
|
{
|
||||||
|
payRoutes.GET("/controls", paymentHandlers.ListControls)
|
||||||
|
payRoutes.POST("/assessments", paymentHandlers.CreateAssessment)
|
||||||
|
payRoutes.GET("/assessments", paymentHandlers.ListAssessments)
|
||||||
|
payRoutes.GET("/assessments/:id", paymentHandlers.GetAssessment)
|
||||||
|
payRoutes.PATCH("/assessments/:id/verdict", paymentHandlers.UpdateControlVerdict)
|
||||||
|
|
||||||
|
// Tender Analysis
|
||||||
|
payRoutes.POST("/tender/upload", tenderHandlers.Upload)
|
||||||
|
payRoutes.POST("/tender/:id/extract", tenderHandlers.Extract)
|
||||||
|
payRoutes.POST("/tender/:id/match", tenderHandlers.Match)
|
||||||
|
payRoutes.GET("/tender", tenderHandlers.ListAnalyses)
|
||||||
|
payRoutes.GET("/tender/:id", tenderHandlers.GetAnalysis)
|
||||||
|
}
|
||||||
|
|
||||||
// RAG routes - Legal Corpus Search & Versioning
|
// RAG routes - Legal Corpus Search & Versioning
|
||||||
ragRoutes := v1.Group("/rag")
|
ragRoutes := v1.Group("/rag")
|
||||||
{
|
{
|
||||||
|
|||||||
290
ai-compliance-sdk/internal/api/handlers/payment_handlers.go
Normal file
290
ai-compliance-sdk/internal/api/handlers/payment_handlers.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
220
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
220
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1122,6 +1122,114 @@ func (h *UCCAHandlers) GetWizardSchema(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Act Decision Tree Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GetDecisionTree returns the decision tree structure for the frontend
|
||||||
|
// GET /sdk/v1/ucca/decision-tree
|
||||||
|
func (h *UCCAHandlers) GetDecisionTree(c *gin.Context) {
|
||||||
|
tree := ucca.BuildDecisionTreeDefinition()
|
||||||
|
c.JSON(http.StatusOK, tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateDecisionTree evaluates the decision tree answers and stores the result
|
||||||
|
// POST /sdk/v1/ucca/decision-tree/evaluate
|
||||||
|
func (h *UCCAHandlers) EvaluateDecisionTree(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
if tenantID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ucca.DecisionTreeEvalRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SystemName == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "system_name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate
|
||||||
|
result := ucca.EvaluateDecisionTree(&req)
|
||||||
|
result.TenantID = tenantID
|
||||||
|
|
||||||
|
// Parse optional project_id
|
||||||
|
if projectIDStr := c.Query("project_id"); projectIDStr != "" {
|
||||||
|
if pid, err := uuid.Parse(projectIDStr); err == nil {
|
||||||
|
result.ProjectID = &pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
if err := h.store.CreateDecisionTreeResult(c.Request.Context(), result); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDecisionTreeResults returns stored decision tree results for a tenant
|
||||||
|
// GET /sdk/v1/ucca/decision-tree/results
|
||||||
|
func (h *UCCAHandlers) ListDecisionTreeResults(c *gin.Context) {
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
if tenantID == uuid.Nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.store.ListDecisionTreeResults(c.Request.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"results": results, "total": len(results)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDecisionTreeResult returns a single decision tree result by ID
|
||||||
|
// GET /sdk/v1/ucca/decision-tree/results/:id
|
||||||
|
func (h *UCCAHandlers) GetDecisionTreeResult(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.store.GetDecisionTreeResult(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDecisionTreeResult deletes a decision tree result
|
||||||
|
// DELETE /sdk/v1/ucca/decision-tree/results/:id
|
||||||
|
func (h *UCCAHandlers) DeleteDecisionTreeResult(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.store.DeleteDecisionTreeResult(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper functions
|
// Helper functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
305
ai-compliance-sdk/internal/ucca/betrvg_test.go
Normal file
305
ai-compliance-sdk/internal/ucca/betrvg_test.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
325
ai-compliance-sdk/internal/ucca/decision_tree_engine.go
Normal file
325
ai-compliance-sdk/internal/ucca/decision_tree_engine.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
420
ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go
Normal file
420
ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
542
ai-compliance-sdk/internal/ucca/domain_context_test.go
Normal file
542
ai-compliance-sdk/internal/ucca/domain_context_test.go
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
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
|
package ucca
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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 hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold {
|
||||||
if result.DSFARecommended {
|
if result.DSFARecommended {
|
||||||
reasons = append(reasons, "DSFA empfohlen")
|
reasons = append(reasons, "DSFA empfohlen")
|
||||||
@@ -197,6 +204,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
|||||||
return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ")
|
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
|
// E1: Low priority checks
|
||||||
// - WARN rules triggered
|
// - WARN rules triggered
|
||||||
// - Risk 20-40
|
// - Risk 20-40
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ func (m *JSONRegulationModule) defaultApplicability(facts *UnifiedFacts) bool {
|
|||||||
return facts.Organization.EUMember && facts.AIUsage.UsesAI
|
return facts.Organization.EUMember && facts.AIUsage.UsesAI
|
||||||
case "dora":
|
case "dora":
|
||||||
return facts.Financial.DORAApplies || facts.Financial.IsRegulated
|
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:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,10 +217,221 @@ type UseCaseIntake struct {
|
|||||||
// Only applicable for financial domains (banking, finance, insurance, investment)
|
// Only applicable for financial domains (banking, finance, insurance, investment)
|
||||||
FinancialContext *FinancialContext `json:"financial_context,omitempty"`
|
FinancialContext *FinancialContext `json:"financial_context,omitempty"`
|
||||||
|
|
||||||
|
// BetrVG / works council context (Germany)
|
||||||
|
EmployeeMonitoring bool `json:"employee_monitoring,omitempty"` // System can monitor employee behavior/performance
|
||||||
|
HRDecisionSupport bool `json:"hr_decision_support,omitempty"` // System supports HR decisions (hiring, evaluation, termination)
|
||||||
|
WorksCouncilConsulted bool `json:"works_council_consulted,omitempty"` // Works council has been consulted
|
||||||
|
|
||||||
|
// Domain-specific contexts (AI Act Annex III high-risk domains)
|
||||||
|
HRContext *HRContext `json:"hr_context,omitempty"`
|
||||||
|
EducationContext *EducationContext `json:"education_context,omitempty"`
|
||||||
|
HealthcareContext *HealthcareContext `json:"healthcare_context,omitempty"`
|
||||||
|
LegalDomainContext *LegalDomainContext `json:"legal_context,omitempty"`
|
||||||
|
PublicSectorContext *PublicSectorContext `json:"public_sector_context,omitempty"`
|
||||||
|
CriticalInfraContext *CriticalInfraContext `json:"critical_infra_context,omitempty"`
|
||||||
|
AutomotiveContext *AutomotiveContext `json:"automotive_context,omitempty"`
|
||||||
|
RetailContext *RetailContext `json:"retail_context,omitempty"`
|
||||||
|
ITSecurityContext *ITSecurityContext `json:"it_security_context,omitempty"`
|
||||||
|
LogisticsContext *LogisticsContext `json:"logistics_context,omitempty"`
|
||||||
|
ConstructionContext *ConstructionContext `json:"construction_context,omitempty"`
|
||||||
|
MarketingContext *MarketingContext `json:"marketing_context,omitempty"`
|
||||||
|
ManufacturingContext *ManufacturingContext `json:"manufacturing_context,omitempty"`
|
||||||
|
AgricultureContext *AgricultureContext `json:"agriculture_context,omitempty"`
|
||||||
|
SocialServicesCtx *SocialServicesContext `json:"social_services_context,omitempty"`
|
||||||
|
HospitalityContext *HospitalityContext `json:"hospitality_context,omitempty"`
|
||||||
|
InsuranceContext *InsuranceContext `json:"insurance_context,omitempty"`
|
||||||
|
InvestmentContext *InvestmentContext `json:"investment_context,omitempty"`
|
||||||
|
DefenseContext *DefenseContext `json:"defense_context,omitempty"`
|
||||||
|
SupplyChainContext *SupplyChainContext `json:"supply_chain_context,omitempty"`
|
||||||
|
FacilityContext *FacilityContext `json:"facility_context,omitempty"`
|
||||||
|
SportsContext *SportsContext `json:"sports_context,omitempty"`
|
||||||
|
|
||||||
// Opt-in to store raw text (otherwise only hash)
|
// Opt-in to store raw text (otherwise only hash)
|
||||||
StoreRawText bool `json:"store_raw_text,omitempty"`
|
StoreRawText bool `json:"store_raw_text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HRContext captures HR/recruiting-specific compliance data (AI Act Annex III Nr. 4 + AGG)
|
||||||
|
type HRContext struct {
|
||||||
|
AutomatedScreening bool `json:"automated_screening"` // KI sortiert Bewerber vor
|
||||||
|
AutomatedRejection bool `json:"automated_rejection"` // KI generiert Absagen
|
||||||
|
CandidateRanking bool `json:"candidate_ranking"` // KI erstellt Bewerber-Rankings
|
||||||
|
BiasAuditsDone bool `json:"bias_audits_done"` // Regelmaessige Bias-Audits
|
||||||
|
AGGCategoriesVisible bool `json:"agg_categories_visible"` // System kann Name/Foto/Alter erkennen
|
||||||
|
HumanReviewEnforced bool `json:"human_review_enforced"` // Mensch prueft jede KI-Empfehlung
|
||||||
|
PerformanceEvaluation bool `json:"performance_evaluation"` // KI bewertet Mitarbeiterleistung
|
||||||
|
}
|
||||||
|
|
||||||
|
// EducationContext captures education-specific compliance data (AI Act Annex III Nr. 3)
|
||||||
|
type EducationContext struct {
|
||||||
|
GradeInfluence bool `json:"grade_influence"` // KI beeinflusst Noten
|
||||||
|
ExamEvaluation bool `json:"exam_evaluation"` // KI bewertet Pruefungen
|
||||||
|
StudentSelection bool `json:"student_selection"` // KI beeinflusst Zugang/Auswahl
|
||||||
|
MinorsInvolved bool `json:"minors_involved"` // Minderjaehrige betroffen
|
||||||
|
TeacherReviewRequired bool `json:"teacher_review_required"` // Lehrkraft prueft KI-Ergebnis
|
||||||
|
LearningAdaptation bool `json:"learning_adaptation"` // KI passt Lernpfade an
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthcareContext captures healthcare-specific compliance data (AI Act Annex III Nr. 5 + MDR)
|
||||||
|
type HealthcareContext struct {
|
||||||
|
DiagnosisSupport bool `json:"diagnosis_support"` // KI unterstuetzt Diagnosen
|
||||||
|
TreatmentRecommend bool `json:"treatment_recommendation"` // KI empfiehlt Behandlungen
|
||||||
|
TriageDecision bool `json:"triage_decision"` // KI priorisiert Patienten
|
||||||
|
PatientDataProcessed bool `json:"patient_data_processed"` // Gesundheitsdaten verarbeitet
|
||||||
|
MedicalDevice bool `json:"medical_device"` // System ist Medizinprodukt
|
||||||
|
ClinicalValidation bool `json:"clinical_validation"` // Klinisch validiert
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegalDomainContext captures legal/justice-specific compliance data (AI Act Annex III Nr. 8)
|
||||||
|
type LegalDomainContext struct {
|
||||||
|
LegalAdvice bool `json:"legal_advice"` // KI gibt Rechtsberatung
|
||||||
|
ContractAnalysis bool `json:"contract_analysis"` // KI analysiert Vertraege
|
||||||
|
CourtPrediction bool `json:"court_prediction"` // KI prognostiziert Urteile
|
||||||
|
AccessToJustice bool `json:"access_to_justice"` // KI beeinflusst Zugang zu Recht
|
||||||
|
ClientConfidential bool `json:"client_confidential"` // Mandantengeheimnis betroffen
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicSectorContext captures public sector compliance data (Art. 27 FRIA)
|
||||||
|
type PublicSectorContext struct {
|
||||||
|
AdminDecision bool `json:"admin_decision"` // KI beeinflusst Verwaltungsentscheidungen
|
||||||
|
CitizenService bool `json:"citizen_service"` // KI in Buergerservices
|
||||||
|
BenefitAllocation bool `json:"benefit_allocation"` // KI verteilt Leistungen/Mittel
|
||||||
|
PublicSafety bool `json:"public_safety"` // KI in oeffentlicher Sicherheit
|
||||||
|
TransparencyEnsured bool `json:"transparency_ensured"` // Transparenz gegenueber Buergern
|
||||||
|
}
|
||||||
|
|
||||||
|
// CriticalInfraContext captures critical infrastructure data (NIS2 + Annex III Nr. 2)
|
||||||
|
type CriticalInfraContext struct {
|
||||||
|
GridControl bool `json:"grid_control"` // KI steuert Netz/Infrastruktur
|
||||||
|
SafetyCritical bool `json:"safety_critical"` // Sicherheitskritische Steuerung
|
||||||
|
AnomalyDetection bool `json:"anomaly_detection"` // KI erkennt Anomalien
|
||||||
|
RedundancyExists bool `json:"redundancy_exists"` // Redundante Systeme vorhanden
|
||||||
|
IncidentResponse bool `json:"incident_response"` // Incident Response Plan vorhanden
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutomotiveContext captures automotive/aerospace safety data
|
||||||
|
type AutomotiveContext struct {
|
||||||
|
AutonomousDriving bool `json:"autonomous_driving"` // Autonomes Fahren / ADAS
|
||||||
|
SafetyRelevant bool `json:"safety_relevant"` // Sicherheitsrelevante Funktion
|
||||||
|
TypeApprovalNeeded bool `json:"type_approval_needed"` // Typgenehmigung erforderlich
|
||||||
|
FunctionalSafety bool `json:"functional_safety"` // ISO 26262 relevant
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetailContext captures retail/e-commerce compliance data
|
||||||
|
type RetailContext struct {
|
||||||
|
PricingPersonalized bool `json:"pricing_personalized"` // Personalisierte Preise
|
||||||
|
CustomerProfiling bool `json:"customer_profiling"` // Kundenprofilbildung
|
||||||
|
RecommendationEngine bool `json:"recommendation_engine"` // Empfehlungssystem
|
||||||
|
CreditScoring bool `json:"credit_scoring"` // Bonitaetspruefung bei Kauf
|
||||||
|
DarkPatterns bool `json:"dark_patterns"` // Manipulative UI-Muster moeglich
|
||||||
|
}
|
||||||
|
|
||||||
|
// ITSecurityContext captures IT/cybersecurity/telecom data
|
||||||
|
type ITSecurityContext struct {
|
||||||
|
EmployeeSurveillance bool `json:"employee_surveillance"` // Mitarbeiterueberwachung
|
||||||
|
NetworkMonitoring bool `json:"network_monitoring"` // Netzwerkueberwachung
|
||||||
|
ThreatDetection bool `json:"threat_detection"` // Bedrohungserkennung
|
||||||
|
AccessControl bool `json:"access_control_ai"` // KI-gestuetzte Zugriffskontrolle
|
||||||
|
DataRetention bool `json:"data_retention_logs"` // Umfangreiche Log-Speicherung
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogisticsContext captures logistics/transport compliance data
|
||||||
|
type LogisticsContext struct {
|
||||||
|
DriverTracking bool `json:"driver_tracking"` // Fahrer-/Kurier-Tracking
|
||||||
|
RouteOptimization bool `json:"route_optimization"` // Routenoptimierung mit Personenbezug
|
||||||
|
WorkloadScoring bool `json:"workload_scoring"` // Leistungsbewertung Lagerarbeiter
|
||||||
|
PredictiveMaint bool `json:"predictive_maintenance"` // Vorausschauende Wartung
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConstructionContext captures construction/real estate data
|
||||||
|
type ConstructionContext struct {
|
||||||
|
SafetyMonitoring bool `json:"safety_monitoring"` // Baustellensicherheit per KI
|
||||||
|
TenantScreening bool `json:"tenant_screening"` // KI-gestuetzte Mieterauswahl
|
||||||
|
BuildingAutomation bool `json:"building_automation"` // Gebaeudesteuerung
|
||||||
|
WorkerSafety bool `json:"worker_safety"` // Arbeitsschutzueberwachung
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarketingContext captures marketing/media compliance data
|
||||||
|
type MarketingContext struct {
|
||||||
|
DeepfakeContent bool `json:"deepfake_content"` // Synthetische Inhalte (Deepfakes)
|
||||||
|
ContentModeration bool `json:"content_moderation"` // Automatische Inhaltsmoderation
|
||||||
|
BehavioralTargeting bool `json:"behavioral_targeting"` // Verhaltensbasiertes Targeting
|
||||||
|
MinorsTargeted bool `json:"minors_targeted"` // Minderjaehrige als Zielgruppe
|
||||||
|
AIContentLabeled bool `json:"ai_content_labeled"` // KI-Inhalte als solche gekennzeichnet
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManufacturingContext captures manufacturing/CE safety data
|
||||||
|
type ManufacturingContext struct {
|
||||||
|
MachineSafety bool `json:"machine_safety"` // Maschinensicherheit
|
||||||
|
QualityControl bool `json:"quality_control"` // KI in Qualitaetskontrolle
|
||||||
|
ProcessControl bool `json:"process_control"` // KI steuert Fertigungsprozess
|
||||||
|
CEMarkingRequired bool `json:"ce_marking_required"` // CE-Kennzeichnung erforderlich
|
||||||
|
SafetyValidated bool `json:"safety_validated"` // Sicherheitsvalidierung durchgefuehrt
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgricultureContext captures agriculture/forestry compliance data
|
||||||
|
type AgricultureContext struct {
|
||||||
|
PesticideAI bool `json:"pesticide_ai"` // KI steuert Pestizideinsatz
|
||||||
|
AnimalWelfare bool `json:"animal_welfare"` // KI beeinflusst Tierhaltung
|
||||||
|
EnvironmentalData bool `json:"environmental_data"` // Umweltdaten verarbeitet
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocialServicesContext captures social services/nonprofit data
|
||||||
|
type SocialServicesContext struct {
|
||||||
|
VulnerableGroups bool `json:"vulnerable_groups"` // Schutzbeduerftiger Personenkreis
|
||||||
|
BenefitDecision bool `json:"benefit_decision"` // KI beeinflusst Leistungszuteilung
|
||||||
|
CaseManagement bool `json:"case_management"` // KI in Fallmanagement
|
||||||
|
}
|
||||||
|
|
||||||
|
// HospitalityContext captures hospitality/tourism data
|
||||||
|
type HospitalityContext struct {
|
||||||
|
GuestProfiling bool `json:"guest_profiling"` // Gaeste-Profilbildung
|
||||||
|
DynamicPricing bool `json:"dynamic_pricing"` // Dynamische Preisgestaltung
|
||||||
|
ReviewManipulation bool `json:"review_manipulation"` // KI beeinflusst Bewertungen
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsuranceContext captures insurance-specific data (beyond FinancialContext)
|
||||||
|
type InsuranceContext struct {
|
||||||
|
RiskClassification bool `json:"risk_classification"` // KI klassifiziert Versicherungsrisiken
|
||||||
|
ClaimsAutomation bool `json:"claims_automation"` // Automatisierte Schadenbearbeitung
|
||||||
|
PremiumCalculation bool `json:"premium_calculation"` // KI berechnet Praemien individuell
|
||||||
|
FraudDetection bool `json:"fraud_detection"` // Betrugserkennung
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvestmentContext captures investment-specific data
|
||||||
|
type InvestmentContext struct {
|
||||||
|
AlgoTrading bool `json:"algo_trading"` // Algorithmischer Handel
|
||||||
|
InvestmentAdvice bool `json:"investment_advice"` // KI-gestuetzte Anlageberatung
|
||||||
|
RoboAdvisor bool `json:"robo_advisor"` // Automatisierte Vermoegensberatung
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefenseContext captures defense/dual-use data
|
||||||
|
type DefenseContext struct {
|
||||||
|
DualUse bool `json:"dual_use"` // Dual-Use Technologie
|
||||||
|
ExportControlled bool `json:"export_controlled"` // Exportkontrolle relevant
|
||||||
|
ClassifiedData bool `json:"classified_data"` // Verschlusssachen verarbeitet
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupplyChainContext captures textile/packaging/supply chain data (LkSG)
|
||||||
|
type SupplyChainContext struct {
|
||||||
|
SupplierMonitoring bool `json:"supplier_monitoring"` // KI ueberwacht Lieferanten
|
||||||
|
HumanRightsCheck bool `json:"human_rights_check"` // Menschenrechtspruefung in Lieferkette
|
||||||
|
EnvironmentalImpact bool `json:"environmental_impact"` // Umweltauswirkungen analysiert
|
||||||
|
}
|
||||||
|
|
||||||
|
// FacilityContext captures facility management data
|
||||||
|
type FacilityContext struct {
|
||||||
|
AccessControlAI bool `json:"access_control_ai"` // KI-Zutrittskontrolle
|
||||||
|
OccupancyTracking bool `json:"occupancy_tracking"` // Belegungsueberwachung
|
||||||
|
EnergyOptimization bool `json:"energy_optimization"` // Energieoptimierung
|
||||||
|
}
|
||||||
|
|
||||||
|
// SportsContext captures sports/general data
|
||||||
|
type SportsContext struct {
|
||||||
|
AthleteTracking bool `json:"athlete_tracking"` // Athleten-Performance-Tracking
|
||||||
|
FanProfiling bool `json:"fan_profiling"` // Fan-/Zuschauer-Profilbildung
|
||||||
|
DopingDetection bool `json:"doping_detection"` // KI in Doping-Kontrolle
|
||||||
|
}
|
||||||
|
|
||||||
// DataTypes specifies what kinds of data are processed
|
// DataTypes specifies what kinds of data are processed
|
||||||
type DataTypes struct {
|
type DataTypes struct {
|
||||||
PersonalData bool `json:"personal_data"`
|
PersonalData bool `json:"personal_data"`
|
||||||
@@ -383,6 +594,13 @@ type AssessmentResult struct {
|
|||||||
Art22Risk bool `json:"art22_risk"` // Art. 22 GDPR automated decision risk
|
Art22Risk bool `json:"art22_risk"` // Art. 22 GDPR automated decision risk
|
||||||
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
||||||
|
|
||||||
|
// BetrVG Conflict Score (0-100) — works council escalation risk
|
||||||
|
BetrvgConflictScore int `json:"betrvg_conflict_score"`
|
||||||
|
BetrvgConsultationRequired bool `json:"betrvg_consultation_required"`
|
||||||
|
|
||||||
|
// Input (needed for escalation logic)
|
||||||
|
Intake UseCaseIntake `json:"-"` // not serialized, internal use only
|
||||||
|
|
||||||
// Summary for humans
|
// Summary for humans
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Recommendation string `json:"recommendation"`
|
Recommendation string `json:"recommendation"`
|
||||||
@@ -471,6 +689,10 @@ type Assessment struct {
|
|||||||
Art22Risk bool `json:"art22_risk"`
|
Art22Risk bool `json:"art22_risk"`
|
||||||
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
TrainingAllowed TrainingAllowed `json:"training_allowed"`
|
||||||
|
|
||||||
|
// BetrVG Conflict Score (0-100) — works council escalation risk
|
||||||
|
BetrvgConflictScore int `json:"betrvg_conflict_score"`
|
||||||
|
BetrvgConsultationRequired bool `json:"betrvg_consultation_required"`
|
||||||
|
|
||||||
// Corpus Versioning (RAG)
|
// Corpus Versioning (RAG)
|
||||||
CorpusVersionID *uuid.UUID `json:"corpus_version_id,omitempty"`
|
CorpusVersionID *uuid.UUID `json:"corpus_version_id,omitempty"`
|
||||||
CorpusVersion string `json:"corpus_version,omitempty"`
|
CorpusVersion string `json:"corpus_version,omitempty"`
|
||||||
@@ -525,3 +747,73 @@ const (
|
|||||||
ExportFormatJSON ExportFormat = "json"
|
ExportFormatJSON ExportFormat = "json"
|
||||||
ExportFormatMarkdown ExportFormat = "md"
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult {
|
|||||||
RiskLevel: RiskLevelMINIMAL,
|
RiskLevel: RiskLevelMINIMAL,
|
||||||
Complexity: ComplexityLOW,
|
Complexity: ComplexityLOW,
|
||||||
RiskScore: 0,
|
RiskScore: 0,
|
||||||
|
Intake: *intake,
|
||||||
TriggeredRules: []TriggeredRule{},
|
TriggeredRules: []TriggeredRule{},
|
||||||
RequiredControls: []RequiredControl{},
|
RequiredControls: []RequiredControl{},
|
||||||
RecommendedArchitecture: []PatternRecommendation{},
|
RecommendedArchitecture: []PatternRecommendation{},
|
||||||
@@ -338,6 +339,9 @@ func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult {
|
|||||||
// Determine complexity
|
// Determine complexity
|
||||||
result.Complexity = e.calculateComplexity(result)
|
result.Complexity = e.calculateComplexity(result)
|
||||||
|
|
||||||
|
// Calculate BetrVG Conflict Score (Germany only, employees >= 5)
|
||||||
|
result.BetrvgConflictScore, result.BetrvgConsultationRequired = e.calculateBetrvgConflictScore(intake)
|
||||||
|
|
||||||
// Check if DSFA is recommended
|
// Check if DSFA is recommended
|
||||||
result.DSFARecommended = e.shouldRecommendDSFA(intake, result)
|
result.DSFARecommended = e.shouldRecommendDSFA(intake, result)
|
||||||
|
|
||||||
@@ -457,11 +461,382 @@ func (e *PolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interf
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return e.getRetentionValue(parts[1], intake)
|
return e.getRetentionValue(parts[1], intake)
|
||||||
|
case "employee_monitoring":
|
||||||
|
return intake.EmployeeMonitoring
|
||||||
|
case "hr_decision_support":
|
||||||
|
return intake.HRDecisionSupport
|
||||||
|
case "works_council_consulted":
|
||||||
|
return intake.WorksCouncilConsulted
|
||||||
|
case "hr_context":
|
||||||
|
if len(parts) < 2 || intake.HRContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getHRContextValue(parts[1], intake)
|
||||||
|
case "education_context":
|
||||||
|
if len(parts) < 2 || intake.EducationContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getEducationContextValue(parts[1], intake)
|
||||||
|
case "healthcare_context":
|
||||||
|
if len(parts) < 2 || intake.HealthcareContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getHealthcareContextValue(parts[1], intake)
|
||||||
|
case "legal_context":
|
||||||
|
if len(parts) < 2 || intake.LegalDomainContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getLegalContextValue(parts[1], intake)
|
||||||
|
case "public_sector_context":
|
||||||
|
if len(parts) < 2 || intake.PublicSectorContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getPublicSectorContextValue(parts[1], intake)
|
||||||
|
case "critical_infra_context":
|
||||||
|
if len(parts) < 2 || intake.CriticalInfraContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getCriticalInfraContextValue(parts[1], intake)
|
||||||
|
case "automotive_context":
|
||||||
|
if len(parts) < 2 || intake.AutomotiveContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getAutomotiveContextValue(parts[1], intake)
|
||||||
|
case "retail_context":
|
||||||
|
if len(parts) < 2 || intake.RetailContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getRetailContextValue(parts[1], intake)
|
||||||
|
case "it_security_context":
|
||||||
|
if len(parts) < 2 || intake.ITSecurityContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getITSecurityContextValue(parts[1], intake)
|
||||||
|
case "logistics_context":
|
||||||
|
if len(parts) < 2 || intake.LogisticsContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getLogisticsContextValue(parts[1], intake)
|
||||||
|
case "construction_context":
|
||||||
|
if len(parts) < 2 || intake.ConstructionContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getConstructionContextValue(parts[1], intake)
|
||||||
|
case "marketing_context":
|
||||||
|
if len(parts) < 2 || intake.MarketingContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getMarketingContextValue(parts[1], intake)
|
||||||
|
case "manufacturing_context":
|
||||||
|
if len(parts) < 2 || intake.ManufacturingContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.getManufacturingContextValue(parts[1], intake)
|
||||||
|
case "agriculture_context":
|
||||||
|
if len(parts) < 2 || intake.AgricultureContext == nil { return nil }
|
||||||
|
return e.getAgricultureContextValue(parts[1], intake)
|
||||||
|
case "social_services_context":
|
||||||
|
if len(parts) < 2 || intake.SocialServicesCtx == nil { return nil }
|
||||||
|
return e.getSocialServicesContextValue(parts[1], intake)
|
||||||
|
case "hospitality_context":
|
||||||
|
if len(parts) < 2 || intake.HospitalityContext == nil { return nil }
|
||||||
|
return e.getHospitalityContextValue(parts[1], intake)
|
||||||
|
case "insurance_context":
|
||||||
|
if len(parts) < 2 || intake.InsuranceContext == nil { return nil }
|
||||||
|
return e.getInsuranceContextValue(parts[1], intake)
|
||||||
|
case "investment_context":
|
||||||
|
if len(parts) < 2 || intake.InvestmentContext == nil { return nil }
|
||||||
|
return e.getInvestmentContextValue(parts[1], intake)
|
||||||
|
case "defense_context":
|
||||||
|
if len(parts) < 2 || intake.DefenseContext == nil { return nil }
|
||||||
|
return e.getDefenseContextValue(parts[1], intake)
|
||||||
|
case "supply_chain_context":
|
||||||
|
if len(parts) < 2 || intake.SupplyChainContext == nil { return nil }
|
||||||
|
return e.getSupplyChainContextValue(parts[1], intake)
|
||||||
|
case "facility_context":
|
||||||
|
if len(parts) < 2 || intake.FacilityContext == nil { return nil }
|
||||||
|
return e.getFacilityContextValue(parts[1], intake)
|
||||||
|
case "sports_context":
|
||||||
|
if len(parts) < 2 || intake.SportsContext == nil { return nil }
|
||||||
|
return e.getSportsContextValue(parts[1], intake)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getHRContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.HRContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch field {
|
||||||
|
case "automated_screening":
|
||||||
|
return intake.HRContext.AutomatedScreening
|
||||||
|
case "automated_rejection":
|
||||||
|
return intake.HRContext.AutomatedRejection
|
||||||
|
case "candidate_ranking":
|
||||||
|
return intake.HRContext.CandidateRanking
|
||||||
|
case "bias_audits_done":
|
||||||
|
return intake.HRContext.BiasAuditsDone
|
||||||
|
case "agg_categories_visible":
|
||||||
|
return intake.HRContext.AGGCategoriesVisible
|
||||||
|
case "human_review_enforced":
|
||||||
|
return intake.HRContext.HumanReviewEnforced
|
||||||
|
case "performance_evaluation":
|
||||||
|
return intake.HRContext.PerformanceEvaluation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getEducationContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.EducationContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch field {
|
||||||
|
case "grade_influence":
|
||||||
|
return intake.EducationContext.GradeInfluence
|
||||||
|
case "exam_evaluation":
|
||||||
|
return intake.EducationContext.ExamEvaluation
|
||||||
|
case "student_selection":
|
||||||
|
return intake.EducationContext.StudentSelection
|
||||||
|
case "minors_involved":
|
||||||
|
return intake.EducationContext.MinorsInvolved
|
||||||
|
case "teacher_review_required":
|
||||||
|
return intake.EducationContext.TeacherReviewRequired
|
||||||
|
case "learning_adaptation":
|
||||||
|
return intake.EducationContext.LearningAdaptation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getHealthcareContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.HealthcareContext == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch field {
|
||||||
|
case "diagnosis_support":
|
||||||
|
return intake.HealthcareContext.DiagnosisSupport
|
||||||
|
case "treatment_recommendation":
|
||||||
|
return intake.HealthcareContext.TreatmentRecommend
|
||||||
|
case "triage_decision":
|
||||||
|
return intake.HealthcareContext.TriageDecision
|
||||||
|
case "patient_data_processed":
|
||||||
|
return intake.HealthcareContext.PatientDataProcessed
|
||||||
|
case "medical_device":
|
||||||
|
return intake.HealthcareContext.MedicalDevice
|
||||||
|
case "clinical_validation":
|
||||||
|
return intake.HealthcareContext.ClinicalValidation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getLegalContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.LegalDomainContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "legal_advice": return intake.LegalDomainContext.LegalAdvice
|
||||||
|
case "contract_analysis": return intake.LegalDomainContext.ContractAnalysis
|
||||||
|
case "court_prediction": return intake.LegalDomainContext.CourtPrediction
|
||||||
|
case "access_to_justice": return intake.LegalDomainContext.AccessToJustice
|
||||||
|
case "client_confidential": return intake.LegalDomainContext.ClientConfidential
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getPublicSectorContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.PublicSectorContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "admin_decision": return intake.PublicSectorContext.AdminDecision
|
||||||
|
case "citizen_service": return intake.PublicSectorContext.CitizenService
|
||||||
|
case "benefit_allocation": return intake.PublicSectorContext.BenefitAllocation
|
||||||
|
case "public_safety": return intake.PublicSectorContext.PublicSafety
|
||||||
|
case "transparency_ensured": return intake.PublicSectorContext.TransparencyEnsured
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getCriticalInfraContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.CriticalInfraContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "grid_control": return intake.CriticalInfraContext.GridControl
|
||||||
|
case "safety_critical": return intake.CriticalInfraContext.SafetyCritical
|
||||||
|
case "anomaly_detection": return intake.CriticalInfraContext.AnomalyDetection
|
||||||
|
case "redundancy_exists": return intake.CriticalInfraContext.RedundancyExists
|
||||||
|
case "incident_response": return intake.CriticalInfraContext.IncidentResponse
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getAutomotiveContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.AutomotiveContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "autonomous_driving": return intake.AutomotiveContext.AutonomousDriving
|
||||||
|
case "safety_relevant": return intake.AutomotiveContext.SafetyRelevant
|
||||||
|
case "type_approval_needed": return intake.AutomotiveContext.TypeApprovalNeeded
|
||||||
|
case "functional_safety": return intake.AutomotiveContext.FunctionalSafety
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getRetailContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.RetailContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "pricing_personalized": return intake.RetailContext.PricingPersonalized
|
||||||
|
case "customer_profiling": return intake.RetailContext.CustomerProfiling
|
||||||
|
case "recommendation_engine": return intake.RetailContext.RecommendationEngine
|
||||||
|
case "credit_scoring": return intake.RetailContext.CreditScoring
|
||||||
|
case "dark_patterns": return intake.RetailContext.DarkPatterns
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getITSecurityContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.ITSecurityContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "employee_surveillance": return intake.ITSecurityContext.EmployeeSurveillance
|
||||||
|
case "network_monitoring": return intake.ITSecurityContext.NetworkMonitoring
|
||||||
|
case "threat_detection": return intake.ITSecurityContext.ThreatDetection
|
||||||
|
case "access_control_ai": return intake.ITSecurityContext.AccessControl
|
||||||
|
case "data_retention_logs": return intake.ITSecurityContext.DataRetention
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getLogisticsContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.LogisticsContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "driver_tracking": return intake.LogisticsContext.DriverTracking
|
||||||
|
case "route_optimization": return intake.LogisticsContext.RouteOptimization
|
||||||
|
case "workload_scoring": return intake.LogisticsContext.WorkloadScoring
|
||||||
|
case "predictive_maintenance": return intake.LogisticsContext.PredictiveMaint
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getConstructionContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.ConstructionContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "safety_monitoring": return intake.ConstructionContext.SafetyMonitoring
|
||||||
|
case "tenant_screening": return intake.ConstructionContext.TenantScreening
|
||||||
|
case "building_automation": return intake.ConstructionContext.BuildingAutomation
|
||||||
|
case "worker_safety": return intake.ConstructionContext.WorkerSafety
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getMarketingContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.MarketingContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "deepfake_content": return intake.MarketingContext.DeepfakeContent
|
||||||
|
case "content_moderation": return intake.MarketingContext.ContentModeration
|
||||||
|
case "behavioral_targeting": return intake.MarketingContext.BehavioralTargeting
|
||||||
|
case "minors_targeted": return intake.MarketingContext.MinorsTargeted
|
||||||
|
case "ai_content_labeled": return intake.MarketingContext.AIContentLabeled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getManufacturingContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.ManufacturingContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "machine_safety": return intake.ManufacturingContext.MachineSafety
|
||||||
|
case "quality_control": return intake.ManufacturingContext.QualityControl
|
||||||
|
case "process_control": return intake.ManufacturingContext.ProcessControl
|
||||||
|
case "ce_marking_required": return intake.ManufacturingContext.CEMarkingRequired
|
||||||
|
case "safety_validated": return intake.ManufacturingContext.SafetyValidated
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getAgricultureContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.AgricultureContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "pesticide_ai": return intake.AgricultureContext.PesticideAI
|
||||||
|
case "animal_welfare": return intake.AgricultureContext.AnimalWelfare
|
||||||
|
case "environmental_data": return intake.AgricultureContext.EnvironmentalData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getSocialServicesContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.SocialServicesCtx == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "vulnerable_groups": return intake.SocialServicesCtx.VulnerableGroups
|
||||||
|
case "benefit_decision": return intake.SocialServicesCtx.BenefitDecision
|
||||||
|
case "case_management": return intake.SocialServicesCtx.CaseManagement
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getHospitalityContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.HospitalityContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "guest_profiling": return intake.HospitalityContext.GuestProfiling
|
||||||
|
case "dynamic_pricing": return intake.HospitalityContext.DynamicPricing
|
||||||
|
case "review_manipulation": return intake.HospitalityContext.ReviewManipulation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getInsuranceContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.InsuranceContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "risk_classification": return intake.InsuranceContext.RiskClassification
|
||||||
|
case "claims_automation": return intake.InsuranceContext.ClaimsAutomation
|
||||||
|
case "premium_calculation": return intake.InsuranceContext.PremiumCalculation
|
||||||
|
case "fraud_detection": return intake.InsuranceContext.FraudDetection
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getInvestmentContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.InvestmentContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "algo_trading": return intake.InvestmentContext.AlgoTrading
|
||||||
|
case "investment_advice": return intake.InvestmentContext.InvestmentAdvice
|
||||||
|
case "robo_advisor": return intake.InvestmentContext.RoboAdvisor
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getDefenseContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.DefenseContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "dual_use": return intake.DefenseContext.DualUse
|
||||||
|
case "export_controlled": return intake.DefenseContext.ExportControlled
|
||||||
|
case "classified_data": return intake.DefenseContext.ClassifiedData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getSupplyChainContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.SupplyChainContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "supplier_monitoring": return intake.SupplyChainContext.SupplierMonitoring
|
||||||
|
case "human_rights_check": return intake.SupplyChainContext.HumanRightsCheck
|
||||||
|
case "environmental_impact": return intake.SupplyChainContext.EnvironmentalImpact
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getFacilityContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.FacilityContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "access_control_ai": return intake.FacilityContext.AccessControlAI
|
||||||
|
case "occupancy_tracking": return intake.FacilityContext.OccupancyTracking
|
||||||
|
case "energy_optimization": return intake.FacilityContext.EnergyOptimization
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *PolicyEngine) getSportsContextValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
|
if intake.SportsContext == nil { return nil }
|
||||||
|
switch field {
|
||||||
|
case "athlete_tracking": return intake.SportsContext.AthleteTracking
|
||||||
|
case "fan_profiling": return intake.SportsContext.FanProfiling
|
||||||
|
case "doping_detection": return intake.SportsContext.DopingDetection
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *PolicyEngine) getDataTypeValue(field string, intake *UseCaseIntake) interface{} {
|
func (e *PolicyEngine) getDataTypeValue(field string, intake *UseCaseIntake) interface{} {
|
||||||
switch field {
|
switch field {
|
||||||
case "personal_data":
|
case "personal_data":
|
||||||
@@ -880,3 +1255,70 @@ func categorizeControl(id string) string {
|
|||||||
}
|
}
|
||||||
return "organizational"
|
return "organizational"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calculateBetrvgConflictScore computes a works council conflict score (0-100).
|
||||||
|
// Higher score = higher risk of escalation with works council.
|
||||||
|
// Only relevant for German organizations with >= 5 employees.
|
||||||
|
func (e *PolicyEngine) calculateBetrvgConflictScore(intake *UseCaseIntake) (int, bool) {
|
||||||
|
if intake.Domain == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
consultationRequired := false
|
||||||
|
|
||||||
|
// Factor 1: Employee data processing (+10)
|
||||||
|
if intake.DataTypes.PersonalData && intake.DataTypes.EmployeeData {
|
||||||
|
score += 10
|
||||||
|
consultationRequired = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 2: System can monitor behavior/performance (+20)
|
||||||
|
if intake.EmployeeMonitoring {
|
||||||
|
score += 20
|
||||||
|
consultationRequired = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 3: Individualized usage data / logging (+15)
|
||||||
|
if intake.Retention.StorePrompts || intake.Retention.StoreResponses {
|
||||||
|
score += 15
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 4: Communication analysis (+10)
|
||||||
|
if intake.Purpose.CustomerSupport || intake.Purpose.Marketing {
|
||||||
|
// These purposes on employee data suggest communication analysis
|
||||||
|
if intake.DataTypes.EmployeeData {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 5: HR / Recruiting context (+20)
|
||||||
|
if intake.HRDecisionSupport {
|
||||||
|
score += 20
|
||||||
|
consultationRequired = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 6: Scoring / Ranking of employees (+10)
|
||||||
|
if intake.Outputs.RankingsOrScores || intake.Outputs.RecommendationsToUsers {
|
||||||
|
if intake.DataTypes.EmployeeData {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 7: Fully automated decisions (+10)
|
||||||
|
if intake.Automation == "fully_automated" {
|
||||||
|
score += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor 8: Works council NOT consulted (+5)
|
||||||
|
if consultationRequired && !intake.WorksCouncilConsulted {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 100
|
||||||
|
if score > 100 {
|
||||||
|
score = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return score, consultationRequired
|
||||||
|
}
|
||||||
|
|||||||
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package ucca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIRegistration represents an EU AI Database registration entry
|
||||||
|
type AIRegistration struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TenantID uuid.UUID `json:"tenant_id"`
|
||||||
|
|
||||||
|
// System
|
||||||
|
SystemName string `json:"system_name"`
|
||||||
|
SystemVersion string `json:"system_version,omitempty"`
|
||||||
|
SystemDescription string `json:"system_description,omitempty"`
|
||||||
|
IntendedPurpose string `json:"intended_purpose,omitempty"`
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
ProviderName string `json:"provider_name,omitempty"`
|
||||||
|
ProviderLegalForm string `json:"provider_legal_form,omitempty"`
|
||||||
|
ProviderAddress string `json:"provider_address,omitempty"`
|
||||||
|
ProviderCountry string `json:"provider_country,omitempty"`
|
||||||
|
EURepresentativeName string `json:"eu_representative_name,omitempty"`
|
||||||
|
EURepresentativeContact string `json:"eu_representative_contact,omitempty"`
|
||||||
|
|
||||||
|
// Classification
|
||||||
|
RiskClassification string `json:"risk_classification"`
|
||||||
|
AnnexIIICategory string `json:"annex_iii_category,omitempty"`
|
||||||
|
GPAIClassification string `json:"gpai_classification"`
|
||||||
|
|
||||||
|
// Conformity
|
||||||
|
ConformityAssessmentType string `json:"conformity_assessment_type,omitempty"`
|
||||||
|
NotifiedBodyName string `json:"notified_body_name,omitempty"`
|
||||||
|
NotifiedBodyID string `json:"notified_body_id,omitempty"`
|
||||||
|
CEMarking bool `json:"ce_marking"`
|
||||||
|
|
||||||
|
// Training data
|
||||||
|
TrainingDataCategories json.RawMessage `json:"training_data_categories,omitempty"`
|
||||||
|
TrainingDataSummary string `json:"training_data_summary,omitempty"`
|
||||||
|
|
||||||
|
// Status
|
||||||
|
RegistrationStatus string `json:"registration_status"`
|
||||||
|
EUDatabaseID string `json:"eu_database_id,omitempty"`
|
||||||
|
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||||
|
LastUpdateDate *time.Time `json:"last_update_date,omitempty"`
|
||||||
|
|
||||||
|
// Links
|
||||||
|
UCCAAssessmentID *uuid.UUID `json:"ucca_assessment_id,omitempty"`
|
||||||
|
DecisionTreeResultID *uuid.UUID `json:"decision_tree_result_id,omitempty"`
|
||||||
|
|
||||||
|
// Export
|
||||||
|
ExportData json.RawMessage `json:"export_data,omitempty"`
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
SubmittedBy string `json:"submitted_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationStore handles AI registration persistence
|
||||||
|
type RegistrationStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistrationStore creates a new registration store
|
||||||
|
func NewRegistrationStore(pool *pgxpool.Pool) *RegistrationStore {
|
||||||
|
return &RegistrationStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new registration
|
||||||
|
func (s *RegistrationStore) Create(ctx context.Context, r *AIRegistration) error {
|
||||||
|
r.ID = uuid.New()
|
||||||
|
r.CreatedAt = time.Now()
|
||||||
|
r.UpdatedAt = time.Now()
|
||||||
|
if r.RegistrationStatus == "" {
|
||||||
|
r.RegistrationStatus = "draft"
|
||||||
|
}
|
||||||
|
if r.RiskClassification == "" {
|
||||||
|
r.RiskClassification = "not_classified"
|
||||||
|
}
|
||||||
|
if r.GPAIClassification == "" {
|
||||||
|
r.GPAIClassification = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO ai_system_registrations (
|
||||||
|
id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||||
|
provider_name, provider_legal_form, provider_address, provider_country,
|
||||||
|
eu_representative_name, eu_representative_contact,
|
||||||
|
risk_classification, annex_iii_category, gpai_classification,
|
||||||
|
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||||
|
training_data_categories, training_data_summary,
|
||||||
|
registration_status, ucca_assessment_id, decision_tree_result_id,
|
||||||
|
created_by
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||||
|
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25
|
||||||
|
)`,
|
||||||
|
r.ID, r.TenantID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||||
|
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||||
|
r.EURepresentativeName, r.EURepresentativeContact,
|
||||||
|
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||||
|
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||||
|
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||||
|
r.RegistrationStatus, r.UCCAAssessmentID, r.DecisionTreeResultID,
|
||||||
|
r.CreatedBy,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all registrations for a tenant
|
||||||
|
func (s *RegistrationStore) List(ctx context.Context, tenantID uuid.UUID) ([]AIRegistration, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||||
|
provider_name, provider_legal_form, provider_address, provider_country,
|
||||||
|
eu_representative_name, eu_representative_contact,
|
||||||
|
risk_classification, annex_iii_category, gpai_classification,
|
||||||
|
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||||
|
training_data_categories, training_data_summary,
|
||||||
|
registration_status, eu_database_id, registration_date, last_update_date,
|
||||||
|
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||||
|
created_at, updated_at, created_by, submitted_by
|
||||||
|
FROM ai_system_registrations
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
tenantID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var registrations []AIRegistration
|
||||||
|
for rows.Next() {
|
||||||
|
var r AIRegistration
|
||||||
|
err := rows.Scan(
|
||||||
|
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||||
|
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||||
|
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||||
|
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||||
|
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||||
|
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||||
|
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||||
|
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
registrations = append(registrations, r)
|
||||||
|
}
|
||||||
|
return registrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID returns a registration by ID
|
||||||
|
func (s *RegistrationStore) GetByID(ctx context.Context, id uuid.UUID) (*AIRegistration, error) {
|
||||||
|
var r AIRegistration
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||||
|
provider_name, provider_legal_form, provider_address, provider_country,
|
||||||
|
eu_representative_name, eu_representative_contact,
|
||||||
|
risk_classification, annex_iii_category, gpai_classification,
|
||||||
|
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||||
|
training_data_categories, training_data_summary,
|
||||||
|
registration_status, eu_database_id, registration_date, last_update_date,
|
||||||
|
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||||
|
created_at, updated_at, created_by, submitted_by
|
||||||
|
FROM ai_system_registrations
|
||||||
|
WHERE id = $1`,
|
||||||
|
id,
|
||||||
|
).Scan(
|
||||||
|
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||||
|
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||||
|
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||||
|
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||||
|
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||||
|
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||||
|
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||||
|
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||||
|
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a registration
|
||||||
|
func (s *RegistrationStore) Update(ctx context.Context, r *AIRegistration) error {
|
||||||
|
r.UpdatedAt = time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE ai_system_registrations SET
|
||||||
|
system_name = $2, system_version = $3, system_description = $4, intended_purpose = $5,
|
||||||
|
provider_name = $6, provider_legal_form = $7, provider_address = $8, provider_country = $9,
|
||||||
|
eu_representative_name = $10, eu_representative_contact = $11,
|
||||||
|
risk_classification = $12, annex_iii_category = $13, gpai_classification = $14,
|
||||||
|
conformity_assessment_type = $15, notified_body_name = $16, notified_body_id = $17, ce_marking = $18,
|
||||||
|
training_data_categories = $19, training_data_summary = $20,
|
||||||
|
registration_status = $21, eu_database_id = $22,
|
||||||
|
export_data = $23, updated_at = $24, submitted_by = $25
|
||||||
|
WHERE id = $1`,
|
||||||
|
r.ID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||||
|
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||||
|
r.EURepresentativeName, r.EURepresentativeContact,
|
||||||
|
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||||
|
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||||
|
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||||
|
r.RegistrationStatus, r.EUDatabaseID,
|
||||||
|
r.ExportData, r.UpdatedAt, r.SubmittedBy,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus changes only the registration status
|
||||||
|
func (s *RegistrationStore) UpdateStatus(ctx context.Context, id uuid.UUID, status string, submittedBy string) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE ai_system_registrations
|
||||||
|
SET registration_status = $2, submitted_by = $3, updated_at = $4,
|
||||||
|
registration_date = CASE WHEN $2 = 'submitted' THEN $4 ELSE registration_date END,
|
||||||
|
last_update_date = $4
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, status, submittedBy, now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildExportJSON creates the EU AI Database submission JSON
|
||||||
|
func (s *RegistrationStore) BuildExportJSON(r *AIRegistration) json.RawMessage {
|
||||||
|
export := map[string]interface{}{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"submission_type": "ai_system_registration",
|
||||||
|
"regulation": "EU AI Act (EU) 2024/1689",
|
||||||
|
"article": "Art. 49",
|
||||||
|
"provider": map[string]interface{}{
|
||||||
|
"name": r.ProviderName,
|
||||||
|
"legal_form": r.ProviderLegalForm,
|
||||||
|
"address": r.ProviderAddress,
|
||||||
|
"country": r.ProviderCountry,
|
||||||
|
"eu_representative": r.EURepresentativeName,
|
||||||
|
"eu_rep_contact": r.EURepresentativeContact,
|
||||||
|
},
|
||||||
|
"system": map[string]interface{}{
|
||||||
|
"name": r.SystemName,
|
||||||
|
"version": r.SystemVersion,
|
||||||
|
"description": r.SystemDescription,
|
||||||
|
"purpose": r.IntendedPurpose,
|
||||||
|
},
|
||||||
|
"classification": map[string]interface{}{
|
||||||
|
"risk_level": r.RiskClassification,
|
||||||
|
"annex_iii_category": r.AnnexIIICategory,
|
||||||
|
"gpai": r.GPAIClassification,
|
||||||
|
},
|
||||||
|
"conformity": map[string]interface{}{
|
||||||
|
"assessment_type": r.ConformityAssessmentType,
|
||||||
|
"notified_body": r.NotifiedBodyName,
|
||||||
|
"notified_body_id": r.NotifiedBodyID,
|
||||||
|
"ce_marking": r.CEMarking,
|
||||||
|
},
|
||||||
|
"training_data": map[string]interface{}{
|
||||||
|
"categories": r.TrainingDataCategories,
|
||||||
|
"summary": r.TrainingDataSummary,
|
||||||
|
},
|
||||||
|
"status": r.RegistrationStatus,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(export)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -358,6 +358,128 @@ type AssessmentFilters struct {
|
|||||||
Offset int // OFFSET for pagination
|
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
|
// Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
65
ai-compliance-sdk/migrations/023_ai_registration_schema.sql
Normal file
65
ai-compliance-sdk/migrations/023_ai_registration_schema.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
-- Migration 023: AI System Registration Schema (Art. 49 AI Act)
|
||||||
|
-- Tracks EU AI Database registrations for High-Risk AI systems
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_system_registrations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- System identification
|
||||||
|
system_name VARCHAR(500) NOT NULL,
|
||||||
|
system_version VARCHAR(100),
|
||||||
|
system_description TEXT,
|
||||||
|
intended_purpose TEXT,
|
||||||
|
|
||||||
|
-- Provider info
|
||||||
|
provider_name VARCHAR(500),
|
||||||
|
provider_legal_form VARCHAR(200),
|
||||||
|
provider_address TEXT,
|
||||||
|
provider_country VARCHAR(10),
|
||||||
|
eu_representative_name VARCHAR(500),
|
||||||
|
eu_representative_contact TEXT,
|
||||||
|
|
||||||
|
-- Classification
|
||||||
|
risk_classification VARCHAR(50) DEFAULT 'not_classified',
|
||||||
|
-- CHECK (risk_classification IN ('not_classified', 'minimal_risk', 'limited_risk', 'high_risk', 'unacceptable'))
|
||||||
|
annex_iii_category VARCHAR(200),
|
||||||
|
gpai_classification VARCHAR(50) DEFAULT 'none',
|
||||||
|
-- CHECK (gpai_classification IN ('none', 'standard', 'systemic'))
|
||||||
|
|
||||||
|
-- Conformity
|
||||||
|
conformity_assessment_type VARCHAR(50),
|
||||||
|
-- CHECK (conformity_assessment_type IN ('internal', 'third_party', 'not_required'))
|
||||||
|
notified_body_name VARCHAR(500),
|
||||||
|
notified_body_id VARCHAR(100),
|
||||||
|
ce_marking BOOLEAN DEFAULT false,
|
||||||
|
|
||||||
|
-- Training data
|
||||||
|
training_data_categories JSONB DEFAULT '[]'::jsonb,
|
||||||
|
training_data_summary TEXT,
|
||||||
|
|
||||||
|
-- Registration status
|
||||||
|
registration_status VARCHAR(50) DEFAULT 'draft',
|
||||||
|
-- CHECK (registration_status IN ('draft', 'ready', 'submitted', 'registered', 'update_required', 'withdrawn'))
|
||||||
|
eu_database_id VARCHAR(200),
|
||||||
|
registration_date TIMESTAMPTZ,
|
||||||
|
last_update_date TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Links to other assessments
|
||||||
|
ucca_assessment_id UUID,
|
||||||
|
decision_tree_result_id UUID,
|
||||||
|
|
||||||
|
-- Export data (cached JSON for EU submission)
|
||||||
|
export_data JSONB,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(200),
|
||||||
|
submitted_by VARCHAR(200)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_tenant ON ai_system_registrations (tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_status ON ai_system_registrations (registration_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_classification ON ai_system_registrations (risk_classification);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_air_ucca ON ai_system_registrations (ucca_assessment_id);
|
||||||
@@ -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);
|
||||||
37
ai-compliance-sdk/migrations/025_tender_analysis_schema.sql
Normal file
37
ai-compliance-sdk/migrations/025_tender_analysis_schema.sql
Normal file
@@ -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);
|
||||||
65
ai-compliance-sdk/payment-compliance-pack/README.md
Normal file
65
ai-compliance-sdk/payment-compliance-pack/README.md
Normal file
@@ -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))
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
rules:
|
||||||
|
- id: payment-no-md5-sha1
|
||||||
|
message: Unsichere Hash-Algorithmen erkannt.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)\b(md5|sha1)\b
|
||||||
|
|
||||||
|
- id: payment-no-des-3des
|
||||||
|
message: Veraltete symmetrische Verfahren erkannt.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)\b(des|3des|tripledes)\b
|
||||||
|
|
||||||
|
- id: payment-no-ecb
|
||||||
|
message: ECB-Modus ist fuer sensible Daten ungeeignet.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)\becb\b
|
||||||
|
|
||||||
|
- id: payment-hardcoded-secret
|
||||||
|
message: Moeglicherweise hartkodiertes Secret erkannt.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
patterns:
|
||||||
|
- pattern-either:
|
||||||
|
- pattern: $KEY = "..."
|
||||||
|
- pattern: const $KEY = "..."
|
||||||
|
- pattern: final String $KEY = "..."
|
||||||
|
- metavariable-pattern:
|
||||||
|
metavariable: $KEY
|
||||||
|
pattern-regex: (?i).*(secret|apikey|api_key|password|passwd|privatekey|private_key|terminalkey|zvtkey|opiKey).*
|
||||||
|
|
||||||
|
- id: payment-weak-random
|
||||||
|
message: Nicht-kryptographischer Zufall in Sicherheitskontext erkannt.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java]
|
||||||
|
pattern-regex: (?i)(Math\.random|random\.random|new Random\()
|
||||||
|
|
||||||
|
- id: payment-disable-tls-verify
|
||||||
|
message: TLS-Zertifikatspruefung scheint deaktiviert zu sein.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(verify\s*=\s*False|rejectUnauthorized\s*:\s*false|InsecureSkipVerify\s*:\s*true|trustAll)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
rules:
|
||||||
|
- id: payment-sensitive-in-telemetry
|
||||||
|
message: Sensitive Zahlungsdaten in Telemetrie oder Tracing pruefen.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(trace|span|metric|telemetry).*(pan|cvv|track2|cardnumber|pin|expiry)
|
||||||
|
|
||||||
|
- id: payment-sensitive-in-cache
|
||||||
|
message: Sensitiver Wert in Cache-Key oder Cache-Payload pruefen.
|
||||||
|
severity: WARNING
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(cache|redis|memcache).*(pan|cvv|track2|cardnumber|pin)
|
||||||
|
|
||||||
|
- id: payment-sensitive-export
|
||||||
|
message: Export oder Report mit sensitiven Feldern pruefen.
|
||||||
|
severity: WARNING
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(export|report|csv|xlsx|pdf).*(pan|cvv|track2|cardnumber|pin)
|
||||||
|
|
||||||
|
- id: payment-test-fixture-real-data
|
||||||
|
message: Testdaten mit moeglichen echten Kartendaten pruefen.
|
||||||
|
severity: WARNING
|
||||||
|
languages: [json, yaml, python, javascript, typescript]
|
||||||
|
pattern-regex: (?i)(4111111111111111|5555555555554444|track2|cvv)
|
||||||
|
|
||||||
|
- id: payment-queue-sensitive-payload
|
||||||
|
message: Queue-Nachricht mit sensitiven Zahlungsfeldern pruefen.
|
||||||
|
severity: WARNING
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(publish|send|enqueue).*(pan|cvv|track2|cardnumber|pin)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
rules:
|
||||||
|
- id: payment-no-sensitive-logging-python
|
||||||
|
message: Sensitive Zahlungsdaten duerfen nicht geloggt werden.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python]
|
||||||
|
patterns:
|
||||||
|
- pattern-either:
|
||||||
|
- pattern: logging.$METHOD(..., $X, ...)
|
||||||
|
- pattern: logger.$METHOD(..., $X, ...)
|
||||||
|
- metavariable-pattern:
|
||||||
|
metavariable: $X
|
||||||
|
pattern-regex: (?i).*(pan|cvv|cvc|track2|track_2|cardnumber|card_number|karten|pin|expiry|ablauf).*
|
||||||
|
|
||||||
|
- id: payment-no-sensitive-logging-js
|
||||||
|
message: Sensitive Zahlungsdaten duerfen nicht geloggt werden.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [javascript, typescript]
|
||||||
|
patterns:
|
||||||
|
- pattern-either:
|
||||||
|
- pattern: console.$METHOD(..., $X, ...)
|
||||||
|
- pattern: logger.$METHOD(..., $X, ...)
|
||||||
|
- metavariable-pattern:
|
||||||
|
metavariable: $X
|
||||||
|
pattern-regex: (?i).*(pan|cvv|cvc|track2|cardnumber|pin|expiry).*
|
||||||
|
|
||||||
|
- id: payment-no-token-logging
|
||||||
|
message: Tokens oder Session-IDs duerfen nicht geloggt werden.
|
||||||
|
severity: ERROR
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(log|logger|logging|console)\.(debug|info|warn|error).*?(token|sessionid|session_id|authheader|authorization)
|
||||||
|
|
||||||
|
- id: payment-no-debug-logging-prod-flag
|
||||||
|
message: Debug-Logging darf in produktiven Pfaden nicht fest aktiviert sein.
|
||||||
|
severity: WARNING
|
||||||
|
languages: [python, javascript, typescript, java, go]
|
||||||
|
pattern-regex: (?i)(DEBUG\s*=\s*true|debug\s*:\s*true|setLevel\(.*DEBUG.*\))
|
||||||
|
|
||||||
|
- id: payment-audit-log-admin-action
|
||||||
|
message: Administrative sicherheitsrelevante Aktion ohne Audit-Hinweis pruefen.
|
||||||
|
severity: INFO
|
||||||
|
languages: [python, javascript, typescript]
|
||||||
|
pattern-regex: (?i)(deleteTerminal|rotateKey|updateConfig|disableDevice|enableMaintenance)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Terminal State Machine Invariants
|
||||||
|
|
||||||
|
## Invariant 1
|
||||||
|
APPROVED darf ohne expliziten Reversal-Pfad nicht in WAITING_FOR_TERMINAL zurueckgehen.
|
||||||
|
|
||||||
|
## Invariant 2
|
||||||
|
DECLINED darf keinen Buchungserfolg oder Success-Report erzeugen.
|
||||||
|
|
||||||
|
## Invariant 3
|
||||||
|
duplicate_response darf keinen zweiten Commit und keine zweite Success-Bestaetigung erzeugen.
|
||||||
|
|
||||||
|
## Invariant 4
|
||||||
|
DESYNC muss Audit-Logging und Klaerungsstatus ausloesen.
|
||||||
|
|
||||||
|
## Invariant 5
|
||||||
|
REVERSAL_PENDING darf nicht mehrfach parallel ausgeloest werden.
|
||||||
|
|
||||||
|
## Invariant 6
|
||||||
|
invalid_command darf nie zu APPROVED fuehren.
|
||||||
|
|
||||||
|
## Invariant 7
|
||||||
|
terminal_timeout darf nie stillschweigend als Erfolg interpretiert werden.
|
||||||
|
|
||||||
|
## Invariant 8
|
||||||
|
Late responses nach finalem Zustand muessen kontrolliert behandelt werden.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Terminal Payment State Machine
|
||||||
|
|
||||||
|
## States
|
||||||
|
- IDLE
|
||||||
|
- SESSION_OPEN
|
||||||
|
- PAYMENT_REQUESTED
|
||||||
|
- WAITING_FOR_TERMINAL
|
||||||
|
- APPROVED
|
||||||
|
- DECLINED
|
||||||
|
- CANCELLED
|
||||||
|
- REVERSAL_PENDING
|
||||||
|
- REVERSED
|
||||||
|
- ERROR
|
||||||
|
- DESYNC
|
||||||
|
|
||||||
|
## Events
|
||||||
|
- open_session
|
||||||
|
- close_session
|
||||||
|
- send_payment
|
||||||
|
- terminal_ack
|
||||||
|
- terminal_approve
|
||||||
|
- terminal_decline
|
||||||
|
- terminal_timeout
|
||||||
|
- backend_timeout
|
||||||
|
- reconnect
|
||||||
|
- cancel_request
|
||||||
|
- reversal_request
|
||||||
|
- reversal_success
|
||||||
|
- reversal_fail
|
||||||
|
- duplicate_response
|
||||||
|
- invalid_command
|
||||||
|
|
||||||
|
## Transitions
|
||||||
|
| From | Event | To |
|
||||||
|
|------|-------|----|
|
||||||
|
| IDLE | open_session | SESSION_OPEN |
|
||||||
|
| SESSION_OPEN | send_payment | PAYMENT_REQUESTED |
|
||||||
|
| PAYMENT_REQUESTED | terminal_ack | WAITING_FOR_TERMINAL |
|
||||||
|
| WAITING_FOR_TERMINAL | terminal_approve | APPROVED |
|
||||||
|
| WAITING_FOR_TERMINAL | terminal_decline | DECLINED |
|
||||||
|
| WAITING_FOR_TERMINAL | terminal_timeout | DESYNC |
|
||||||
|
| WAITING_FOR_TERMINAL | cancel_request | CANCELLED |
|
||||||
|
| APPROVED | reversal_request | REVERSAL_PENDING |
|
||||||
|
| REVERSAL_PENDING | reversal_success | REVERSED |
|
||||||
|
| REVERSAL_PENDING | reversal_fail | ERROR |
|
||||||
|
| * | invalid_command | ERROR |
|
||||||
|
| * | backend_timeout | DESYNC |
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-001",
|
||||||
|
"name": "Duplicate approved response",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["terminal_approve", "duplicate_response"],
|
||||||
|
"expected_final_state": "APPROVED",
|
||||||
|
"invariants": ["Invariant 3"],
|
||||||
|
"mapped_controls": ["TRANS-004", "TRANS-009", "ZVT-RESP-005"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-002",
|
||||||
|
"name": "Timeout then late success",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["terminal_timeout", "terminal_approve"],
|
||||||
|
"expected_final_state": "DESYNC",
|
||||||
|
"invariants": ["Invariant 4", "Invariant 7", "Invariant 8"],
|
||||||
|
"mapped_controls": ["TRANS-005", "TRANS-007", "TERMSYNC-009", "TERMSYNC-010"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-003",
|
||||||
|
"name": "Decline must not produce booking",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["terminal_decline"],
|
||||||
|
"expected_final_state": "DECLINED",
|
||||||
|
"invariants": ["Invariant 2"],
|
||||||
|
"mapped_controls": ["TRANS-011", "TRANS-025", "ZVT-RESP-002"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-004",
|
||||||
|
"name": "Invalid reversal before approval",
|
||||||
|
"initial_state": "PAYMENT_REQUESTED",
|
||||||
|
"events": ["reversal_request"],
|
||||||
|
"expected_final_state": "ERROR",
|
||||||
|
"invariants": ["Invariant 6"],
|
||||||
|
"mapped_controls": ["ZVT-REV-001", "ZVT-STATE-002", "ZVT-CMD-001"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-005",
|
||||||
|
"name": "Cancel during waiting",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["cancel_request"],
|
||||||
|
"expected_final_state": "CANCELLED",
|
||||||
|
"invariants": ["Invariant 7"],
|
||||||
|
"mapped_controls": ["TRANS-006", "ZVT-CMD-001", "ZVT-STATE-003"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-006",
|
||||||
|
"name": "Backend timeout after terminal ack",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["terminal_ack", "backend_timeout"],
|
||||||
|
"expected_final_state": "DESYNC",
|
||||||
|
"invariants": ["Invariant 4", "Invariant 7"],
|
||||||
|
"mapped_controls": ["TERMSYNC-010", "TRANS-012", "ZVT-SESSION-003"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-007",
|
||||||
|
"name": "Parallel reversal requests",
|
||||||
|
"initial_state": "APPROVED",
|
||||||
|
"events": ["reversal_request", "reversal_request"],
|
||||||
|
"expected_final_state": "REVERSAL_PENDING",
|
||||||
|
"invariants": ["Invariant 5"],
|
||||||
|
"mapped_controls": ["ZVT-REV-003", "TRANS-016", "TRANS-019"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-008",
|
||||||
|
"name": "Unknown response code",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["terminal_ack", "invalid_command"],
|
||||||
|
"expected_final_state": "ERROR",
|
||||||
|
"invariants": ["Invariant 6"],
|
||||||
|
"mapped_controls": ["ZVT-RESP-003", "ZVT-COM-005", "ZVT-STATE-005"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-009",
|
||||||
|
"name": "Reconnect and resume controlled",
|
||||||
|
"initial_state": "SESSION_OPEN",
|
||||||
|
"events": ["send_payment", "terminal_timeout", "reconnect"],
|
||||||
|
"expected_final_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"invariants": ["Invariant 7"],
|
||||||
|
"mapped_controls": ["ZVT-SESSION-004", "TRANS-007", "ZVT-RT-004"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test_id": "ZVT-SM-010",
|
||||||
|
"name": "Late response after cancel",
|
||||||
|
"initial_state": "WAITING_FOR_TERMINAL",
|
||||||
|
"events": ["cancel_request", "terminal_approve"],
|
||||||
|
"expected_final_state": "DESYNC",
|
||||||
|
"invariants": ["Invariant 4", "Invariant 8"],
|
||||||
|
"mapped_controls": ["TERMSYNC-008", "TERMSYNC-009", "TRANS-018"]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -5,13 +5,13 @@
|
|||||||
"id": "dsgvo",
|
"id": "dsgvo",
|
||||||
"file": "dsgvo_v2.json",
|
"file": "dsgvo_v2.json",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"count": 80
|
"count": 84
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ai_act",
|
"id": "ai_act",
|
||||||
"file": "ai_act_v2.json",
|
"file": "ai_act_v2.json",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"count": 60
|
"count": 81
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nis2",
|
"id": "nis2",
|
||||||
@@ -54,8 +54,20 @@
|
|||||||
"file": "dora_v2.json",
|
"file": "dora_v2.json",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"count": 20
|
"count": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "betrvg",
|
||||||
|
"file": "betrvg_v2.json",
|
||||||
|
"version": "1.0",
|
||||||
|
"count": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agg",
|
||||||
|
"file": "agg_v2.json",
|
||||||
|
"version": "1.0",
|
||||||
|
"count": 8
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tom_mapping_file": "_tom_mapping.json",
|
"tom_mapping_file": "_tom_mapping.json",
|
||||||
"total_obligations": 325
|
"total_obligations": 370
|
||||||
}
|
}
|
||||||
140
ai-compliance-sdk/policies/obligations/v2/agg_v2.json
Normal file
140
ai-compliance-sdk/policies/obligations/v2/agg_v2.json
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"regulation": "agg",
|
||||||
|
"regulation_full_name": "Allgemeines Gleichbehandlungsgesetz (AGG)",
|
||||||
|
"version": "1.0",
|
||||||
|
"obligations": [
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-001",
|
||||||
|
"title": "Diskriminierungsfreie Gestaltung von KI-Auswahlverfahren",
|
||||||
|
"description": "KI-gestuetzte Auswahlverfahren (Recruiting, Befoerderung, Kuendigung) muessen so gestaltet sein, dass keine Benachteiligung nach § 1 AGG Merkmalen (Geschlecht, Alter, ethnische Herkunft, Religion, Behinderung, sexuelle Identitaet) erfolgt.",
|
||||||
|
"applies_when": "AI system used in employment decisions",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.automated_screening", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 1, § 7", "title": "Benachteiligungsverbot" }, { "norm": "AGG", "article": "§ 11", "title": "Ausschreibung" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§ 1, § 7, § 11 AGG" }],
|
||||||
|
"category": "Governance",
|
||||||
|
"responsible": "HR / Compliance",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einsatz im Auswahlverfahren" },
|
||||||
|
"sanctions": { "description": "Schadensersatz bis 3 Monatsgehaelter (§ 15 AGG), Beweislastumkehr (§ 22 AGG)" },
|
||||||
|
"evidence": [{ "name": "Bias-Audit-Bericht", "required": true }, "AGG-Konformitaetspruefung"],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": ["TOM.FAIR.01"],
|
||||||
|
"breakpilot_feature": "/sdk/use-cases",
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-002",
|
||||||
|
"title": "Keine Nutzung von Proxy-Merkmalen fuer Diskriminierung",
|
||||||
|
"description": "Das KI-System darf keine Proxy-Merkmale verwenden, die indirekt auf geschuetzte Kategorien schliessen lassen (z.B. Name → Herkunft, Foto → Alter/Geschlecht, PLZ → sozialer Hintergrund).",
|
||||||
|
"applies_when": "AI processes applicant data with identifiable features",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.agg_categories_visible", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 3 Abs. 2", "title": "Mittelbare Benachteiligung" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§ 3 Abs. 2 AGG" }],
|
||||||
|
"category": "Technisch",
|
||||||
|
"responsible": "Data Science / Compliance",
|
||||||
|
"priority": "kritisch",
|
||||||
|
"evidence": [{ "name": "Feature-Analyse-Dokumentation (keine Proxy-Merkmale)", "required": true }],
|
||||||
|
"tom_control_ids": ["TOM.FAIR.01"],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-003",
|
||||||
|
"title": "Beweislast-Dokumentation fuehren (§ 22 AGG)",
|
||||||
|
"description": "Bei Indizien fuer eine Benachteiligung kehrt sich die Beweislast um (§ 22 AGG). Der Arbeitgeber muss beweisen, dass KEINE Diskriminierung vorliegt. Daher ist lueckenlose Dokumentation der KI-Entscheidungslogik zwingend.",
|
||||||
|
"applies_when": "AI supports employment decisions in Germany",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 22", "title": "Beweislast" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§ 22 AGG" }],
|
||||||
|
"category": "Governance",
|
||||||
|
"responsible": "HR / Legal",
|
||||||
|
"priority": "kritisch",
|
||||||
|
"deadline": { "type": "recurring", "interval": "laufend" },
|
||||||
|
"sanctions": { "description": "Ohne Dokumentation kann Beweislastumkehr nicht abgewehrt werden — Schadensersatz nach § 15 AGG" },
|
||||||
|
"evidence": [{ "name": "Entscheidungsprotokoll mit KI-Begruendung", "required": true }, "Audit-Trail aller KI-Bewertungen"],
|
||||||
|
"tom_control_ids": ["TOM.LOG.01", "TOM.GOV.01"],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-004",
|
||||||
|
"title": "Regelmaessige Bias-Audits bei KI-gestuetzter Personalauswahl",
|
||||||
|
"description": "KI-Systeme im Recruiting muessen regelmaessig auf Bias geprueft werden: statistische Analyse der Ergebnisse nach Geschlecht, Altersgruppen und soweit zulaessig nach Herkunft.",
|
||||||
|
"applies_when": "AI ranks or scores candidates",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.candidate_ranking", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 1, § 3", "title": "Unmittelbare und mittelbare Benachteiligung" }],
|
||||||
|
"category": "Technisch",
|
||||||
|
"responsible": "Data Science",
|
||||||
|
"priority": "hoch",
|
||||||
|
"deadline": { "type": "recurring", "interval": "quartalsweise" },
|
||||||
|
"evidence": [{ "name": "Bias-Audit-Ergebnis (letzte 3 Monate)", "required": true }],
|
||||||
|
"tom_control_ids": ["TOM.FAIR.01"],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-005",
|
||||||
|
"title": "Schulung der HR-Entscheider ueber KI-Grenzen",
|
||||||
|
"description": "Personen, die KI-gestuetzte Empfehlungen im Personalbereich nutzen, muessen ueber Systemgrenzen, Bias-Risiken und ihre Pflicht zur eigenstaendigen Pruefung geschult werden.",
|
||||||
|
"applies_when": "AI provides recommendations for HR decisions",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 12 Abs. 2", "title": "Pflicht des Arbeitgebers zu Schutzmassnahmen" }],
|
||||||
|
"category": "Organisatorisch",
|
||||||
|
"responsible": "HR / Training",
|
||||||
|
"priority": "hoch",
|
||||||
|
"deadline": { "type": "recurring", "interval": "jaehrlich" },
|
||||||
|
"evidence": [{ "name": "Schulungsnachweis AGG + KI-Kompetenz", "required": true }],
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-006",
|
||||||
|
"title": "Beschwerdemechanismus fuer abgelehnte Bewerber",
|
||||||
|
"description": "Bewerber muessen die Moeglichkeit haben, sich ueber KI-gestuetzte Auswahlentscheidungen zu beschweren. Die zustaendige Stelle (§ 13 AGG) muss benannt sein.",
|
||||||
|
"applies_when": "AI used in applicant selection process",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.automated_screening", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 13", "title": "Beschwerderecht" }],
|
||||||
|
"category": "Organisatorisch",
|
||||||
|
"responsible": "HR",
|
||||||
|
"priority": "hoch",
|
||||||
|
"evidence": [{ "name": "Dokumentierter Beschwerdemechanismus", "required": true }],
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-007",
|
||||||
|
"title": "Schadensersatzrisiko dokumentieren und versichern",
|
||||||
|
"description": "Das Schadensersatzrisiko bei AGG-Verstoessen (bis 3 Monatsgehaelter pro Fall, § 15 AGG) muss bewertet und dokumentiert werden. Bei hohem Bewerbungsvolumen kann das kumulierte Risiko erheblich sein.",
|
||||||
|
"applies_when": "AI processes high volume of applications",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "hr_context.automated_screening", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 15", "title": "Entschaedigung und Schadensersatz" }],
|
||||||
|
"category": "Governance",
|
||||||
|
"responsible": "Legal / Finance",
|
||||||
|
"priority": "hoch",
|
||||||
|
"evidence": [{ "name": "Risikobewertung AGG-Schadensersatz", "required": false }],
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "AGG-OBL-008",
|
||||||
|
"title": "KI-Stellenausschreibungen AGG-konform gestalten",
|
||||||
|
"description": "Wenn KI bei der Erstellung oder Optimierung von Stellenausschreibungen eingesetzt wird, muss sichergestellt sein, dass die Ausschreibungen keine diskriminierenden Formulierungen enthalten (§ 11 AGG).",
|
||||||
|
"applies_when": "AI generates or optimizes job postings",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }] },
|
||||||
|
"legal_basis": [{ "norm": "AGG", "article": "§ 11", "title": "Ausschreibung" }],
|
||||||
|
"category": "Organisatorisch",
|
||||||
|
"responsible": "HR / Marketing",
|
||||||
|
"priority": "hoch",
|
||||||
|
"evidence": [{ "name": "Pruefprotokoll Stellenausschreibung auf AGG-Konformitaet", "required": false }],
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"valid_from": "2006-08-18",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"controls": [],
|
||||||
|
"incident_deadlines": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
250
ai-compliance-sdk/policies/obligations/v2/betrvg_v2.json
Normal file
250
ai-compliance-sdk/policies/obligations/v2/betrvg_v2.json
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
{
|
||||||
|
"regulation": "betrvg",
|
||||||
|
"regulation_full_name": "Betriebsverfassungsgesetz (BetrVG)",
|
||||||
|
"version": "1.0",
|
||||||
|
"obligations": [
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-001",
|
||||||
|
"title": "Mitbestimmung bei technischen Ueberwachungseinrichtungen",
|
||||||
|
"description": "Einfuehrung und Anwendung von technischen Einrichtungen, die dazu bestimmt sind, das Verhalten oder die Leistung der Arbeitnehmer zu ueberwachen, beduerfen der Zustimmung des Betriebsrats. Das BAG hat klargestellt, dass bereits die objektive Eignung zur Ueberwachung genuegt — eine tatsaechliche Nutzung zu diesem Zweck ist nicht erforderlich (BAG 1 ABR 20/21, 1 ABN 36/18).",
|
||||||
|
"applies_when": "technical system can monitor employee behavior or performance",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "IN_ARRAY", "value": ["DE", "AT"] }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei technischen Ueberwachungseinrichtungen" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§ 87 Abs. 1 Nr. 6 BetrVG" }, { "type": "court_decision", "ref": "BAG 1 ABR 20/21 (Microsoft 365)" }, { "type": "court_decision", "ref": "BAG 1 ABN 36/18 (Standardsoftware)" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "Arbeitgeber / HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einfuehrung des Systems" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch des Betriebsrats, einstweilige Verfuegung moeglich, Betriebsvereinbarung ueber Einigungsstelle erzwingbar (§ 87 Abs. 2 BetrVG)" },
|
||||||
|
"evidence": [{ "name": "Betriebsvereinbarung oder dokumentierte Zustimmung des Betriebsrats", "required": true }, "Protokoll der Betriebsratssitzung"],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": ["TOM.GOV.01", "TOM.AC.01"],
|
||||||
|
"breakpilot_feature": "/sdk/betriebsvereinbarung",
|
||||||
|
"valid_from": "1972-01-19",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0",
|
||||||
|
"how_to_implement": "Betriebsrat fruehzeitig informieren, gemeinsame Bewertung der Ueberwachungseignung durchfuehren, Betriebsvereinbarung mit Zweckbindung und verbotenen Nutzungen abschliessen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-002",
|
||||||
|
"title": "Keine Geringfuegigkeitsschwelle bei Standardsoftware",
|
||||||
|
"description": "Auch alltaegliche Standardsoftware (Excel, Word, E-Mail-Clients) unterliegt der Mitbestimmung, wenn sie objektiv geeignet ist, Verhaltens- oder Leistungsdaten zu erheben. Es gibt keine Geringfuegigkeitsschwelle (BAG 1 ABN 36/18).",
|
||||||
|
"applies_when": "any software used by employees that can log or track usage",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung — keine Geringfuegigkeitsschwelle" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABN 36/18" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "IT-Leitung / HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einfuehrung oder Aenderung" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch, einstweilige Verfuegung" },
|
||||||
|
"evidence": [{ "name": "Bestandsaufnahme aller IT-Systeme mit Ueberwachungseignung", "required": true }],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2018-10-23",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-003",
|
||||||
|
"title": "Mitbestimmung bei Ueberwachung durch Drittsysteme (SaaS/Cloud)",
|
||||||
|
"description": "Auch wenn die Ueberwachung ueber ein Dritt-System (SaaS, Cloud, externer Anbieter) laeuft, bleibt der Betriebsrat zu beteiligen. Die Verantwortung des Arbeitgebers entfaellt nicht durch Auslagerung (BAG 1 ABR 68/13).",
|
||||||
|
"applies_when": "cloud or SaaS system processes employee data",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Drittsystemen" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 68/13" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "IT-Leitung / Einkauf",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Vertragsschluss mit SaaS-Anbieter" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch" },
|
||||||
|
"evidence": [{ "name": "Datenschutz-Folgenabschaetzung fuer Cloud-Dienst", "required": false }, "Betriebsvereinbarung"],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": ["TOM.PROC.01"],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2015-07-21",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-004",
|
||||||
|
"title": "Mitbestimmung bei E-Mail- und Kommunikationssoftware",
|
||||||
|
"description": "Sowohl Einfuehrung als auch Nutzung softwarebasierter Anwendungen fuer die E-Mail-Kommunikation sind mitbestimmungspflichtig (BAG 1 ABR 31/19). Dies gilt auch fuer Teams, Slack und vergleichbare Messenger.",
|
||||||
|
"applies_when": "organization introduces or changes email or messaging systems",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Kommunikationssoftware" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 31/19" }, { "type": "court_decision", "ref": "BAG 1 ABR 46/10" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "IT-Leitung / HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einfuehrung oder Funktionsaenderung" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch, einstweilige Verfuegung" },
|
||||||
|
"evidence": [{ "name": "Betriebsvereinbarung zu E-Mail-/Messaging-Nutzung", "required": true }],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": ["TOM.AC.01"],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2021-01-27",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-005",
|
||||||
|
"title": "Verbot der dauerhaften Leistungsueberwachung",
|
||||||
|
"description": "Eine dauerhafte quantitative Erfassung und Auswertung einzelner Arbeitsschritte stellt einen schwerwiegenden Eingriff in das Persoenlichkeitsrecht dar (BAG 1 ABR 46/15). Belastungsstatistiken und KPI-Dashboards auf Personenebene beduerfen besonderer Rechtfertigung.",
|
||||||
|
"applies_when": "system provides individual performance metrics or KPIs",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "purpose.profiling", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Persoenlichkeitsschutz bei Kennzahlenueberwachung" }, { "norm": "GG", "article": "Art. 2 Abs. 1 i.V.m. Art. 1 Abs. 1", "title": "Allgemeines Persoenlichkeitsrecht" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 46/15 (Belastungsstatistik)" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "HR / Compliance",
|
||||||
|
"deadline": { "type": "recurring", "interval": "laufend" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch, Schadensersatz bei Persoenlichkeitsrechtsverletzung" },
|
||||||
|
"evidence": [{ "name": "Nachweis dass keine individuelle Leistungsueberwachung stattfindet", "required": true }],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": ["TOM.GOV.03"],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2017-04-25",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-006",
|
||||||
|
"title": "Unterrichtung bei Planung technischer Anlagen",
|
||||||
|
"description": "Der Arbeitgeber hat den Betriebsrat ueber die Planung von technischen Anlagen rechtzeitig unter Vorlage der erforderlichen Unterlagen zu unterrichten und mit ihm zu beraten.",
|
||||||
|
"applies_when": "organization plans new technical infrastructure",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 90 Abs. 1 Nr. 3", "title": "Unterrichtungs- und Beratungsrechte bei Planung" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§ 90 BetrVG" }],
|
||||||
|
"category": "Information",
|
||||||
|
"responsible": "IT-Leitung",
|
||||||
|
"deadline": { "type": "on_event", "event": "Rechtzeitig vor Umsetzung" },
|
||||||
|
"sanctions": { "description": "Beratungsanspruch, ggf. Aussetzung der Massnahme" },
|
||||||
|
"evidence": [{ "name": "Unterrichtungsschreiben an Betriebsrat mit technischer Dokumentation", "required": true }],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "1972-01-19",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-007",
|
||||||
|
"title": "Mitbestimmung bei Personalfrageboegen und Bewertungssystemen",
|
||||||
|
"description": "Personalfrageboegen und allgemeine Beurteilungsgrundsaetze beduerfen der Zustimmung des Betriebsrats. Dies umfasst auch KI-gestuetzte Bewertungssysteme fuer Mitarbeiterbeurteilungen (BAG 1 ABR 40/07).",
|
||||||
|
"applies_when": "AI or IT system supports employee evaluation or surveys",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "purpose.profiling", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 94", "title": "Personalfrageboegen, Beurteilungsgrundsaetze" }, { "norm": "BetrVG", "article": "§ 95", "title": "Auswahlrichtlinien" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 40/07" }, { "type": "court_decision", "ref": "BAG 1 ABR 16/07" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einfuehrung des Bewertungssystems" },
|
||||||
|
"sanctions": { "description": "Nichtigkeit der Bewertung, Unterlassungsanspruch" },
|
||||||
|
"evidence": [{ "name": "Betriebsvereinbarung zu Beurteilungsgrundsaetzen", "required": true }],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": ["TOM.GOV.01"],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "1972-01-19",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-008",
|
||||||
|
"title": "Mitbestimmung bei KI-gestuetztem Recruiting",
|
||||||
|
"description": "KI-Systeme im Recruiting-Prozess (CV-Screening, Ranking, Vorselektion) beruehren die Mitbestimmung bei Auswahlrichtlinien (§ 95 BetrVG) und ggf. bei Einstellungen (§ 99 BetrVG). Zusaetzlich AI Act Hochrisiko-Klassifikation (Annex III Nr. 4).",
|
||||||
|
"applies_when": "AI system used in hiring, promotion or termination decisions",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "purpose.automation", "operator": "EQUALS", "value": true }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 95", "title": "Auswahlrichtlinien" }, { "norm": "BetrVG", "article": "§ 99", "title": "Mitbestimmung bei personellen Einzelmassnahmen" }, { "norm": "EU AI Act", "article": "Annex III Nr. 4", "title": "Hochrisiko: Beschaeftigung" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§ 95, § 99 BetrVG" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "HR / Legal",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einsatz im Recruiting" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch, Anfechtung der Einstellung, AI Act Bussgeld bei Hochrisiko-Verstoss" },
|
||||||
|
"evidence": [{ "name": "Betriebsvereinbarung KI im Recruiting", "required": true }, "DSFA", "AI Act Konformitaetsbewertung"],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": ["TOM.GOV.01", "TOM.FAIR.01"],
|
||||||
|
"breakpilot_feature": "/sdk/ai-act",
|
||||||
|
"valid_from": "1972-01-19",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-009",
|
||||||
|
"title": "Mitbestimmung bei Betriebsaenderungen durch KI",
|
||||||
|
"description": "Grundlegende Aenderung der Betriebsorganisation durch KI-Einfuehrung kann eine Betriebsaenderung darstellen. In Unternehmen mit mehr als 20 wahlberechtigten Arbeitnehmern ist ein Interessenausgleich zu versuchen und ein Sozialplan aufzustellen.",
|
||||||
|
"applies_when": "AI introduction fundamentally changes work organization",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "organization.employee_count", "operator": "GREATER_THAN", "value": 20 }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 111", "title": "Betriebsaenderungen" }, { "norm": "BetrVG", "article": "§ 112", "title": "Interessenausgleich, Sozialplan" }],
|
||||||
|
"sources": [{ "type": "national_law", "ref": "§§ 111-113 BetrVG" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "Geschaeftsfuehrung / HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Rechtzeitig vor Umsetzung" },
|
||||||
|
"sanctions": { "description": "Nachteilsausgleich, Sozialplananspruch, Anfechtung der Massnahme" },
|
||||||
|
"evidence": [{ "name": "Interessenausgleich", "required": false }, "Sozialplan", "Unterrichtung des Betriebsrats"],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "1972-01-19",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-010",
|
||||||
|
"title": "Zustaendigkeit bei konzernweiten IT-Systemen",
|
||||||
|
"description": "Bei konzernweit eingesetzten IT-Systemen (z.B. M365, SAP) kann nicht der lokale Betriebsrat, sondern der Gesamt- oder Konzernbetriebsrat zustaendig sein (BAG 1 ABR 45/11). Die Zustaendigkeitsabgrenzung ist vor Einfuehrung zu klaeren.",
|
||||||
|
"applies_when": "IT system deployed across multiple establishments or companies",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 50 Abs. 1", "title": "Zustaendigkeit Gesamtbetriebsrat" }, { "norm": "BetrVG", "article": "§ 58 Abs. 1", "title": "Zustaendigkeit Konzernbetriebsrat" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 45/11 (SAP ERP)" }, { "type": "court_decision", "ref": "BAG 1 ABR 2/05" }],
|
||||||
|
"category": "Organisation",
|
||||||
|
"responsible": "HR / Legal",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Einfuehrung" },
|
||||||
|
"sanctions": { "description": "Unwirksamkeit der Vereinbarung bei falschem Verhandlungspartner" },
|
||||||
|
"evidence": [{ "name": "Zustaendigkeitsbestimmung dokumentiert", "required": true }],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2012-09-25",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-011",
|
||||||
|
"title": "Change-Management — erneute Mitbestimmung bei Funktionserweiterungen",
|
||||||
|
"description": "Neue Module, Funktionen oder Konnektoren in bestehenden IT-Systemen koennen eine erneute Mitbestimmung ausloesen, wenn sie die Ueberwachungseignung aendern oder erweitern (BAG 1 ABR 20/21 — Anwendung, nicht nur Einfuehrung).",
|
||||||
|
"applies_when": "existing IT system receives feature updates affecting monitoring capability",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_types.employee_data", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Anwendung (nicht nur Einfuehrung)" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 20/21" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "IT-Leitung / HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Aktivierung neuer Funktionen" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch" },
|
||||||
|
"evidence": [{ "name": "Change-Management-Protokoll mit BR-Bewertung", "required": true }],
|
||||||
|
"priority": "hoch",
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2022-03-08",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BETRVG-OBL-012",
|
||||||
|
"title": "Videoueberwachung — Mitbestimmung und Verhaeltnismaessigkeit",
|
||||||
|
"description": "Videoueberwachung am Arbeitsplatz ist grundsaetzlich mitbestimmungspflichtig. Die Regelungen ueber Einfuehrung und Ausgestaltung beduerfen der Zustimmung des Betriebsrats (BAG 1 ABR 78/11, 1 ABR 21/03).",
|
||||||
|
"applies_when": "organization uses video surveillance that may capture employees",
|
||||||
|
"applies_when_condition": { "all_of": [{ "field": "organization.country", "operator": "EQUALS", "value": "DE" }, { "field": "data_protection.video_surveillance", "operator": "EQUALS", "value": true }] },
|
||||||
|
"legal_basis": [{ "norm": "BetrVG", "article": "§ 87 Abs. 1 Nr. 6", "title": "Mitbestimmung bei Videoueberwachung" }],
|
||||||
|
"sources": [{ "type": "court_decision", "ref": "BAG 1 ABR 78/11" }, { "type": "court_decision", "ref": "BAG 1 ABR 21/03" }],
|
||||||
|
"category": "Mitbestimmung",
|
||||||
|
"responsible": "Facility Management / HR",
|
||||||
|
"deadline": { "type": "on_event", "event": "Vor Installation" },
|
||||||
|
"sanctions": { "description": "Unterlassungsanspruch, Beweisverwertungsverbot" },
|
||||||
|
"evidence": [{ "name": "Betriebsvereinbarung Videoueberwachung", "required": true }, "Beschilderung"],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": ["TOM.PHY.01"],
|
||||||
|
"breakpilot_feature": null,
|
||||||
|
"valid_from": "2004-06-29",
|
||||||
|
"valid_until": null,
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"controls": [],
|
||||||
|
"incident_deadlines": []
|
||||||
|
}
|
||||||
@@ -4591,6 +4591,209 @@
|
|||||||
"valid_from": "2018-05-25",
|
"valid_from": "2018-05-25",
|
||||||
"valid_until": null,
|
"valid_until": null,
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DSGVO-OBL-081",
|
||||||
|
"title": "Drittlanduebermittlung nur mit geeigneten Garantien",
|
||||||
|
"description": "Die Uebermittlung personenbezogener Daten in Drittlaender (insbesondere USA) ist nur zulaessig, wenn ein Angemessenheitsbeschluss vorliegt oder geeignete Garantien (z.B. Standardvertragsklauseln) implementiert sind. Nach Schrems II (C-311/18) muessen zusaetzliche Massnahmen geprueft werden.",
|
||||||
|
"applies_when": "data transferred to third country or US provider used",
|
||||||
|
"applies_when_condition": {
|
||||||
|
"all_of": [
|
||||||
|
{
|
||||||
|
"field": "data_protection.processes_personal_data",
|
||||||
|
"operator": "EQUALS",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"legal_basis": [
|
||||||
|
{
|
||||||
|
"norm": "DSGVO",
|
||||||
|
"article": "Art. 44",
|
||||||
|
"title": "Allgemeine Grundsaetze der Datenuebermittlung"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm": "DSGVO",
|
||||||
|
"article": "Art. 46",
|
||||||
|
"title": "Datenuebermittlung vorbehaltlich geeigneter Garantien"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "regulation",
|
||||||
|
"ref": "Art. 44-49 DSGVO"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "court_decision",
|
||||||
|
"ref": "EuGH C-311/18 (Schrems II)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "Governance",
|
||||||
|
"responsible": "Datenschutzbeauftragter",
|
||||||
|
"deadline": {
|
||||||
|
"type": "on_event",
|
||||||
|
"event": "Vor Beginn der Datenuebermittlung"
|
||||||
|
},
|
||||||
|
"sanctions": {
|
||||||
|
"max_fine": "20 Mio. EUR oder 4% Jahresumsatz"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"name": "Transfer Impact Assessment (TIA)",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"Standardvertragsklauseln (SCC)",
|
||||||
|
"Dokumentation zusaetzlicher Massnahmen"
|
||||||
|
],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": [
|
||||||
|
"TOM.GOV.01",
|
||||||
|
"TOM.CRY.01"
|
||||||
|
],
|
||||||
|
"valid_from": "2018-05-25",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DSGVO-OBL-082",
|
||||||
|
"title": "Transfer Impact Assessment (TIA) bei US-Anbietern",
|
||||||
|
"description": "Bei Nutzung von US-Cloud-Anbietern (AWS, Azure, Google etc.) muss ein Transfer Impact Assessment durchgefuehrt werden, das FISA 702 und Cloud Act Risiken bewertet und dokumentiert, ob die Standardvertragsklauseln wirksam schuetzen.",
|
||||||
|
"applies_when": "US cloud provider used for personal data",
|
||||||
|
"applies_when_condition": {
|
||||||
|
"all_of": [
|
||||||
|
{
|
||||||
|
"field": "data_protection.processes_personal_data",
|
||||||
|
"operator": "EQUALS",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"legal_basis": [
|
||||||
|
{
|
||||||
|
"norm": "DSGVO",
|
||||||
|
"article": "Art. 46 Abs. 1",
|
||||||
|
"title": "Geeignete Garantien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm": "EuGH",
|
||||||
|
"article": "C-311/18",
|
||||||
|
"title": "Schrems II — Wirksamkeit von SCCs pruefen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "court_decision",
|
||||||
|
"ref": "EuGH C-311/18 (Schrems II)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "guidance",
|
||||||
|
"ref": "EDPB Recommendations 01/2020 Supplementary Measures"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "Governance",
|
||||||
|
"responsible": "Datenschutzbeauftragter / Legal",
|
||||||
|
"deadline": {
|
||||||
|
"type": "on_event",
|
||||||
|
"event": "Vor Vertragsschluss mit US-Anbieter"
|
||||||
|
},
|
||||||
|
"sanctions": {
|
||||||
|
"max_fine": "20 Mio. EUR oder 4% Jahresumsatz"
|
||||||
|
},
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"name": "Transfer Impact Assessment",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"FISA 702 Risikobewertung"
|
||||||
|
],
|
||||||
|
"priority": "kritisch",
|
||||||
|
"tom_control_ids": [
|
||||||
|
"TOM.GOV.01"
|
||||||
|
],
|
||||||
|
"valid_from": "2020-07-16",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DSGVO-OBL-083",
|
||||||
|
"title": "Zusaetzliche technische Massnahmen bei Drittlanduebermittlung",
|
||||||
|
"description": "Wenn Standardvertragsklauseln allein nicht ausreichen (z.B. bei FISA 702 Exposure), muessen zusaetzliche technische Massnahmen implementiert werden: E2EE mit eigener Schluesselhoheit, Pseudonymisierung vor Uebermittlung, oder Verzicht auf den US-Anbieter.",
|
||||||
|
"applies_when": "SCC alone insufficient due to surveillance laws",
|
||||||
|
"applies_when_condition": {
|
||||||
|
"all_of": [
|
||||||
|
{
|
||||||
|
"field": "data_protection.processes_personal_data",
|
||||||
|
"operator": "EQUALS",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"legal_basis": [
|
||||||
|
{
|
||||||
|
"norm": "DSGVO",
|
||||||
|
"article": "Art. 46 Abs. 1",
|
||||||
|
"title": "Zusaetzliche Massnahmen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"norm": "EDPB",
|
||||||
|
"article": "Recommendations 01/2020",
|
||||||
|
"title": "Supplementary Measures"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "guidance",
|
||||||
|
"ref": "EDPB Recommendations 01/2020"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "Technisch",
|
||||||
|
"responsible": "IT-Sicherheit / Datenschutzbeauftragter",
|
||||||
|
"priority": "hoch",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"name": "Nachweis zusaetzlicher Schutzmassnahmen",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"E2EE Dokumentation oder Pseudonymisierungskonzept"
|
||||||
|
],
|
||||||
|
"tom_control_ids": [
|
||||||
|
"TOM.CRY.01",
|
||||||
|
"TOM.GOV.01"
|
||||||
|
],
|
||||||
|
"valid_from": "2020-07-16",
|
||||||
|
"version": "1.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "DSGVO-OBL-084",
|
||||||
|
"title": "Informationspflicht bei Drittlanduebermittlung",
|
||||||
|
"description": "Betroffene Personen muessen darueber informiert werden, dass ihre Daten in ein Drittland uebermittelt werden, einschliesslich der Angabe des Drittlands und der genutzten Garantien (Art. 13 Abs. 1 lit. f DSGVO).",
|
||||||
|
"applies_when": "personal data transferred to third country",
|
||||||
|
"applies_when_condition": {
|
||||||
|
"all_of": [
|
||||||
|
{
|
||||||
|
"field": "data_protection.processes_personal_data",
|
||||||
|
"operator": "EQUALS",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"legal_basis": [
|
||||||
|
{
|
||||||
|
"norm": "DSGVO",
|
||||||
|
"article": "Art. 13 Abs. 1 lit. f",
|
||||||
|
"title": "Informationspflicht bei Drittlanduebermittlung"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "Organisatorisch",
|
||||||
|
"responsible": "Datenschutzbeauftragter",
|
||||||
|
"priority": "hoch",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"name": "Datenschutzerklaerung mit Drittland-Hinweis",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tom_control_ids": [],
|
||||||
|
"valid_from": "2018-05-25",
|
||||||
|
"version": "1.0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"controls": [
|
"controls": [
|
||||||
|
|||||||
5553
ai-compliance-sdk/policies/payment_controls_v1.json
Normal file
5553
ai-compliance-sdk/policies/payment_controls_v1.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -941,6 +941,676 @@ rules:
|
|||||||
gdpr_ref: "Art. 9(2)(h) DSGVO"
|
gdpr_ref: "Art. 9(2)(h) DSGVO"
|
||||||
rationale: "Gesundheitsdaten nur mit besonderen Schutzmaßnahmen"
|
rationale: "Gesundheitsdaten nur mit besonderen Schutzmaßnahmen"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# J. Drittlandtransfer / FISA 702
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
- id: R-FISA-001
|
||||||
|
category: "J. Drittlandrisiko"
|
||||||
|
title: "US-Cloud-Provider: FISA 702 Exposure"
|
||||||
|
description: "Der Hosting-Provider unterliegt US-Recht (FISA 702, Cloud Act). Ein Zugriff durch US-Behoerden auf EU-Daten ist nicht ausschliessbar, unabhaengig vom Serverstandort."
|
||||||
|
condition:
|
||||||
|
field: "hosting.provider"
|
||||||
|
operator: "in"
|
||||||
|
value: ["aws", "azure", "google", "microsoft", "amazon", "openai", "anthropic", "oracle"]
|
||||||
|
effect:
|
||||||
|
risk_add: 20
|
||||||
|
dsfa_recommended: true
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Art. 44-49 DSGVO, Schrems II (C-311/18)"
|
||||||
|
rationale: "FISA 702 erlaubt US-Behoerden Zugriff auf Daten von Nicht-US-Personen ohne richterlichen Beschluss. EU-Serverstandort schuetzt nicht."
|
||||||
|
|
||||||
|
- id: R-FISA-002
|
||||||
|
category: "J. Drittlandrisiko"
|
||||||
|
title: "Personenbezogene Daten bei US-Provider ohne E2EE"
|
||||||
|
description: "Personenbezogene Daten werden bei einem US-Provider verarbeitet ohne dass eine Ende-zu-Ende-Verschluesselung mit kundenseitiger Schluesselhoheit vorliegt."
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "hosting.provider"
|
||||||
|
operator: "in"
|
||||||
|
value: ["aws", "azure", "google", "microsoft", "amazon", "openai", "anthropic", "oracle"]
|
||||||
|
- field: "data_types.personal_data"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 15
|
||||||
|
controls_add: [C_ENCRYPTION]
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Art. 32 DSGVO i.V.m. Art. 44 ff. DSGVO"
|
||||||
|
rationale: "Ohne E2EE mit eigener Schluesselhoheit kann der Provider technisch auf Daten zugreifen und muss sie bei US-Anordnung herausgeben."
|
||||||
|
|
||||||
|
- id: R-FISA-003
|
||||||
|
category: "J. Drittlandrisiko"
|
||||||
|
title: "Besondere Datenkategorien bei US-Provider"
|
||||||
|
description: "Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden bei einem US-Provider verarbeitet."
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "hosting.provider"
|
||||||
|
operator: "in"
|
||||||
|
value: ["aws", "azure", "google", "microsoft", "amazon", "openai", "anthropic", "oracle"]
|
||||||
|
- field: "data_types.article_9_data"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 25
|
||||||
|
feasibility: CONDITIONAL
|
||||||
|
dsfa_recommended: true
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Art. 9 DSGVO i.V.m. Art. 49 DSGVO"
|
||||||
|
rationale: "Besondere Kategorien bei FISA-exponierten Anbietern sind hochriskant. DSFA ist Pflicht."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# K. Domain-spezifische Hochrisiko-Fragen (Annex III)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# HR / Recruiting (Annex III Nr. 4)
|
||||||
|
- id: R-HR-001
|
||||||
|
category: "K. HR Hochrisiko"
|
||||||
|
title: "Automatisches Bewerber-Screening ohne Human Review"
|
||||||
|
description: "KI sortiert Bewerber vor ohne dass ein Mensch jede Empfehlung tatsaechlich prueft"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "hr_context.automated_screening"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "hr_context.human_review_enforced"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect:
|
||||||
|
risk_add: 20
|
||||||
|
feasibility: CONDITIONAL
|
||||||
|
controls_add: [C_HUMAN_OVERSIGHT]
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Art. 22 DSGVO + Annex III Nr. 4 AI Act"
|
||||||
|
rationale: "Ohne echtes Human Review droht Art. 22 DSGVO Verstoss"
|
||||||
|
|
||||||
|
- id: R-HR-002
|
||||||
|
category: "K. HR Hochrisiko"
|
||||||
|
title: "Automatisierte Absagen — Art. 22 DSGVO Risiko"
|
||||||
|
description: "KI generiert und versendet Absagen automatisch ohne menschliche Freigabe"
|
||||||
|
condition:
|
||||||
|
field: "hr_context.automated_rejection"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 25
|
||||||
|
feasibility: NO
|
||||||
|
art22_risk: true
|
||||||
|
severity: BLOCK
|
||||||
|
gdpr_ref: "Art. 22 Abs. 1 DSGVO"
|
||||||
|
rationale: "Vollautomatische Ablehnung = ausschliesslich automatisierte Entscheidung mit rechtlicher Wirkung"
|
||||||
|
|
||||||
|
- id: R-HR-003
|
||||||
|
category: "K. HR Hochrisiko"
|
||||||
|
title: "AGG-relevante Merkmale fuer KI erkennbar"
|
||||||
|
description: "System kann Merkmale nach § 1 AGG erkennen (Name, Foto, Alter → Proxy-Diskriminierung)"
|
||||||
|
condition:
|
||||||
|
field: "hr_context.agg_categories_visible"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 15
|
||||||
|
controls_add: [C_BIAS_AUDIT]
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "§ 1, § 3 Abs. 2 AGG"
|
||||||
|
rationale: "Proxy-Merkmale koennen indirekte Diskriminierung verursachen"
|
||||||
|
|
||||||
|
- id: R-HR-004
|
||||||
|
category: "K. HR Hochrisiko"
|
||||||
|
title: "Bewerber-Ranking ohne Bias-Audit"
|
||||||
|
description: "KI erstellt Bewerber-Rankings ohne regelmaessige Bias-Pruefung"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "hr_context.candidate_ranking"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "hr_context.bias_audits_done"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect:
|
||||||
|
risk_add: 15
|
||||||
|
controls_add: [C_BIAS_AUDIT]
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "§ 22 AGG (Beweislastumkehr)"
|
||||||
|
rationale: "Ohne Bias-Audit keine Verteidigung bei AGG-Klage"
|
||||||
|
|
||||||
|
- id: R-HR-005
|
||||||
|
category: "K. HR Hochrisiko"
|
||||||
|
title: "KI-gestuetzte Mitarbeiterbewertung"
|
||||||
|
description: "KI bewertet Mitarbeiterleistung (Performance Review, KPI-Tracking)"
|
||||||
|
condition:
|
||||||
|
field: "hr_context.performance_evaluation"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 20
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "§ 87 Abs. 1 Nr. 6 BetrVG + § 94 BetrVG"
|
||||||
|
rationale: "Leistungsbewertung durch KI ist mitbestimmungspflichtig und diskriminierungsriskant"
|
||||||
|
|
||||||
|
# Education (Annex III Nr. 3)
|
||||||
|
- id: R-EDU-001
|
||||||
|
category: "K. Bildung Hochrisiko"
|
||||||
|
title: "KI beeinflusst Notenvergabe"
|
||||||
|
description: "KI erstellt Notenvorschlaege oder beeinflusst Bewertungen"
|
||||||
|
condition:
|
||||||
|
field: "education_context.grade_influence"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 20
|
||||||
|
controls_add: [C_HUMAN_OVERSIGHT]
|
||||||
|
dsfa_recommended: true
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Annex III Nr. 3 AI Act"
|
||||||
|
rationale: "Notenvergabe hat erhebliche Auswirkungen auf Bildungschancen"
|
||||||
|
|
||||||
|
- id: R-EDU-002
|
||||||
|
category: "K. Bildung Hochrisiko"
|
||||||
|
title: "Minderjaehrige betroffen ohne Lehrkraft-Review"
|
||||||
|
description: "KI-System betrifft Minderjaehrige und Lehrkraft prueft nicht jedes Ergebnis"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "education_context.minors_involved"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "education_context.teacher_review_required"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect:
|
||||||
|
risk_add: 25
|
||||||
|
feasibility: NO
|
||||||
|
severity: BLOCK
|
||||||
|
gdpr_ref: "Art. 24 EU-Grundrechtecharta + Annex III Nr. 3 AI Act"
|
||||||
|
rationale: "KI-Entscheidungen ueber Minderjaehrige ohne Lehrkraft-Kontrolle sind unzulaessig"
|
||||||
|
|
||||||
|
- id: R-EDU-003
|
||||||
|
category: "K. Bildung Hochrisiko"
|
||||||
|
title: "KI steuert Zugang zu Bildungsangeboten"
|
||||||
|
description: "KI beeinflusst Zulassung, Kursempfehlungen oder Einstufungen"
|
||||||
|
condition:
|
||||||
|
field: "education_context.student_selection"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 20
|
||||||
|
dsfa_recommended: true
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Art. 14 EU-Grundrechtecharta (Recht auf Bildung)"
|
||||||
|
rationale: "Zugangssteuerung zu Bildung ist hochrisiko nach AI Act"
|
||||||
|
|
||||||
|
# Healthcare (Annex III Nr. 5)
|
||||||
|
- id: R-HC-001
|
||||||
|
category: "K. Gesundheit Hochrisiko"
|
||||||
|
title: "KI unterstuetzt Diagnosen"
|
||||||
|
description: "KI erstellt Diagnosevorschlaege oder wertet Bildgebung aus"
|
||||||
|
condition:
|
||||||
|
field: "healthcare_context.diagnosis_support"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 20
|
||||||
|
dsfa_recommended: true
|
||||||
|
controls_add: [C_HUMAN_OVERSIGHT]
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Annex III Nr. 5 AI Act + MDR (EU) 2017/745"
|
||||||
|
rationale: "Diagnoseunterstuetzung erfordert hoechste Genauigkeit und Human Oversight"
|
||||||
|
|
||||||
|
- id: R-HC-002
|
||||||
|
category: "K. Gesundheit Hochrisiko"
|
||||||
|
title: "Triage-Entscheidung durch KI"
|
||||||
|
description: "KI priorisiert Patienten nach Dringlichkeit"
|
||||||
|
condition:
|
||||||
|
field: "healthcare_context.triage_decision"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 30
|
||||||
|
feasibility: CONDITIONAL
|
||||||
|
controls_add: [C_HUMAN_OVERSIGHT]
|
||||||
|
dsfa_recommended: true
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Annex III Nr. 5 AI Act"
|
||||||
|
rationale: "Lebenskritische Priorisierung erfordert maximale Sicherheit"
|
||||||
|
|
||||||
|
- id: R-HC-003
|
||||||
|
category: "K. Gesundheit Hochrisiko"
|
||||||
|
title: "Medizinprodukt ohne klinische Validierung"
|
||||||
|
description: "System ist als Medizinprodukt eingestuft aber nicht klinisch validiert"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "healthcare_context.medical_device"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "healthcare_context.clinical_validation"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect:
|
||||||
|
risk_add: 30
|
||||||
|
feasibility: NO
|
||||||
|
severity: BLOCK
|
||||||
|
gdpr_ref: "MDR (EU) 2017/745 Art. 61"
|
||||||
|
rationale: "Medizinprodukte ohne klinische Validierung duerfen nicht in Verkehr gebracht werden"
|
||||||
|
|
||||||
|
- id: R-HC-004
|
||||||
|
category: "K. Gesundheit Hochrisiko"
|
||||||
|
title: "Gesundheitsdaten ohne besondere Schutzmassnahmen"
|
||||||
|
description: "Gesundheitsdaten (Art. 9 DSGVO) werden verarbeitet"
|
||||||
|
condition:
|
||||||
|
field: "healthcare_context.patient_data_processed"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect:
|
||||||
|
risk_add: 15
|
||||||
|
dsfa_recommended: true
|
||||||
|
controls_add: [C_DSFA]
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Art. 9 DSGVO"
|
||||||
|
rationale: "Gesundheitsdaten sind besondere Kategorien mit erhoehtem Schutzbedarf"
|
||||||
|
|
||||||
|
# Legal / Justice (Annex III Nr. 8)
|
||||||
|
- id: R-LEG-001
|
||||||
|
category: "K. Legal Hochrisiko"
|
||||||
|
title: "KI gibt Rechtsberatung"
|
||||||
|
description: "KI generiert rechtliche Empfehlungen oder Einschaetzungen"
|
||||||
|
condition: { field: "legal_context.legal_advice", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, controls_add: [C_HUMAN_OVERSIGHT] }
|
||||||
|
severity: WARN
|
||||||
|
gdpr_ref: "Annex III Nr. 8 AI Act"
|
||||||
|
rationale: "Rechtsberatung durch KI kann Zugang zur Justiz beeintraechtigen"
|
||||||
|
|
||||||
|
- id: R-LEG-002
|
||||||
|
category: "K. Legal Hochrisiko"
|
||||||
|
title: "KI prognostiziert Gerichtsurteile"
|
||||||
|
description: "System erstellt Prognosen ueber Verfahrensausgaenge"
|
||||||
|
condition: { field: "legal_context.court_prediction", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Urteilsprognosen koennen rechtliches Verhalten verzerren"
|
||||||
|
|
||||||
|
- id: R-LEG-003
|
||||||
|
category: "K. Legal Hochrisiko"
|
||||||
|
title: "Mandantengeheimnis bei KI-Verarbeitung"
|
||||||
|
description: "Vertrauliche Mandantendaten werden durch KI verarbeitet"
|
||||||
|
condition: { field: "legal_context.client_confidential", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, controls_add: [C_ENCRYPTION] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Mandantengeheimnis erfordert besonderen Schutz (§ 203 StGB)"
|
||||||
|
|
||||||
|
# Public Sector (Art. 27 FRIA)
|
||||||
|
- id: R-PUB-001
|
||||||
|
category: "K. Oeffentlicher Sektor"
|
||||||
|
title: "KI in Verwaltungsentscheidungen"
|
||||||
|
description: "KI beeinflusst Verwaltungsakte oder Bescheide"
|
||||||
|
condition: { field: "public_sector_context.admin_decision", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 25, dsfa_recommended: true, controls_add: [C_FRIA, C_HUMAN_OVERSIGHT] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Verwaltungsentscheidungen erfordern FRIA (Art. 27 AI Act)"
|
||||||
|
|
||||||
|
- id: R-PUB-002
|
||||||
|
category: "K. Oeffentlicher Sektor"
|
||||||
|
title: "KI verteilt oeffentliche Leistungen"
|
||||||
|
description: "KI entscheidet ueber Zuteilung von Sozialleistungen oder Foerderung"
|
||||||
|
condition: { field: "public_sector_context.benefit_allocation", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 25, feasibility: CONDITIONAL }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Leistungszuteilung betrifft Grundrecht auf soziale Sicherheit"
|
||||||
|
|
||||||
|
- id: R-PUB-003
|
||||||
|
category: "K. Oeffentlicher Sektor"
|
||||||
|
title: "Fehlende Transparenz gegenueber Buergern"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "public_sector_context.citizen_service"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "public_sector_context.transparency_ensured"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect: { risk_add: 15, controls_add: [C_TRANSPARENCY] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Oeffentliche Stellen haben erhoehte Transparenzpflicht"
|
||||||
|
|
||||||
|
# Critical Infrastructure (NIS2 + Annex III Nr. 2)
|
||||||
|
- id: R-CRIT-001
|
||||||
|
category: "K. Kritische Infrastruktur"
|
||||||
|
title: "Sicherheitskritische KI-Steuerung ohne Redundanz"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "critical_infra_context.safety_critical"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "critical_infra_context.redundancy_exists"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect: { risk_add: 30, feasibility: NO }
|
||||||
|
severity: BLOCK
|
||||||
|
rationale: "Sicherheitskritische Steuerung ohne Redundanz ist unzulaessig"
|
||||||
|
|
||||||
|
- id: R-CRIT-002
|
||||||
|
category: "K. Kritische Infrastruktur"
|
||||||
|
title: "KI steuert Netz-/Infrastruktur"
|
||||||
|
condition: { field: "critical_infra_context.grid_control", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, controls_add: [C_INCIDENT_RESPONSE, C_HUMAN_OVERSIGHT] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Netzsteuerung durch KI erfordert NIS2-konforme Absicherung"
|
||||||
|
|
||||||
|
# Automotive / Aerospace
|
||||||
|
- id: R-AUTO-001
|
||||||
|
category: "K. Automotive Hochrisiko"
|
||||||
|
title: "Autonomes Fahren / ADAS"
|
||||||
|
condition: { field: "automotive_context.autonomous_driving", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 30, controls_add: [C_HUMAN_OVERSIGHT, C_FRIA] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Autonomes Fahren ist sicherheitskritisch und hochreguliert"
|
||||||
|
|
||||||
|
- id: R-AUTO-002
|
||||||
|
category: "K. Automotive Hochrisiko"
|
||||||
|
title: "Sicherheitsrelevant ohne Functional Safety"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "automotive_context.safety_relevant"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "automotive_context.functional_safety"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect: { risk_add: 25, feasibility: CONDITIONAL }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Sicherheitsrelevante Systeme erfordern ISO 26262 Konformitaet"
|
||||||
|
|
||||||
|
# Retail / E-Commerce
|
||||||
|
- id: R-RET-001
|
||||||
|
category: "K. Retail"
|
||||||
|
title: "Personalisierte Preise durch KI"
|
||||||
|
condition: { field: "retail_context.pricing_personalized", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, controls_add: [C_TRANSPARENCY] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Personalisierte Preise koennen Verbraucher benachteiligen (DSA Art. 25)"
|
||||||
|
|
||||||
|
- id: R-RET-002
|
||||||
|
category: "K. Retail"
|
||||||
|
title: "Bonitaetspruefung bei Kauf"
|
||||||
|
condition: { field: "retail_context.credit_scoring", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, dsfa_recommended: true, art22_risk: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Kredit-Scoring ist Annex III Nr. 5 AI Act (Zugang zu Diensten)"
|
||||||
|
|
||||||
|
- id: R-RET-003
|
||||||
|
category: "K. Retail"
|
||||||
|
title: "Dark Patterns moeglich"
|
||||||
|
condition: { field: "retail_context.dark_patterns", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Manipulative UI-Muster verstossen gegen DSA und Verbraucherrecht"
|
||||||
|
|
||||||
|
# IT / Cybersecurity / Telecom
|
||||||
|
- id: R-ITS-001
|
||||||
|
category: "K. IT-Sicherheit"
|
||||||
|
title: "KI-gestuetzte Mitarbeiterueberwachung"
|
||||||
|
condition: { field: "it_security_context.employee_surveillance", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Mitarbeiterueberwachung ist §87 BetrVG + DSGVO relevant"
|
||||||
|
|
||||||
|
- id: R-ITS-002
|
||||||
|
category: "K. IT-Sicherheit"
|
||||||
|
title: "Umfangreiche Log-Speicherung"
|
||||||
|
condition: { field: "it_security_context.data_retention_logs", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 10, controls_add: [C_DATA_MINIMIZATION] }
|
||||||
|
severity: INFO
|
||||||
|
rationale: "Datenminimierung beachten auch bei Security-Logs"
|
||||||
|
|
||||||
|
# Logistics
|
||||||
|
- id: R-LOG-001
|
||||||
|
category: "K. Logistik"
|
||||||
|
title: "Fahrer-/Kurier-Tracking"
|
||||||
|
condition: { field: "logistics_context.driver_tracking", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "GPS-Tracking ist Verhaltenskontrolle (§87 BetrVG)"
|
||||||
|
|
||||||
|
- id: R-LOG-002
|
||||||
|
category: "K. Logistik"
|
||||||
|
title: "Leistungsbewertung Lagerarbeiter"
|
||||||
|
condition: { field: "logistics_context.workload_scoring", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, art22_risk: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Leistungs-Scoring ist Annex III Nr. 4 (Employment)"
|
||||||
|
|
||||||
|
# Construction / Real Estate
|
||||||
|
- id: R-CON-001
|
||||||
|
category: "K. Bau/Immobilien"
|
||||||
|
title: "KI-gestuetzte Mieterauswahl"
|
||||||
|
condition: { field: "construction_context.tenant_screening", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Mieterauswahl betrifft Zugang zu Wohnraum (Grundrecht)"
|
||||||
|
|
||||||
|
- id: R-CON-002
|
||||||
|
category: "K. Bau/Immobilien"
|
||||||
|
title: "KI-Arbeitsschutzueberwachung"
|
||||||
|
condition: { field: "construction_context.worker_safety", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Arbeitsschutzueberwachung kann Verhaltenskontrolle sein"
|
||||||
|
|
||||||
|
# Marketing / Media
|
||||||
|
- id: R-MKT-001
|
||||||
|
category: "K. Marketing/Medien"
|
||||||
|
title: "Deepfake-Inhalte ohne Kennzeichnung"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "marketing_context.deepfake_content"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "marketing_context.ai_content_labeled"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect: { risk_add: 20, feasibility: NO }
|
||||||
|
severity: BLOCK
|
||||||
|
rationale: "Art. 50 Abs. 4 AI Act: Deepfakes muessen gekennzeichnet werden"
|
||||||
|
|
||||||
|
- id: R-MKT-002
|
||||||
|
category: "K. Marketing/Medien"
|
||||||
|
title: "Minderjaehrige als Zielgruppe"
|
||||||
|
condition: { field: "marketing_context.minors_targeted", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, controls_add: [C_DSFA] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Besonderer Schutz Minderjaehriger (DSA + DSGVO)"
|
||||||
|
|
||||||
|
- id: R-MKT-003
|
||||||
|
category: "K. Marketing/Medien"
|
||||||
|
title: "Verhaltensbasiertes Targeting"
|
||||||
|
condition: { field: "marketing_context.behavioral_targeting", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Behavioral Targeting ist Profiling (Art. 22 DSGVO)"
|
||||||
|
|
||||||
|
# Manufacturing / CE
|
||||||
|
- id: R-MFG-001
|
||||||
|
category: "K. Fertigung"
|
||||||
|
title: "KI in Maschinensicherheit ohne Validierung"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "manufacturing_context.machine_safety"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "manufacturing_context.safety_validated"
|
||||||
|
operator: "equals"
|
||||||
|
value: false
|
||||||
|
effect: { risk_add: 30, feasibility: NO }
|
||||||
|
severity: BLOCK
|
||||||
|
rationale: "Maschinenverordnung (EU) 2023/1230 erfordert Sicherheitsvalidierung"
|
||||||
|
|
||||||
|
- id: R-MFG-002
|
||||||
|
category: "K. Fertigung"
|
||||||
|
title: "CE-Kennzeichnung erforderlich"
|
||||||
|
condition: { field: "manufacturing_context.ce_marking_required", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, controls_add: [C_CE_CONFORMITY] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "CE-Kennzeichnung ist Pflicht fuer Maschinenprodukte mit KI"
|
||||||
|
|
||||||
|
# Agriculture
|
||||||
|
- id: R-AGR-001
|
||||||
|
category: "K. Landwirtschaft"
|
||||||
|
title: "KI steuert Pestizideinsatz"
|
||||||
|
condition: { field: "agriculture_context.pesticide_ai", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Umwelt- und Gesundheitsrisiken bei KI-gesteuertem Pflanzenschutz"
|
||||||
|
|
||||||
|
- id: R-AGR-002
|
||||||
|
category: "K. Landwirtschaft"
|
||||||
|
title: "KI beeinflusst Tierhaltung"
|
||||||
|
condition: { field: "agriculture_context.animal_welfare", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 10 }
|
||||||
|
severity: INFO
|
||||||
|
rationale: "Tierschutzrelevanz bei automatisierter Haltungsentscheidung"
|
||||||
|
|
||||||
|
# Social Services
|
||||||
|
- id: R-SOC-001
|
||||||
|
category: "K. Soziales"
|
||||||
|
title: "KI trifft Leistungsentscheidungen fuer schutzbeduerftiger Gruppen"
|
||||||
|
condition:
|
||||||
|
all_of:
|
||||||
|
- field: "social_services_context.vulnerable_groups"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
- field: "social_services_context.benefit_decision"
|
||||||
|
operator: "equals"
|
||||||
|
value: true
|
||||||
|
effect: { risk_add: 25, dsfa_recommended: true, controls_add: [C_FRIA, C_HUMAN_OVERSIGHT] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Leistungsentscheidungen fuer Schutzbeduerftiger erfordern FRIA"
|
||||||
|
|
||||||
|
- id: R-SOC-002
|
||||||
|
category: "K. Soziales"
|
||||||
|
title: "KI in Fallmanagement"
|
||||||
|
condition: { field: "social_services_context.case_management", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Fallmanagement betrifft Grundrechte der Betroffenen"
|
||||||
|
|
||||||
|
# Hospitality / Tourism
|
||||||
|
- id: R-HOS-001
|
||||||
|
category: "K. Tourismus"
|
||||||
|
title: "Dynamische Preisgestaltung"
|
||||||
|
condition: { field: "hospitality_context.dynamic_pricing", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 10, controls_add: [C_TRANSPARENCY] }
|
||||||
|
severity: INFO
|
||||||
|
rationale: "Personalisierte Preise erfordern Transparenz"
|
||||||
|
|
||||||
|
- id: R-HOS-002
|
||||||
|
category: "K. Tourismus"
|
||||||
|
title: "KI manipuliert Bewertungen"
|
||||||
|
condition: { field: "hospitality_context.review_manipulation", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, feasibility: NO }
|
||||||
|
severity: BLOCK
|
||||||
|
rationale: "Bewertungsmanipulation verstoesst gegen UWG und DSA"
|
||||||
|
|
||||||
|
# Insurance
|
||||||
|
- id: R-INS-001
|
||||||
|
category: "K. Versicherung"
|
||||||
|
title: "KI-gestuetzte Praemienberechnung"
|
||||||
|
condition: { field: "insurance_context.premium_calculation", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Individuelle Praemien koennen diskriminierend wirken (AGG, Annex III Nr. 5)"
|
||||||
|
|
||||||
|
- id: R-INS-002
|
||||||
|
category: "K. Versicherung"
|
||||||
|
title: "Automatisierte Schadenbearbeitung"
|
||||||
|
condition: { field: "insurance_context.claims_automation", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, art22_risk: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Automatische Schadensablehnung kann Art. 22 DSGVO ausloesen"
|
||||||
|
|
||||||
|
# Investment
|
||||||
|
- id: R-INV-001
|
||||||
|
category: "K. Investment"
|
||||||
|
title: "Algorithmischer Handel"
|
||||||
|
condition: { field: "investment_context.algo_trading", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "MiFID II Anforderungen an algorithmischen Handel"
|
||||||
|
|
||||||
|
- id: R-INV-002
|
||||||
|
category: "K. Investment"
|
||||||
|
title: "KI-gestuetzte Anlageberatung (Robo Advisor)"
|
||||||
|
condition: { field: "investment_context.robo_advisor", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, controls_add: [C_HUMAN_OVERSIGHT, C_TRANSPARENCY] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Anlageberatung ist reguliert (WpHG, MiFID II) — Haftungsrisiko"
|
||||||
|
|
||||||
|
# Defense
|
||||||
|
- id: R-DEF-001
|
||||||
|
category: "K. Verteidigung"
|
||||||
|
title: "Dual-Use KI-Technologie"
|
||||||
|
condition: { field: "defense_context.dual_use", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 25 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Dual-Use Technologie unterliegt Exportkontrolle (EU VO 2021/821)"
|
||||||
|
|
||||||
|
- id: R-DEF-002
|
||||||
|
category: "K. Verteidigung"
|
||||||
|
title: "Verschlusssachen in KI verarbeitet"
|
||||||
|
condition: { field: "defense_context.classified_data", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 20, controls_add: [C_ENCRYPTION] }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "VS-NfD und hoeher erfordert besondere Schutzmassnahmen"
|
||||||
|
|
||||||
|
# Supply Chain (LkSG)
|
||||||
|
- id: R-SCH-001
|
||||||
|
category: "K. Lieferkette"
|
||||||
|
title: "KI-Menschenrechtspruefung in Lieferkette"
|
||||||
|
condition: { field: "supply_chain_context.human_rights_check", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 10 }
|
||||||
|
severity: INFO
|
||||||
|
rationale: "LkSG-relevante KI-Analyse — Bias bei Laenderrisiko-Bewertung beachten"
|
||||||
|
|
||||||
|
- id: R-SCH-002
|
||||||
|
category: "K. Lieferkette"
|
||||||
|
title: "KI ueberwacht Lieferanten"
|
||||||
|
condition: { field: "supply_chain_context.supplier_monitoring", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 10 }
|
||||||
|
severity: INFO
|
||||||
|
rationale: "Lieferantenbewertung durch KI kann indirekt Personen betreffen"
|
||||||
|
|
||||||
|
# Facility Management
|
||||||
|
- id: R-FAC-001
|
||||||
|
category: "K. Facility"
|
||||||
|
title: "KI-Zutrittskontrolle"
|
||||||
|
condition: { field: "facility_context.access_control_ai", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Biometrische oder verhaltensbasierte Zutrittskontrolle ist DSGVO-relevant"
|
||||||
|
|
||||||
|
- id: R-FAC-002
|
||||||
|
category: "K. Facility"
|
||||||
|
title: "Belegungsueberwachung"
|
||||||
|
condition: { field: "facility_context.occupancy_tracking", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 10 }
|
||||||
|
severity: INFO
|
||||||
|
rationale: "Belegungsdaten koennen Rueckschluesse auf Verhalten erlauben"
|
||||||
|
|
||||||
|
# Sports
|
||||||
|
- id: R-SPO-001
|
||||||
|
category: "K. Sport"
|
||||||
|
title: "Athleten-Performance-Tracking"
|
||||||
|
condition: { field: "sports_context.athlete_tracking", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15 }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Leistungsdaten von Athleten sind besonders schuetzenswert"
|
||||||
|
|
||||||
|
- id: R-SPO-002
|
||||||
|
category: "K. Sport"
|
||||||
|
title: "Fan-/Zuschauer-Profilbildung"
|
||||||
|
condition: { field: "sports_context.fan_profiling", operator: "equals", value: true }
|
||||||
|
effect: { risk_add: 15, dsfa_recommended: true }
|
||||||
|
severity: WARN
|
||||||
|
rationale: "Massen-Profiling bei Sportevents erfordert DSFA"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# G. Aggregation & Ergebnis
|
# G. Aggregation & Ergebnis
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class ControlResponse(BaseModel):
|
|||||||
customer_visible: Optional[bool] = None
|
customer_visible: Optional[bool] = None
|
||||||
verification_method: Optional[str] = None
|
verification_method: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
evidence_type: Optional[str] = None
|
||||||
target_audience: Optional[str] = None
|
target_audience: Optional[str] = None
|
||||||
generation_metadata: Optional[dict] = None
|
generation_metadata: Optional[dict] = None
|
||||||
generation_strategy: Optional[str] = "ungrouped"
|
generation_strategy: Optional[str] = "ungrouped"
|
||||||
@@ -113,6 +114,7 @@ class ControlCreateRequest(BaseModel):
|
|||||||
customer_visible: Optional[bool] = True
|
customer_visible: Optional[bool] = True
|
||||||
verification_method: Optional[str] = None
|
verification_method: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
evidence_type: Optional[str] = None
|
||||||
target_audience: Optional[str] = None
|
target_audience: Optional[str] = None
|
||||||
generation_metadata: Optional[dict] = None
|
generation_metadata: Optional[dict] = None
|
||||||
applicable_industries: Optional[list] = None
|
applicable_industries: Optional[list] = None
|
||||||
@@ -141,6 +143,7 @@ class ControlUpdateRequest(BaseModel):
|
|||||||
customer_visible: Optional[bool] = None
|
customer_visible: Optional[bool] = None
|
||||||
verification_method: Optional[str] = None
|
verification_method: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
evidence_type: Optional[str] = None
|
||||||
target_audience: Optional[str] = None
|
target_audience: Optional[str] = None
|
||||||
generation_metadata: Optional[dict] = None
|
generation_metadata: Optional[dict] = None
|
||||||
applicable_industries: Optional[list] = None
|
applicable_industries: Optional[list] = None
|
||||||
@@ -172,7 +175,7 @@ _CONTROL_COLS = """id, framework_id, control_id, title, objective, rationale,
|
|||||||
severity, risk_score, implementation_effort,
|
severity, risk_score, implementation_effort,
|
||||||
evidence_confidence, open_anchors, release_state, tags,
|
evidence_confidence, open_anchors, release_state, tags,
|
||||||
license_rule, source_original_text, source_citation,
|
license_rule, source_original_text, source_citation,
|
||||||
customer_visible, verification_method, category,
|
customer_visible, verification_method, category, evidence_type,
|
||||||
target_audience, generation_metadata, generation_strategy,
|
target_audience, generation_metadata, generation_strategy,
|
||||||
applicable_industries, applicable_company_size, scope_conditions,
|
applicable_industries, applicable_company_size, scope_conditions,
|
||||||
parent_control_uuid, decomposition_method, pipeline_version,
|
parent_control_uuid, decomposition_method, pipeline_version,
|
||||||
@@ -312,10 +315,12 @@ async def list_controls(
|
|||||||
release_state: Optional[str] = Query(None),
|
release_state: Optional[str] = Query(None),
|
||||||
verification_method: Optional[str] = Query(None),
|
verification_method: Optional[str] = Query(None),
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
|
evidence_type: Optional[str] = Query(None, description="Filter: code, process, hybrid"),
|
||||||
target_audience: Optional[str] = Query(None),
|
target_audience: Optional[str] = Query(None),
|
||||||
source: Optional[str] = Query(None, description="Filter by source_citation->source"),
|
source: Optional[str] = Query(None, description="Filter by source_citation->source"),
|
||||||
search: Optional[str] = Query(None, description="Full-text search in control_id, title, objective"),
|
search: Optional[str] = Query(None, description="Full-text search in control_id, title, objective"),
|
||||||
control_type: Optional[str] = Query(None, description="Filter: atomic, rich, or all"),
|
control_type: Optional[str] = Query(None, description="Filter: atomic, rich, or all"),
|
||||||
|
exclude_duplicates: bool = Query(False, description="Exclude controls with release_state='duplicate'"),
|
||||||
sort: Optional[str] = Query("control_id", description="Sort field: control_id, created_at, severity"),
|
sort: Optional[str] = Query("control_id", description="Sort field: control_id, created_at, severity"),
|
||||||
order: Optional[str] = Query("asc", description="Sort order: asc or desc"),
|
order: Optional[str] = Query("asc", description="Sort order: asc or desc"),
|
||||||
limit: Optional[int] = Query(None, ge=1, le=5000, description="Max results"),
|
limit: Optional[int] = Query(None, ge=1, le=5000, description="Max results"),
|
||||||
@@ -329,6 +334,9 @@ async def list_controls(
|
|||||||
"""
|
"""
|
||||||
params: dict[str, Any] = {}
|
params: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if exclude_duplicates:
|
||||||
|
query += " AND release_state != 'duplicate'"
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
query += " AND severity = :sev"
|
query += " AND severity = :sev"
|
||||||
params["sev"] = severity
|
params["sev"] = severity
|
||||||
@@ -339,11 +347,23 @@ async def list_controls(
|
|||||||
query += " AND release_state = :rs"
|
query += " AND release_state = :rs"
|
||||||
params["rs"] = release_state
|
params["rs"] = release_state
|
||||||
if verification_method:
|
if verification_method:
|
||||||
query += " AND verification_method = :vm"
|
if verification_method == "__none__":
|
||||||
params["vm"] = verification_method
|
query += " AND verification_method IS NULL"
|
||||||
|
else:
|
||||||
|
query += " AND verification_method = :vm"
|
||||||
|
params["vm"] = verification_method
|
||||||
if category:
|
if category:
|
||||||
query += " AND category = :cat"
|
if category == "__none__":
|
||||||
params["cat"] = category
|
query += " AND category IS NULL"
|
||||||
|
else:
|
||||||
|
query += " AND category = :cat"
|
||||||
|
params["cat"] = category
|
||||||
|
if evidence_type:
|
||||||
|
if evidence_type == "__none__":
|
||||||
|
query += " AND evidence_type IS NULL"
|
||||||
|
else:
|
||||||
|
query += " AND evidence_type = :et"
|
||||||
|
params["et"] = evidence_type
|
||||||
if target_audience:
|
if target_audience:
|
||||||
query += " AND target_audience LIKE :ta_pattern"
|
query += " AND target_audience LIKE :ta_pattern"
|
||||||
params["ta_pattern"] = f'%"{target_audience}"%'
|
params["ta_pattern"] = f'%"{target_audience}"%'
|
||||||
@@ -357,6 +377,11 @@ async def list_controls(
|
|||||||
query += " AND decomposition_method = 'pass0b'"
|
query += " AND decomposition_method = 'pass0b'"
|
||||||
elif control_type == "rich":
|
elif control_type == "rich":
|
||||||
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
|
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
|
||||||
|
elif control_type == "eigenentwicklung":
|
||||||
|
query += """ AND generation_strategy = 'ungrouped'
|
||||||
|
AND (pipeline_version = '1' OR pipeline_version IS NULL)
|
||||||
|
AND source_citation IS NULL
|
||||||
|
AND parent_control_uuid IS NULL"""
|
||||||
if search:
|
if search:
|
||||||
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
|
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
|
||||||
params["q"] = f"%{search}%"
|
params["q"] = f"%{search}%"
|
||||||
@@ -394,15 +419,20 @@ async def count_controls(
|
|||||||
release_state: Optional[str] = Query(None),
|
release_state: Optional[str] = Query(None),
|
||||||
verification_method: Optional[str] = Query(None),
|
verification_method: Optional[str] = Query(None),
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
|
evidence_type: Optional[str] = Query(None),
|
||||||
target_audience: Optional[str] = Query(None),
|
target_audience: Optional[str] = Query(None),
|
||||||
source: Optional[str] = Query(None),
|
source: Optional[str] = Query(None),
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
control_type: Optional[str] = Query(None),
|
control_type: Optional[str] = Query(None),
|
||||||
|
exclude_duplicates: bool = Query(False, description="Exclude controls with release_state='duplicate'"),
|
||||||
):
|
):
|
||||||
"""Count controls matching filters (for pagination)."""
|
"""Count controls matching filters (for pagination)."""
|
||||||
query = "SELECT count(*) FROM canonical_controls WHERE 1=1"
|
query = "SELECT count(*) FROM canonical_controls WHERE 1=1"
|
||||||
params: dict[str, Any] = {}
|
params: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if exclude_duplicates:
|
||||||
|
query += " AND release_state != 'duplicate'"
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
query += " AND severity = :sev"
|
query += " AND severity = :sev"
|
||||||
params["sev"] = severity
|
params["sev"] = severity
|
||||||
@@ -413,11 +443,23 @@ async def count_controls(
|
|||||||
query += " AND release_state = :rs"
|
query += " AND release_state = :rs"
|
||||||
params["rs"] = release_state
|
params["rs"] = release_state
|
||||||
if verification_method:
|
if verification_method:
|
||||||
query += " AND verification_method = :vm"
|
if verification_method == "__none__":
|
||||||
params["vm"] = verification_method
|
query += " AND verification_method IS NULL"
|
||||||
|
else:
|
||||||
|
query += " AND verification_method = :vm"
|
||||||
|
params["vm"] = verification_method
|
||||||
if category:
|
if category:
|
||||||
query += " AND category = :cat"
|
if category == "__none__":
|
||||||
params["cat"] = category
|
query += " AND category IS NULL"
|
||||||
|
else:
|
||||||
|
query += " AND category = :cat"
|
||||||
|
params["cat"] = category
|
||||||
|
if evidence_type:
|
||||||
|
if evidence_type == "__none__":
|
||||||
|
query += " AND evidence_type IS NULL"
|
||||||
|
else:
|
||||||
|
query += " AND evidence_type = :et"
|
||||||
|
params["et"] = evidence_type
|
||||||
if target_audience:
|
if target_audience:
|
||||||
query += " AND target_audience LIKE :ta_pattern"
|
query += " AND target_audience LIKE :ta_pattern"
|
||||||
params["ta_pattern"] = f'%"{target_audience}"%'
|
params["ta_pattern"] = f'%"{target_audience}"%'
|
||||||
@@ -431,6 +473,11 @@ async def count_controls(
|
|||||||
query += " AND decomposition_method = 'pass0b'"
|
query += " AND decomposition_method = 'pass0b'"
|
||||||
elif control_type == "rich":
|
elif control_type == "rich":
|
||||||
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
|
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
|
||||||
|
elif control_type == "eigenentwicklung":
|
||||||
|
query += """ AND generation_strategy = 'ungrouped'
|
||||||
|
AND (pipeline_version = '1' OR pipeline_version IS NULL)
|
||||||
|
AND source_citation IS NULL
|
||||||
|
AND parent_control_uuid IS NULL"""
|
||||||
if search:
|
if search:
|
||||||
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
|
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
|
||||||
params["q"] = f"%{search}%"
|
params["q"] = f"%{search}%"
|
||||||
@@ -442,34 +489,189 @@ async def count_controls(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/controls-meta")
|
@router.get("/controls-meta")
|
||||||
async def controls_meta():
|
async def controls_meta(
|
||||||
"""Return aggregated metadata for filter dropdowns (domains, sources, counts)."""
|
severity: Optional[str] = Query(None),
|
||||||
|
domain: Optional[str] = Query(None),
|
||||||
|
release_state: Optional[str] = Query(None),
|
||||||
|
verification_method: Optional[str] = Query(None),
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
evidence_type: Optional[str] = Query(None),
|
||||||
|
target_audience: Optional[str] = Query(None),
|
||||||
|
source: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
control_type: Optional[str] = Query(None),
|
||||||
|
exclude_duplicates: bool = Query(False),
|
||||||
|
):
|
||||||
|
"""Return faceted metadata for filter dropdowns.
|
||||||
|
|
||||||
|
Each facet's counts respect ALL active filters EXCEPT the facet's own,
|
||||||
|
so dropdowns always show how many items each option would yield.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_where(skip: Optional[str] = None) -> tuple[str, dict[str, Any]]:
|
||||||
|
clauses = ["1=1"]
|
||||||
|
p: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if exclude_duplicates:
|
||||||
|
clauses.append("release_state != 'duplicate'")
|
||||||
|
if severity and skip != "severity":
|
||||||
|
clauses.append("severity = :sev")
|
||||||
|
p["sev"] = severity
|
||||||
|
if domain and skip != "domain":
|
||||||
|
clauses.append("LEFT(control_id, LENGTH(:dom)) = :dom")
|
||||||
|
p["dom"] = domain.upper()
|
||||||
|
if release_state and skip != "release_state":
|
||||||
|
clauses.append("release_state = :rs")
|
||||||
|
p["rs"] = release_state
|
||||||
|
if verification_method and skip != "verification_method":
|
||||||
|
if verification_method == "__none__":
|
||||||
|
clauses.append("verification_method IS NULL")
|
||||||
|
else:
|
||||||
|
clauses.append("verification_method = :vm")
|
||||||
|
p["vm"] = verification_method
|
||||||
|
if category and skip != "category":
|
||||||
|
if category == "__none__":
|
||||||
|
clauses.append("category IS NULL")
|
||||||
|
else:
|
||||||
|
clauses.append("category = :cat")
|
||||||
|
p["cat"] = category
|
||||||
|
if evidence_type and skip != "evidence_type":
|
||||||
|
if evidence_type == "__none__":
|
||||||
|
clauses.append("evidence_type IS NULL")
|
||||||
|
else:
|
||||||
|
clauses.append("evidence_type = :et")
|
||||||
|
p["et"] = evidence_type
|
||||||
|
if target_audience and skip != "target_audience":
|
||||||
|
clauses.append("target_audience LIKE :ta_pattern")
|
||||||
|
p["ta_pattern"] = f'%"{target_audience}"%'
|
||||||
|
if source and skip != "source":
|
||||||
|
if source == "__none__":
|
||||||
|
clauses.append("(source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')")
|
||||||
|
else:
|
||||||
|
clauses.append("source_citation->>'source' = :src")
|
||||||
|
p["src"] = source
|
||||||
|
if control_type and skip != "control_type":
|
||||||
|
if control_type == "atomic":
|
||||||
|
clauses.append("decomposition_method = 'pass0b'")
|
||||||
|
elif control_type == "rich":
|
||||||
|
clauses.append("(decomposition_method IS NULL OR decomposition_method != 'pass0b')")
|
||||||
|
elif control_type == "eigenentwicklung":
|
||||||
|
clauses.append("""generation_strategy = 'ungrouped'
|
||||||
|
AND (pipeline_version = '1' OR pipeline_version IS NULL)
|
||||||
|
AND source_citation IS NULL
|
||||||
|
AND parent_control_uuid IS NULL""")
|
||||||
|
if search and skip != "search":
|
||||||
|
clauses.append("(control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)")
|
||||||
|
p["q"] = f"%{search}%"
|
||||||
|
|
||||||
|
return " AND ".join(clauses), p
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
total = db.execute(text("SELECT count(*) FROM canonical_controls")).scalar()
|
# Total with ALL filters
|
||||||
|
w_all, p_all = _build_where()
|
||||||
|
total = db.execute(text(f"SELECT count(*) FROM canonical_controls WHERE {w_all}"), p_all).scalar()
|
||||||
|
|
||||||
domains = db.execute(text("""
|
# Domain facet (skip domain filter so user sees all domains)
|
||||||
|
w_dom, p_dom = _build_where(skip="domain")
|
||||||
|
domains = db.execute(text(f"""
|
||||||
SELECT UPPER(SPLIT_PART(control_id, '-', 1)) as domain, count(*) as cnt
|
SELECT UPPER(SPLIT_PART(control_id, '-', 1)) as domain, count(*) as cnt
|
||||||
FROM canonical_controls
|
FROM canonical_controls WHERE {w_dom}
|
||||||
GROUP BY domain ORDER BY domain
|
GROUP BY domain ORDER BY domain
|
||||||
""")).fetchall()
|
"""), p_dom).fetchall()
|
||||||
|
|
||||||
sources = db.execute(text("""
|
# Source facet (skip source filter)
|
||||||
|
w_src, p_src = _build_where(skip="source")
|
||||||
|
sources = db.execute(text(f"""
|
||||||
SELECT source_citation->>'source' as src, count(*) as cnt
|
SELECT source_citation->>'source' as src, count(*) as cnt
|
||||||
FROM canonical_controls
|
FROM canonical_controls
|
||||||
WHERE source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
|
WHERE {w_src}
|
||||||
|
AND source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
|
||||||
GROUP BY src ORDER BY cnt DESC
|
GROUP BY src ORDER BY cnt DESC
|
||||||
""")).fetchall()
|
"""), p_src).fetchall()
|
||||||
|
|
||||||
no_source = db.execute(text("""
|
no_source = db.execute(text(f"""
|
||||||
SELECT count(*) FROM canonical_controls
|
SELECT count(*) FROM canonical_controls
|
||||||
WHERE source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = ''
|
WHERE {w_src}
|
||||||
""")).scalar()
|
AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')
|
||||||
|
"""), p_src).scalar()
|
||||||
|
|
||||||
|
# Type facet (skip control_type filter)
|
||||||
|
w_typ, p_typ = _build_where(skip="control_type")
|
||||||
|
atomic_count = db.execute(text(f"""
|
||||||
|
SELECT count(*) FROM canonical_controls
|
||||||
|
WHERE {w_typ} AND decomposition_method = 'pass0b'
|
||||||
|
"""), p_typ).scalar() or 0
|
||||||
|
|
||||||
|
eigenentwicklung_count = db.execute(text(f"""
|
||||||
|
SELECT count(*) FROM canonical_controls
|
||||||
|
WHERE {w_typ}
|
||||||
|
AND generation_strategy = 'ungrouped'
|
||||||
|
AND (pipeline_version = '1' OR pipeline_version IS NULL)
|
||||||
|
AND source_citation IS NULL
|
||||||
|
AND parent_control_uuid IS NULL
|
||||||
|
"""), p_typ).scalar() or 0
|
||||||
|
|
||||||
|
rich_count = db.execute(text(f"""
|
||||||
|
SELECT count(*) FROM canonical_controls
|
||||||
|
WHERE {w_typ}
|
||||||
|
AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')
|
||||||
|
"""), p_typ).scalar() or 0
|
||||||
|
|
||||||
|
# Severity facet (skip severity filter)
|
||||||
|
w_sev, p_sev = _build_where(skip="severity")
|
||||||
|
severity_counts = db.execute(text(f"""
|
||||||
|
SELECT severity, count(*) as cnt
|
||||||
|
FROM canonical_controls WHERE {w_sev}
|
||||||
|
GROUP BY severity ORDER BY severity
|
||||||
|
"""), p_sev).fetchall()
|
||||||
|
|
||||||
|
# Verification method facet (include NULLs as __none__)
|
||||||
|
w_vm, p_vm = _build_where(skip="verification_method")
|
||||||
|
vm_counts = db.execute(text(f"""
|
||||||
|
SELECT COALESCE(verification_method, '__none__') as vm, count(*) as cnt
|
||||||
|
FROM canonical_controls WHERE {w_vm}
|
||||||
|
GROUP BY vm ORDER BY vm
|
||||||
|
"""), p_vm).fetchall()
|
||||||
|
|
||||||
|
# Category facet (include NULLs as __none__)
|
||||||
|
w_cat, p_cat = _build_where(skip="category")
|
||||||
|
cat_counts = db.execute(text(f"""
|
||||||
|
SELECT COALESCE(category, '__none__') as cat, count(*) as cnt
|
||||||
|
FROM canonical_controls WHERE {w_cat}
|
||||||
|
GROUP BY cat ORDER BY cnt DESC
|
||||||
|
"""), p_cat).fetchall()
|
||||||
|
|
||||||
|
# Evidence type facet (include NULLs as __none__)
|
||||||
|
w_et, p_et = _build_where(skip="evidence_type")
|
||||||
|
et_counts = db.execute(text(f"""
|
||||||
|
SELECT COALESCE(evidence_type, '__none__') as et, count(*) as cnt
|
||||||
|
FROM canonical_controls WHERE {w_et}
|
||||||
|
GROUP BY et ORDER BY et
|
||||||
|
"""), p_et).fetchall()
|
||||||
|
|
||||||
|
# Release state facet
|
||||||
|
w_rs, p_rs = _build_where(skip="release_state")
|
||||||
|
rs_counts = db.execute(text(f"""
|
||||||
|
SELECT release_state, count(*) as cnt
|
||||||
|
FROM canonical_controls WHERE {w_rs}
|
||||||
|
GROUP BY release_state ORDER BY release_state
|
||||||
|
"""), p_rs).fetchall()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"domains": [{"domain": r[0], "count": r[1]} for r in domains],
|
"domains": [{"domain": r[0], "count": r[1]} for r in domains],
|
||||||
"sources": [{"source": r[0], "count": r[1]} for r in sources],
|
"sources": [{"source": r[0], "count": r[1]} for r in sources],
|
||||||
"no_source_count": no_source,
|
"no_source_count": no_source,
|
||||||
|
"type_counts": {
|
||||||
|
"rich": rich_count,
|
||||||
|
"atomic": atomic_count,
|
||||||
|
"eigenentwicklung": eigenentwicklung_count,
|
||||||
|
},
|
||||||
|
"severity_counts": {r[0]: r[1] for r in severity_counts},
|
||||||
|
"verification_method_counts": {r[0]: r[1] for r in vm_counts},
|
||||||
|
"category_counts": {r[0]: r[1] for r in cat_counts},
|
||||||
|
"evidence_type_counts": {r[0]: r[1] for r in et_counts},
|
||||||
|
"release_state_counts": {r[0]: r[1] for r in rs_counts},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -528,6 +730,15 @@ async def atomic_stats():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/controls/v1-enrichment-stats")
|
||||||
|
async def v1_enrichment_stats_endpoint():
|
||||||
|
"""
|
||||||
|
Uebersicht: Wie viele v1 Controls haben regulatorische Abdeckung?
|
||||||
|
"""
|
||||||
|
from compliance.services.v1_enrichment import get_v1_enrichment_stats
|
||||||
|
return await get_v1_enrichment_stats()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/controls/{control_id}")
|
@router.get("/controls/{control_id}")
|
||||||
async def get_control(control_id: str):
|
async def get_control(control_id: str):
|
||||||
"""Get a single canonical control by its control_id (e.g. AUTH-001)."""
|
"""Get a single canonical control by its control_id (e.g. AUTH-001)."""
|
||||||
@@ -804,7 +1015,7 @@ async def get_control_provenance(control_id: str):
|
|||||||
normative_strength, release_state
|
normative_strength, release_state
|
||||||
FROM obligation_candidates
|
FROM obligation_candidates
|
||||||
WHERE parent_control_uuid = CAST(:uid AS uuid)
|
WHERE parent_control_uuid = CAST(:uid AS uuid)
|
||||||
AND release_state NOT IN ('rejected', 'merged')
|
AND release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
ORDER BY candidate_id
|
ORDER BY candidate_id
|
||||||
"""),
|
"""),
|
||||||
{"uid": ctrl_uuid},
|
{"uid": ctrl_uuid},
|
||||||
@@ -908,6 +1119,516 @@ async def get_control_provenance(control_id: str):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# NORMATIVE STRENGTH BACKFILL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post("/controls/backfill-normative-strength")
|
||||||
|
async def backfill_normative_strength(
|
||||||
|
dry_run: bool = Query(True, description="Nur zaehlen, nicht aendern"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Korrigiert normative_strength auf obligation_candidates basierend auf
|
||||||
|
dem source_type der Quell-Regulierung.
|
||||||
|
|
||||||
|
Dreistufiges Modell:
|
||||||
|
- law (Gesetz): normative_strength bleibt unveraendert
|
||||||
|
- guideline (Leitlinie): max 'should'
|
||||||
|
- framework (Framework): max 'can'
|
||||||
|
|
||||||
|
Fuer Controls mit mehreren Parent-Links gilt der hoechste source_type.
|
||||||
|
"""
|
||||||
|
from compliance.data.source_type_classification import (
|
||||||
|
classify_source_regulation,
|
||||||
|
cap_normative_strength,
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
# 1. Alle Obligations mit source_citation des Parent Controls laden
|
||||||
|
obligations = db.execute(text("""
|
||||||
|
SELECT oc.id, oc.candidate_id, oc.normative_strength,
|
||||||
|
cc.source_citation->>'source' AS parent_source
|
||||||
|
FROM obligation_candidates oc
|
||||||
|
JOIN canonical_controls cc ON cc.id = oc.parent_control_uuid
|
||||||
|
WHERE oc.release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
|
AND oc.normative_strength IS NOT NULL
|
||||||
|
ORDER BY oc.candidate_id
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
# 2. Normative strength korrigieren basierend auf source_type
|
||||||
|
changes = []
|
||||||
|
stats = {"total": len(obligations), "unchanged": 0, "capped_to_should": 0, "capped_to_may": 0, "no_source": 0}
|
||||||
|
|
||||||
|
for obl in obligations:
|
||||||
|
if not obl.parent_source:
|
||||||
|
stats["no_source"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_type = classify_source_regulation(obl.parent_source)
|
||||||
|
new_strength = cap_normative_strength(obl.normative_strength, source_type)
|
||||||
|
|
||||||
|
if new_strength != obl.normative_strength:
|
||||||
|
changes.append({
|
||||||
|
"id": str(obl.id),
|
||||||
|
"candidate_id": obl.candidate_id,
|
||||||
|
"old_strength": obl.normative_strength,
|
||||||
|
"new_strength": new_strength,
|
||||||
|
"source_type": source_type,
|
||||||
|
"source_regulation": obl.parent_source,
|
||||||
|
})
|
||||||
|
if new_strength == "should":
|
||||||
|
stats["capped_to_should"] += 1
|
||||||
|
elif new_strength == "may":
|
||||||
|
stats["capped_to_may"] += 1
|
||||||
|
else:
|
||||||
|
stats["unchanged"] += 1
|
||||||
|
|
||||||
|
# 4. Aenderungen anwenden (wenn kein dry_run)
|
||||||
|
if not dry_run and changes:
|
||||||
|
for change in changes:
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE obligation_candidates
|
||||||
|
SET normative_strength = :new_strength
|
||||||
|
WHERE id = CAST(:oid AS uuid)
|
||||||
|
"""), {"new_strength": change["new_strength"], "oid": change["id"]})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"stats": stats,
|
||||||
|
"total_changes": len(changes),
|
||||||
|
"sample_changes": changes[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OBLIGATION DEDUPLICATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post("/obligations/dedup")
|
||||||
|
async def dedup_obligations(
|
||||||
|
dry_run: bool = Query(True, description="Nur zaehlen, nicht aendern"),
|
||||||
|
batch_size: int = Query(0, description="0 = alle auf einmal"),
|
||||||
|
offset: int = Query(0, description="Offset fuer Batch-Verarbeitung"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Markiert doppelte obligation_candidates als 'duplicate'.
|
||||||
|
|
||||||
|
Duplikate = mehrere Eintraege mit gleichem candidate_id.
|
||||||
|
Pro candidate_id wird der aelteste Eintrag (MIN(created_at)) behalten,
|
||||||
|
alle anderen erhalten release_state='duplicate' und merged_into_id
|
||||||
|
zeigt auf den behaltenen Eintrag.
|
||||||
|
"""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
# 1. Finde alle candidate_ids mit mehr als einem Eintrag
|
||||||
|
# (nur noch nicht-deduplizierte beruecksichtigen)
|
||||||
|
dup_query = """
|
||||||
|
SELECT candidate_id, count(*) as cnt
|
||||||
|
FROM obligation_candidates
|
||||||
|
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
|
GROUP BY candidate_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
ORDER BY candidate_id
|
||||||
|
"""
|
||||||
|
if batch_size > 0:
|
||||||
|
dup_query += f" LIMIT {batch_size} OFFSET {offset}"
|
||||||
|
|
||||||
|
dup_groups = db.execute(text(dup_query)).fetchall()
|
||||||
|
|
||||||
|
total_groups = db.execute(text("""
|
||||||
|
SELECT count(*) FROM (
|
||||||
|
SELECT candidate_id
|
||||||
|
FROM obligation_candidates
|
||||||
|
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
|
GROUP BY candidate_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) sub
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
|
# 2. Pro Gruppe: aeltesten behalten, Rest als duplicate markieren
|
||||||
|
kept_count = 0
|
||||||
|
duplicate_count = 0
|
||||||
|
sample_changes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for grp in dup_groups:
|
||||||
|
cid = grp.candidate_id
|
||||||
|
|
||||||
|
# Alle Eintraege fuer dieses candidate_id holen
|
||||||
|
entries = db.execute(text("""
|
||||||
|
SELECT id, candidate_id, obligation_text, release_state, created_at
|
||||||
|
FROM obligation_candidates
|
||||||
|
WHERE candidate_id = :cid
|
||||||
|
AND release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
"""), {"cid": cid}).fetchall()
|
||||||
|
|
||||||
|
if len(entries) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
keeper = entries[0] # aeltester Eintrag
|
||||||
|
duplicates = entries[1:]
|
||||||
|
kept_count += 1
|
||||||
|
duplicate_count += len(duplicates)
|
||||||
|
|
||||||
|
if len(sample_changes) < 20:
|
||||||
|
sample_changes.append({
|
||||||
|
"candidate_id": cid,
|
||||||
|
"kept_id": str(keeper.id),
|
||||||
|
"kept_text": keeper.obligation_text[:100],
|
||||||
|
"duplicate_count": len(duplicates),
|
||||||
|
"duplicate_ids": [str(d.id) for d in duplicates],
|
||||||
|
})
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
for dup in duplicates:
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE obligation_candidates
|
||||||
|
SET release_state = 'duplicate',
|
||||||
|
merged_into_id = CAST(:keeper_id AS uuid),
|
||||||
|
quality_flags = COALESCE(quality_flags, '{}'::jsonb)
|
||||||
|
|| jsonb_build_object(
|
||||||
|
'dedup_reason', 'duplicate of ' || :keeper_cid,
|
||||||
|
'dedup_kept_id', :keeper_id_str,
|
||||||
|
'dedup_at', NOW()::text
|
||||||
|
)
|
||||||
|
WHERE id = CAST(:dup_id AS uuid)
|
||||||
|
"""), {
|
||||||
|
"keeper_id": str(keeper.id),
|
||||||
|
"keeper_cid": cid,
|
||||||
|
"keeper_id_str": str(keeper.id),
|
||||||
|
"dup_id": str(dup.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not dry_run and duplicate_count > 0:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"stats": {
|
||||||
|
"total_duplicate_groups": total_groups,
|
||||||
|
"processed_groups": len(dup_groups),
|
||||||
|
"kept": kept_count,
|
||||||
|
"marked_duplicate": duplicate_count,
|
||||||
|
},
|
||||||
|
"sample_changes": sample_changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/obligations/dedup-stats")
|
||||||
|
async def dedup_obligations_stats():
|
||||||
|
"""Statistiken ueber den aktuellen Dedup-Status der Obligations."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
total = db.execute(text(
|
||||||
|
"SELECT count(*) FROM obligation_candidates"
|
||||||
|
)).scalar()
|
||||||
|
|
||||||
|
by_state = db.execute(text("""
|
||||||
|
SELECT release_state, count(*) as cnt
|
||||||
|
FROM obligation_candidates
|
||||||
|
GROUP BY release_state
|
||||||
|
ORDER BY release_state
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
dup_groups = db.execute(text("""
|
||||||
|
SELECT count(*) FROM (
|
||||||
|
SELECT candidate_id
|
||||||
|
FROM obligation_candidates
|
||||||
|
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
|
GROUP BY candidate_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) sub
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
|
removable = db.execute(text("""
|
||||||
|
SELECT COALESCE(sum(cnt - 1), 0) FROM (
|
||||||
|
SELECT candidate_id, count(*) as cnt
|
||||||
|
FROM obligation_candidates
|
||||||
|
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
|
||||||
|
GROUP BY candidate_id
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) sub
|
||||||
|
""")).scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_obligations": total,
|
||||||
|
"by_state": {r.release_state: r.cnt for r in by_state},
|
||||||
|
"pending_duplicate_groups": dup_groups,
|
||||||
|
"pending_removable_duplicates": removable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EVIDENCE TYPE BACKFILL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Domains that are primarily technical (code-verifiable)
|
||||||
|
_CODE_DOMAINS = frozenset({
|
||||||
|
"SEC", "AUTH", "CRYPT", "CRYP", "CRY", "NET", "LOG", "ACC", "APP", "SYS",
|
||||||
|
"CI", "CONT", "API", "CLOUD", "IAC", "SAST", "DAST", "DEP", "SBOM",
|
||||||
|
"WEB", "DEV", "SDL", "PKI", "HSM", "TEE", "TPM", "CRX", "CRF",
|
||||||
|
"FWU", "STO", "RUN", "VUL", "MAL", "PLT", "AUT",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Domains that are primarily process-based (document-verifiable)
|
||||||
|
_PROCESS_DOMAINS = frozenset({
|
||||||
|
"GOV", "ORG", "COMP", "LEGAL", "HR", "TRAIN", "AML", "FIN",
|
||||||
|
"RISK", "AUDIT", "AUD", "PROC", "DOC", "PHYS", "PHY", "PRIV", "DPO",
|
||||||
|
"BCDR", "BCP", "VENDOR", "SUPPLY", "SUP", "CERT", "POLICY",
|
||||||
|
"ENV", "HLT", "TRD", "LAB", "PER", "REL", "ISM", "COM",
|
||||||
|
"GAM", "RIS", "PCA", "GNT", "HCA", "RES", "ISS",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Domains that are typically hybrid
|
||||||
|
_HYBRID_DOMAINS = frozenset({
|
||||||
|
"DATA", "AI", "INC", "ID", "IAM", "IDF", "IDP", "IDA", "IDN",
|
||||||
|
"OPS", "MNT", "INT", "BCK",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_evidence_type(control_id: str, category: str | None) -> str:
|
||||||
|
"""Heuristic: classify a control as code/process/hybrid based on domain prefix."""
|
||||||
|
domain = control_id.split("-")[0].upper() if control_id else ""
|
||||||
|
|
||||||
|
if domain in _CODE_DOMAINS:
|
||||||
|
return "code"
|
||||||
|
if domain in _PROCESS_DOMAINS:
|
||||||
|
return "process"
|
||||||
|
if domain in _HYBRID_DOMAINS:
|
||||||
|
return "hybrid"
|
||||||
|
|
||||||
|
# Fallback: use category if available
|
||||||
|
code_categories = {"encryption", "authentication", "network", "application", "system", "identity"}
|
||||||
|
process_categories = {"compliance", "personnel", "physical", "governance", "risk"}
|
||||||
|
if category in code_categories:
|
||||||
|
return "code"
|
||||||
|
if category in process_categories:
|
||||||
|
return "process"
|
||||||
|
|
||||||
|
return "process" # Conservative default
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/controls/backfill-evidence-type")
|
||||||
|
async def backfill_evidence_type(
|
||||||
|
dry_run: bool = Query(True, description="Nur zaehlen, nicht aendern"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Klassifiziert Controls als code/process/hybrid basierend auf Domain-Prefix.
|
||||||
|
|
||||||
|
Heuristik:
|
||||||
|
- SEC, AUTH, CRYPT, NET, LOG, ... → code
|
||||||
|
- GOV, ORG, COMP, LEGAL, HR, ... → process
|
||||||
|
- DATA, AI, INC → hybrid
|
||||||
|
"""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT id, control_id, category, evidence_type
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE release_state NOT IN ('rejected', 'merged')
|
||||||
|
ORDER BY control_id
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
stats = {"total": len(rows), "already_set": 0, "code": 0, "process": 0, "hybrid": 0}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.evidence_type is not None:
|
||||||
|
stats["already_set"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_type = _classify_evidence_type(row.control_id, row.category)
|
||||||
|
stats[new_type] += 1
|
||||||
|
changes.append({
|
||||||
|
"id": str(row.id),
|
||||||
|
"control_id": row.control_id,
|
||||||
|
"evidence_type": new_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not dry_run and changes:
|
||||||
|
for change in changes:
|
||||||
|
db.execute(text("""
|
||||||
|
UPDATE canonical_controls
|
||||||
|
SET evidence_type = :et
|
||||||
|
WHERE id = CAST(:cid AS uuid)
|
||||||
|
"""), {"et": change["evidence_type"], "cid": change["id"]})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"stats": stats,
|
||||||
|
"total_changes": len(changes),
|
||||||
|
"sample_changes": changes[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RATIONALE BACKFILL (LLM)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post("/controls/backfill-rationale")
|
||||||
|
async def backfill_rationale(
|
||||||
|
dry_run: bool = Query(True, description="Nur zaehlen, nicht aendern"),
|
||||||
|
batch_size: int = Query(50, description="Parent-Controls pro Durchlauf"),
|
||||||
|
offset: int = Query(0, description="Offset fuer Paginierung (Parent-Index)"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generiert sinnvolle Begruendungen fuer atomare Controls per LLM.
|
||||||
|
|
||||||
|
Optimierung: Gruppiert nach Parent-Control (~7k Parents statt ~86k Einzel-Calls).
|
||||||
|
Pro Parent-Gruppe wird EIN LLM-Aufruf gemacht, der eine gemeinsame
|
||||||
|
Begruendung fuer alle Kinder erzeugt.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. dry_run=true → Statistiken anzeigen
|
||||||
|
2. dry_run=false&batch_size=50&offset=0 → Erste 50 Parents verarbeiten
|
||||||
|
3. Wiederholen mit offset=50, 100, ... bis fertig
|
||||||
|
"""
|
||||||
|
from compliance.services.llm_provider import get_llm_provider
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
# 1. Parent-Controls mit Kindern laden (nur wo rationale = Placeholder)
|
||||||
|
parents = db.execute(text("""
|
||||||
|
SELECT p.id AS parent_uuid, p.control_id, p.title, p.category,
|
||||||
|
p.source_citation->>'source' AS source_name,
|
||||||
|
COUNT(c.id) AS child_count
|
||||||
|
FROM canonical_controls p
|
||||||
|
JOIN canonical_controls c ON c.parent_control_uuid = p.id
|
||||||
|
WHERE c.rationale = 'Aus Obligation abgeleitet.'
|
||||||
|
AND c.release_state NOT IN ('rejected', 'merged')
|
||||||
|
GROUP BY p.id, p.control_id, p.title, p.category,
|
||||||
|
p.source_citation->>'source'
|
||||||
|
ORDER BY p.control_id
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
total_parents = len(parents)
|
||||||
|
total_children = sum(p.child_count for p in parents)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"total_parents": total_parents,
|
||||||
|
"total_children": total_children,
|
||||||
|
"estimated_llm_calls": total_parents,
|
||||||
|
"sample_parents": [
|
||||||
|
{
|
||||||
|
"control_id": p.control_id,
|
||||||
|
"title": p.title,
|
||||||
|
"source": p.source_name,
|
||||||
|
"child_count": p.child_count,
|
||||||
|
}
|
||||||
|
for p in parents[:10]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Batch auswählen
|
||||||
|
batch = parents[offset : offset + batch_size]
|
||||||
|
if not batch:
|
||||||
|
return {
|
||||||
|
"dry_run": False,
|
||||||
|
"message": "Kein weiterer Batch — alle Parents verarbeitet.",
|
||||||
|
"total_parents": total_parents,
|
||||||
|
"offset": offset,
|
||||||
|
"processed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
provider = get_llm_provider()
|
||||||
|
processed = 0
|
||||||
|
children_updated = 0
|
||||||
|
errors = []
|
||||||
|
sample_rationales = []
|
||||||
|
|
||||||
|
for parent in batch:
|
||||||
|
parent_uuid = str(parent.parent_uuid)
|
||||||
|
source = parent.source_name or "Regulierung"
|
||||||
|
|
||||||
|
# LLM-Prompt
|
||||||
|
prompt = (
|
||||||
|
f"Du bist Compliance-Experte. Erklaere in 1-2 Saetzen auf Deutsch, "
|
||||||
|
f"WARUM aus dem uebergeordneten Control atomare Teilmassnahmen "
|
||||||
|
f"abgeleitet wurden.\n\n"
|
||||||
|
f"Uebergeordnetes Control: {parent.control_id} — {parent.title}\n"
|
||||||
|
f"Regulierung: {source}\n"
|
||||||
|
f"Kategorie: {parent.category or 'k.A.'}\n"
|
||||||
|
f"Anzahl atomarer Controls: {parent.child_count}\n\n"
|
||||||
|
f"Schreibe NUR die Begruendung (1-2 Saetze). Kein Markdown, "
|
||||||
|
f"keine Aufzaehlung, kein Praefix. "
|
||||||
|
f"Erklaere den regulatorischen Hintergrund und warum die "
|
||||||
|
f"Zerlegung in atomare, testbare Massnahmen notwendig ist."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await provider.complete(
|
||||||
|
prompt=prompt,
|
||||||
|
max_tokens=256,
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
rationale = response.content.strip()
|
||||||
|
|
||||||
|
# Bereinigen: Anfuehrungszeichen, Markdown entfernen
|
||||||
|
rationale = rationale.strip('"').strip("'").strip()
|
||||||
|
if rationale.startswith("Begründung:") or rationale.startswith("Begruendung:"):
|
||||||
|
rationale = rationale.split(":", 1)[1].strip()
|
||||||
|
|
||||||
|
# Laenge begrenzen (max 500 Zeichen)
|
||||||
|
if len(rationale) > 500:
|
||||||
|
rationale = rationale[:497] + "..."
|
||||||
|
|
||||||
|
if not rationale or len(rationale) < 10:
|
||||||
|
errors.append({
|
||||||
|
"control_id": parent.control_id,
|
||||||
|
"error": "LLM-Antwort zu kurz oder leer",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Alle Kinder dieses Parents updaten
|
||||||
|
result = db.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE canonical_controls
|
||||||
|
SET rationale = :rationale
|
||||||
|
WHERE parent_control_uuid = CAST(:pid AS uuid)
|
||||||
|
AND rationale = 'Aus Obligation abgeleitet.'
|
||||||
|
AND release_state NOT IN ('rejected', 'merged')
|
||||||
|
"""),
|
||||||
|
{"rationale": rationale, "pid": parent_uuid},
|
||||||
|
)
|
||||||
|
children_updated += result.rowcount
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
if len(sample_rationales) < 5:
|
||||||
|
sample_rationales.append({
|
||||||
|
"parent": parent.control_id,
|
||||||
|
"title": parent.title,
|
||||||
|
"rationale": rationale,
|
||||||
|
"children_updated": result.rowcount,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM error for {parent.control_id}: {e}")
|
||||||
|
errors.append({
|
||||||
|
"control_id": parent.control_id,
|
||||||
|
"error": str(e)[:200],
|
||||||
|
})
|
||||||
|
# Rollback um DB-Session nach Fehler nutzbar zu halten
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": False,
|
||||||
|
"offset": offset,
|
||||||
|
"batch_size": batch_size,
|
||||||
|
"next_offset": offset + batch_size if offset + batch_size < total_parents else None,
|
||||||
|
"processed_parents": processed,
|
||||||
|
"children_updated": children_updated,
|
||||||
|
"total_parents": total_parents,
|
||||||
|
"total_children": total_children,
|
||||||
|
"errors": errors[:10],
|
||||||
|
"sample_rationales": sample_rationales,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CONTROL CRUD (CREATE / UPDATE / DELETE)
|
# CONTROL CRUD (CREATE / UPDATE / DELETE)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -950,7 +1671,7 @@ async def create_control(body: ControlCreateRequest):
|
|||||||
severity, risk_score, implementation_effort, evidence_confidence,
|
severity, risk_score, implementation_effort, evidence_confidence,
|
||||||
open_anchors, release_state, tags,
|
open_anchors, release_state, tags,
|
||||||
license_rule, source_original_text, source_citation,
|
license_rule, source_original_text, source_citation,
|
||||||
customer_visible, verification_method, category,
|
customer_visible, verification_method, category, evidence_type,
|
||||||
target_audience, generation_metadata,
|
target_audience, generation_metadata,
|
||||||
applicable_industries, applicable_company_size, scope_conditions
|
applicable_industries, applicable_company_size, scope_conditions
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@@ -961,7 +1682,7 @@ async def create_control(body: ControlCreateRequest):
|
|||||||
CAST(:anchors AS jsonb), :release_state, CAST(:tags AS jsonb),
|
CAST(:anchors AS jsonb), :release_state, CAST(:tags AS jsonb),
|
||||||
:license_rule, :source_original_text,
|
:license_rule, :source_original_text,
|
||||||
CAST(:source_citation AS jsonb),
|
CAST(:source_citation AS jsonb),
|
||||||
:customer_visible, :verification_method, :category,
|
:customer_visible, :verification_method, :category, :evidence_type,
|
||||||
:target_audience, CAST(:generation_metadata AS jsonb),
|
:target_audience, CAST(:generation_metadata AS jsonb),
|
||||||
CAST(:applicable_industries AS jsonb),
|
CAST(:applicable_industries AS jsonb),
|
||||||
CAST(:applicable_company_size AS jsonb),
|
CAST(:applicable_company_size AS jsonb),
|
||||||
@@ -992,6 +1713,7 @@ async def create_control(body: ControlCreateRequest):
|
|||||||
"customer_visible": body.customer_visible,
|
"customer_visible": body.customer_visible,
|
||||||
"verification_method": body.verification_method,
|
"verification_method": body.verification_method,
|
||||||
"category": body.category,
|
"category": body.category,
|
||||||
|
"evidence_type": body.evidence_type,
|
||||||
"target_audience": body.target_audience,
|
"target_audience": body.target_audience,
|
||||||
"generation_metadata": _json.dumps(body.generation_metadata) if body.generation_metadata else None,
|
"generation_metadata": _json.dumps(body.generation_metadata) if body.generation_metadata else None,
|
||||||
"applicable_industries": _json.dumps(body.applicable_industries) if body.applicable_industries else None,
|
"applicable_industries": _json.dumps(body.applicable_industries) if body.applicable_industries else None,
|
||||||
@@ -1193,6 +1915,57 @@ async def list_licenses():
|
|||||||
return get_license_matrix(db)
|
return get_license_matrix(db)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# V1 ENRICHMENT (Eigenentwicklung → Regulatorische Abdeckung)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.post("/controls/enrich-v1-matches")
|
||||||
|
async def enrich_v1_matches_endpoint(
|
||||||
|
dry_run: bool = Query(True, description="Nur zaehlen, nicht schreiben"),
|
||||||
|
batch_size: int = Query(100, description="Controls pro Durchlauf"),
|
||||||
|
offset: int = Query(0, description="Offset fuer Paginierung"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Findet regulatorische Abdeckung fuer v1 Eigenentwicklung Controls.
|
||||||
|
|
||||||
|
Eigenentwicklung = generation_strategy='ungrouped', pipeline_version=1,
|
||||||
|
source_citation IS NULL, parent_control_uuid IS NULL.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. dry_run=true → Statistiken anzeigen
|
||||||
|
2. dry_run=false&batch_size=100&offset=0 → Erste 100 verarbeiten
|
||||||
|
3. Wiederholen mit next_offset bis fertig
|
||||||
|
"""
|
||||||
|
from compliance.services.v1_enrichment import enrich_v1_matches
|
||||||
|
return await enrich_v1_matches(
|
||||||
|
dry_run=dry_run,
|
||||||
|
batch_size=batch_size,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/controls/{control_id}/v1-matches")
|
||||||
|
async def get_v1_matches_endpoint(control_id: str):
|
||||||
|
"""
|
||||||
|
Gibt regulatorische Matches fuer ein v1 Control zurueck.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste von Matches mit Control-Details, Source, Score.
|
||||||
|
"""
|
||||||
|
from compliance.services.v1_enrichment import get_v1_matches
|
||||||
|
|
||||||
|
# Resolve control_id to UUID
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT id FROM canonical_controls WHERE control_id = :cid
|
||||||
|
"""), {"cid": control_id}).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
|
||||||
|
|
||||||
|
return await get_v1_matches(str(row.id))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# INTERNAL HELPERS
|
# INTERNAL HELPERS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1222,6 +1995,7 @@ def _control_row(r) -> dict:
|
|||||||
"customer_visible": r.customer_visible,
|
"customer_visible": r.customer_visible,
|
||||||
"verification_method": r.verification_method,
|
"verification_method": r.verification_method,
|
||||||
"category": r.category,
|
"category": r.category,
|
||||||
|
"evidence_type": getattr(r, "evidence_type", None),
|
||||||
"target_audience": r.target_audience,
|
"target_audience": r.target_audience,
|
||||||
"generation_metadata": r.generation_metadata,
|
"generation_metadata": r.generation_metadata,
|
||||||
"generation_strategy": getattr(r, "generation_strategy", "ungrouped"),
|
"generation_strategy": getattr(r, "generation_strategy", "ungrouped"),
|
||||||
|
|||||||
205
backend-compliance/compliance/data/source_type_classification.py
Normal file
205
backend-compliance/compliance/data/source_type_classification.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Source-Type-Klassifikation fuer Regulierungen und Frameworks.
|
||||||
|
|
||||||
|
Dreistufiges Modell der normativen Verbindlichkeit:
|
||||||
|
|
||||||
|
Stufe 1 — GESETZ (law):
|
||||||
|
Rechtlich bindend. Bussgeld bei Verstoss.
|
||||||
|
Beispiele: DSGVO, NIS2, AI Act, CRA
|
||||||
|
|
||||||
|
Stufe 2 — LEITLINIE (guideline):
|
||||||
|
Offizielle Auslegungshilfe von Aufsichtsbehoerden.
|
||||||
|
Beweislastumkehr: Wer abweicht, muss begruenden warum.
|
||||||
|
Beispiele: EDPB-Leitlinien, BSI-Standards, WP29-Dokumente
|
||||||
|
|
||||||
|
Stufe 3 — FRAMEWORK (framework):
|
||||||
|
Freiwillige Best Practices, nicht rechtsverbindlich.
|
||||||
|
Aber: Koennen als "Stand der Technik" herangezogen werden.
|
||||||
|
Beispiele: ENISA, NIST, OWASP, OECD, CISA
|
||||||
|
|
||||||
|
Mapping: source_regulation (aus control_parent_links) -> source_type
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- Typ-Definitionen ---
|
||||||
|
SOURCE_TYPE_LAW = "law" # Gesetz/Verordnung/Richtlinie — normative_strength bleibt
|
||||||
|
SOURCE_TYPE_GUIDELINE = "guideline" # Leitlinie/Standard — max "should"
|
||||||
|
SOURCE_TYPE_FRAMEWORK = "framework" # Framework/Best Practice — max "may"
|
||||||
|
|
||||||
|
# Max erlaubte normative_strength pro source_type
|
||||||
|
# DB-Constraint erlaubt: must, should, may (NICHT "can")
|
||||||
|
NORMATIVE_STRENGTH_CAP: dict[str, str] = {
|
||||||
|
SOURCE_TYPE_LAW: "must", # keine Begrenzung
|
||||||
|
SOURCE_TYPE_GUIDELINE: "should", # max "should"
|
||||||
|
SOURCE_TYPE_FRAMEWORK: "may", # max "may" (= "kann")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reihenfolge fuer Vergleiche (hoeher = staerker)
|
||||||
|
STRENGTH_ORDER: dict[str, int] = {
|
||||||
|
"may": 1, # KANN (DB-Wert)
|
||||||
|
"can": 1, # Alias — wird in cap_normative_strength zu "may" normalisiert
|
||||||
|
"should": 2,
|
||||||
|
"must": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cap_normative_strength(original: str, source_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Begrenzt die normative_strength basierend auf dem source_type.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
cap_normative_strength("must", "framework") -> "may"
|
||||||
|
cap_normative_strength("should", "law") -> "should"
|
||||||
|
cap_normative_strength("must", "guideline") -> "should"
|
||||||
|
"""
|
||||||
|
cap = NORMATIVE_STRENGTH_CAP.get(source_type, "must")
|
||||||
|
cap_level = STRENGTH_ORDER.get(cap, 3)
|
||||||
|
original_level = STRENGTH_ORDER.get(original, 3)
|
||||||
|
if original_level > cap_level:
|
||||||
|
return cap
|
||||||
|
return original
|
||||||
|
|
||||||
|
|
||||||
|
def get_highest_source_type(source_types: list[str]) -> str:
|
||||||
|
"""
|
||||||
|
Bestimmt den hoechsten source_type aus einer Liste.
|
||||||
|
Ein Gesetz uebertrumpft alles.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
get_highest_source_type(["framework", "law"]) -> "law"
|
||||||
|
get_highest_source_type(["framework", "guideline"]) -> "guideline"
|
||||||
|
"""
|
||||||
|
type_order = {SOURCE_TYPE_FRAMEWORK: 1, SOURCE_TYPE_GUIDELINE: 2, SOURCE_TYPE_LAW: 3}
|
||||||
|
if not source_types:
|
||||||
|
return SOURCE_TYPE_FRAMEWORK
|
||||||
|
return max(source_types, key=lambda t: type_order.get(t, 0))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Klassifikation: source_regulation -> source_type
|
||||||
|
#
|
||||||
|
# Diese Map wird fuer den Backfill und zukuenftige Pipeline-Runs verwendet.
|
||||||
|
# Neue Regulierungen hier eintragen!
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
SOURCE_REGULATION_CLASSIFICATION: dict[str, str] = {
|
||||||
|
# --- EU-Verordnungen (unmittelbar bindend) ---
|
||||||
|
"DSGVO (EU) 2016/679": SOURCE_TYPE_LAW,
|
||||||
|
"KI-Verordnung (EU) 2024/1689": SOURCE_TYPE_LAW,
|
||||||
|
"Cyber Resilience Act (CRA)": SOURCE_TYPE_LAW,
|
||||||
|
"NIS2-Richtlinie (EU) 2022/2555": SOURCE_TYPE_LAW,
|
||||||
|
"Data Act": SOURCE_TYPE_LAW,
|
||||||
|
"Data Governance Act (DGA)": SOURCE_TYPE_LAW,
|
||||||
|
"Markets in Crypto-Assets (MiCA)": SOURCE_TYPE_LAW,
|
||||||
|
"Maschinenverordnung (EU) 2023/1230": SOURCE_TYPE_LAW,
|
||||||
|
"Batterieverordnung (EU) 2023/1542": SOURCE_TYPE_LAW,
|
||||||
|
"AML-Verordnung": SOURCE_TYPE_LAW,
|
||||||
|
|
||||||
|
# --- EU-Richtlinien (nach nationaler Umsetzung bindend) ---
|
||||||
|
# Fuer Compliance-Zwecke wie Gesetze behandeln
|
||||||
|
|
||||||
|
# --- Nationale Gesetze ---
|
||||||
|
"Bundesdatenschutzgesetz (BDSG)": SOURCE_TYPE_LAW,
|
||||||
|
"Telekommunikationsgesetz": SOURCE_TYPE_LAW,
|
||||||
|
"Telekommunikationsgesetz Oesterreich": SOURCE_TYPE_LAW,
|
||||||
|
"Gewerbeordnung (GewO)": SOURCE_TYPE_LAW,
|
||||||
|
"Handelsgesetzbuch (HGB)": SOURCE_TYPE_LAW,
|
||||||
|
"Abgabenordnung (AO)": SOURCE_TYPE_LAW,
|
||||||
|
"IFRS-Übernahmeverordnung": SOURCE_TYPE_LAW,
|
||||||
|
"Österreichisches Datenschutzgesetz (DSG)": SOURCE_TYPE_LAW,
|
||||||
|
"LOPDGDD - Ley Orgánica de Protección de Datos (Spanien)": SOURCE_TYPE_LAW,
|
||||||
|
"Loi Informatique et Libertés (Frankreich)": SOURCE_TYPE_LAW,
|
||||||
|
"Információs önrendelkezési jog törvény (Ungarn)": SOURCE_TYPE_LAW,
|
||||||
|
"EU Blue Guide 2022": SOURCE_TYPE_LAW,
|
||||||
|
|
||||||
|
# --- EDPB/WP29 Leitlinien (offizielle Auslegungshilfe) ---
|
||||||
|
"EDPB Leitlinien 01/2019 (Zertifizierung)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 01/2020 (Datentransfers)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 01/2020 (Vernetzte Fahrzeuge)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 01/2022 (BCR)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 01/2024 (Berechtigtes Interesse)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 04/2019 (Data Protection by Design)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 05/2020 - Einwilligung": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 07/2020 (Datentransfers)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 08/2020 (Social Media)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 09/2022 (Data Breach)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien 09/2022 - Meldung von Datenschutzverletzungen": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Empfehlungen 01/2020 - Ergaenzende Massnahmen fuer Datentransfers": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"EDPB Leitlinien - Berechtigtes Interesse (Art. 6(1)(f))": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"WP244 Leitlinien (Profiling)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"WP251 Leitlinien (Profiling)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"WP260 Leitlinien (Transparenz)": SOURCE_TYPE_GUIDELINE,
|
||||||
|
|
||||||
|
# --- BSI Standards (behoerdliche technische Richtlinien) ---
|
||||||
|
"BSI-TR-03161-1": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"BSI-TR-03161-2": SOURCE_TYPE_GUIDELINE,
|
||||||
|
"BSI-TR-03161-3": SOURCE_TYPE_GUIDELINE,
|
||||||
|
|
||||||
|
# --- ENISA (EU-Agentur, aber Empfehlungen nicht rechtsverbindlich) ---
|
||||||
|
"ENISA Cybersecurity State 2024": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"ENISA ICS/SCADA Dependencies": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"ENISA Supply Chain Good Practices": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"ENISA Threat Landscape Supply Chain": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
|
||||||
|
# --- NIST (US-Standards, international als Best Practice) ---
|
||||||
|
"NIST AI Risk Management Framework": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"NIST Cybersecurity Framework 2.0": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"NIST SP 800-207 (Zero Trust)": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"NIST SP 800-218 (SSDF)": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"NIST SP 800-53 Rev. 5": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"NIST SP 800-63-3": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
|
||||||
|
# --- OWASP (Community-Standards) ---
|
||||||
|
"OWASP API Security Top 10 (2023)": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"OWASP ASVS 4.0": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"OWASP MASVS 2.0": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"OWASP SAMM 2.0": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"OWASP Top 10 (2021)": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
|
||||||
|
# --- Sonstige Frameworks ---
|
||||||
|
"OECD KI-Empfehlung": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
"CISA Secure by Design": SOURCE_TYPE_FRAMEWORK,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_source_regulation(source_regulation: str) -> str:
|
||||||
|
"""
|
||||||
|
Klassifiziert eine source_regulation als law, guideline oder framework.
|
||||||
|
|
||||||
|
Verwendet exaktes Matching gegen die Map. Bei unbekannten Quellen
|
||||||
|
wird anhand von Schluesselwoertern geraten, Fallback ist 'framework'
|
||||||
|
(konservativstes Ergebnis).
|
||||||
|
"""
|
||||||
|
if not source_regulation:
|
||||||
|
return SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
# Exaktes Match
|
||||||
|
if source_regulation in SOURCE_REGULATION_CLASSIFICATION:
|
||||||
|
return SOURCE_REGULATION_CLASSIFICATION[source_regulation]
|
||||||
|
|
||||||
|
# Heuristik fuer unbekannte Quellen
|
||||||
|
lower = source_regulation.lower()
|
||||||
|
|
||||||
|
# Gesetze erkennen
|
||||||
|
law_indicators = [
|
||||||
|
"verordnung", "richtlinie", "gesetz", "directive", "regulation",
|
||||||
|
"(eu)", "(eg)", "act", "ley", "loi", "törvény", "código",
|
||||||
|
]
|
||||||
|
if any(ind in lower for ind in law_indicators):
|
||||||
|
return SOURCE_TYPE_LAW
|
||||||
|
|
||||||
|
# Leitlinien erkennen
|
||||||
|
guideline_indicators = [
|
||||||
|
"edpb", "leitlinie", "guideline", "wp2", "bsi", "empfehlung",
|
||||||
|
]
|
||||||
|
if any(ind in lower for ind in guideline_indicators):
|
||||||
|
return SOURCE_TYPE_GUIDELINE
|
||||||
|
|
||||||
|
# Frameworks erkennen
|
||||||
|
framework_indicators = [
|
||||||
|
"enisa", "nist", "owasp", "oecd", "cisa", "framework", "iso",
|
||||||
|
]
|
||||||
|
if any(ind in lower for ind in framework_indicators):
|
||||||
|
return SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
# Konservativ: unbekannt = framework (geringste Verbindlichkeit)
|
||||||
|
return SOURCE_TYPE_FRAMEWORK
|
||||||
@@ -459,7 +459,9 @@ def _split_compound_action(action: str) -> list[str]:
|
|||||||
# ── 2. Action Type Classification (18 types) ────────────────────────────
|
# ── 2. Action Type Classification (18 types) ────────────────────────────
|
||||||
|
|
||||||
_ACTION_PRIORITY = [
|
_ACTION_PRIORITY = [
|
||||||
|
"prevent", "exclude", "forbid",
|
||||||
"implement", "configure", "encrypt", "restrict_access",
|
"implement", "configure", "encrypt", "restrict_access",
|
||||||
|
"enforce", "invalidate", "issue", "rotate",
|
||||||
"monitor", "review", "assess", "audit",
|
"monitor", "review", "assess", "audit",
|
||||||
"test", "verify", "validate",
|
"test", "verify", "validate",
|
||||||
"report", "notify", "train",
|
"report", "notify", "train",
|
||||||
@@ -470,7 +472,41 @@ _ACTION_PRIORITY = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
_ACTION_KEYWORDS: list[tuple[str, str]] = [
|
_ACTION_KEYWORDS: list[tuple[str, str]] = [
|
||||||
# Multi-word patterns first (longest match wins)
|
# ── Negative / prohibitive actions (highest priority) ────
|
||||||
|
("dürfen keine", "prevent"),
|
||||||
|
("dürfen nicht", "prevent"),
|
||||||
|
("darf keine", "prevent"),
|
||||||
|
("darf nicht", "prevent"),
|
||||||
|
("nicht zulässig", "forbid"),
|
||||||
|
("nicht erlaubt", "forbid"),
|
||||||
|
("nicht gestattet", "forbid"),
|
||||||
|
("untersagt", "forbid"),
|
||||||
|
("verboten", "forbid"),
|
||||||
|
("nicht enthalten", "exclude"),
|
||||||
|
("nicht übertragen", "prevent"),
|
||||||
|
("nicht übermittelt", "prevent"),
|
||||||
|
("nicht wiederverwendet", "prevent"),
|
||||||
|
("nicht gespeichert", "prevent"),
|
||||||
|
("verhindern", "prevent"),
|
||||||
|
("unterbinden", "prevent"),
|
||||||
|
("ausschließen", "exclude"),
|
||||||
|
("vermeiden", "prevent"),
|
||||||
|
("ablehnen", "exclude"),
|
||||||
|
("zurückweisen", "exclude"),
|
||||||
|
# ── Session / lifecycle actions ──────────────────────────
|
||||||
|
("ungültig machen", "invalidate"),
|
||||||
|
("invalidieren", "invalidate"),
|
||||||
|
("widerrufen", "invalidate"),
|
||||||
|
("session beenden", "invalidate"),
|
||||||
|
("vergeben", "issue"),
|
||||||
|
("ausstellen", "issue"),
|
||||||
|
("erzeugen", "issue"),
|
||||||
|
("generieren", "issue"),
|
||||||
|
("rotieren", "rotate"),
|
||||||
|
("erneuern", "rotate"),
|
||||||
|
("durchsetzen", "enforce"),
|
||||||
|
("erzwingen", "enforce"),
|
||||||
|
# ── Multi-word patterns (longest match wins) ─────────────
|
||||||
("aktuell halten", "maintain"),
|
("aktuell halten", "maintain"),
|
||||||
("aufrechterhalten", "maintain"),
|
("aufrechterhalten", "maintain"),
|
||||||
("sicherstellen", "ensure"),
|
("sicherstellen", "ensure"),
|
||||||
@@ -565,6 +601,15 @@ _ACTION_KEYWORDS: list[tuple[str, str]] = [
|
|||||||
("remediate", "remediate"),
|
("remediate", "remediate"),
|
||||||
("perform", "perform"),
|
("perform", "perform"),
|
||||||
("obtain", "obtain"),
|
("obtain", "obtain"),
|
||||||
|
("prevent", "prevent"),
|
||||||
|
("forbid", "forbid"),
|
||||||
|
("exclude", "exclude"),
|
||||||
|
("invalidate", "invalidate"),
|
||||||
|
("revoke", "invalidate"),
|
||||||
|
("issue", "issue"),
|
||||||
|
("generate", "issue"),
|
||||||
|
("rotate", "rotate"),
|
||||||
|
("enforce", "enforce"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -627,11 +672,29 @@ _OBJECT_CLASS_KEYWORDS: dict[str, list[str]] = {
|
|||||||
"access_control": [
|
"access_control": [
|
||||||
"authentifizierung", "autorisierung", "zugriff",
|
"authentifizierung", "autorisierung", "zugriff",
|
||||||
"berechtigung", "passwort", "kennwort", "anmeldung",
|
"berechtigung", "passwort", "kennwort", "anmeldung",
|
||||||
"sso", "rbac", "session",
|
"sso", "rbac",
|
||||||
|
],
|
||||||
|
"session": [
|
||||||
|
"session", "sitzung", "sitzungsverwaltung", "session management",
|
||||||
|
"session-id", "session-token", "idle timeout",
|
||||||
|
"inaktivitäts-timeout", "inaktivitätszeitraum",
|
||||||
|
"logout", "abmeldung",
|
||||||
|
],
|
||||||
|
"cookie": [
|
||||||
|
"cookie", "session-cookie", "secure-flag", "httponly",
|
||||||
|
"samesite", "cookie-attribut",
|
||||||
|
],
|
||||||
|
"jwt": [
|
||||||
|
"jwt", "json web token", "bearer token",
|
||||||
|
"jwt-algorithmus", "jwt-signatur",
|
||||||
|
],
|
||||||
|
"federated_assertion": [
|
||||||
|
"assertion", "saml", "oidc", "openid",
|
||||||
|
"föderiert", "federated", "identity provider",
|
||||||
],
|
],
|
||||||
"cryptographic_control": [
|
"cryptographic_control": [
|
||||||
"schlüssel", "zertifikat", "signatur", "kryptographi",
|
"schlüssel", "zertifikat", "signatur", "kryptographi",
|
||||||
"cipher", "hash", "token",
|
"cipher", "hash", "token", "entropie",
|
||||||
],
|
],
|
||||||
"configuration": [
|
"configuration": [
|
||||||
"konfiguration", "einstellung", "parameter",
|
"konfiguration", "einstellung", "parameter",
|
||||||
@@ -1030,6 +1093,85 @@ _ACTION_TEMPLATES: dict[str, dict[str, list[str]]] = {
|
|||||||
"Gültigkeitsprüfung mit Zeitstempeln",
|
"Gültigkeitsprüfung mit Zeitstempeln",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
# ── Prevent / Exclude / Forbid (negative norms) ────────────
|
||||||
|
"prevent": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung, dass {object} technisch verhindert wird",
|
||||||
|
"Stichprobe: Versuch der verbotenen Aktion schlägt fehl",
|
||||||
|
"Review der Konfiguration und Zugriffskontrollen",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Konfigurationsnachweis der Präventionsmassnahme",
|
||||||
|
"Testprotokoll der Negativtests",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"exclude": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung, dass {object} ausgeschlossen ist",
|
||||||
|
"Stichprobe: Verbotene Inhalte/Aktionen sind nicht vorhanden",
|
||||||
|
"Automatisierter Scan oder manuelle Prüfung",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Scan-Ergebnis oder Prüfprotokoll",
|
||||||
|
"Konfigurationsnachweis",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"forbid": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung, dass {object} untersagt und technisch blockiert ist",
|
||||||
|
"Verifizierung der Richtlinie und technischen Durchsetzung",
|
||||||
|
"Stichprobe: Versuch der untersagten Aktion wird abgelehnt",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Richtlinie mit explizitem Verbot",
|
||||||
|
"Technischer Nachweis der Blockierung",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# ── Enforce / Invalidate / Issue / Rotate ────────────────
|
||||||
|
"enforce": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung der technischen Durchsetzung von {object}",
|
||||||
|
"Stichprobe: Nicht-konforme Konfigurationen werden automatisch korrigiert oder abgelehnt",
|
||||||
|
"Review der Enforcement-Regeln und Ausnahmen",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Enforcement-Policy mit technischer Umsetzung",
|
||||||
|
"Protokoll erzwungener Korrekturen oder Ablehnungen",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"invalidate": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung, dass {object} korrekt ungültig gemacht wird",
|
||||||
|
"Stichprobe: Nach Invalidierung kein Zugriff mehr möglich",
|
||||||
|
"Verifizierung der serverseitigen Bereinigung",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Protokoll der Invalidierungsaktionen",
|
||||||
|
"Testnachweis der Zugriffsverweigerung nach Invalidierung",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"issue": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung des Vergabeprozesses für {object}",
|
||||||
|
"Verifizierung der kryptographischen Sicherheit und Entropie",
|
||||||
|
"Stichprobe: Korrekte Vergabe unter definierten Bedingungen",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Prozessdokumentation der Vergabe",
|
||||||
|
"Nachweis der Entropie-/Sicherheitseigenschaften",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"rotate": {
|
||||||
|
"test_procedure": [
|
||||||
|
"Prüfung des Rotationsprozesses für {object}",
|
||||||
|
"Verifizierung der Rotationsfrequenz und automatischen Auslöser",
|
||||||
|
"Stichprobe: Alte Artefakte nach Rotation ungültig",
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Rotationsrichtlinie mit Frequenz",
|
||||||
|
"Rotationsprotokoll mit Zeitstempeln",
|
||||||
|
],
|
||||||
|
},
|
||||||
# ── Approve / Remediate ───────────────────────────────────
|
# ── Approve / Remediate ───────────────────────────────────
|
||||||
"approve": {
|
"approve": {
|
||||||
"test_procedure": [
|
"test_procedure": [
|
||||||
@@ -1415,20 +1557,127 @@ _OBJECT_SYNONYMS: dict[str, str] = {
|
|||||||
"zugriff": "access_control",
|
"zugriff": "access_control",
|
||||||
"einwilligung": "consent",
|
"einwilligung": "consent",
|
||||||
"zustimmung": "consent",
|
"zustimmung": "consent",
|
||||||
|
# Near-synonym expansions found via heavy-control analysis (2026-03-28)
|
||||||
|
"erkennung": "detection",
|
||||||
|
"früherkennung": "detection",
|
||||||
|
"frühzeitige erkennung": "detection",
|
||||||
|
"frühzeitigen erkennung": "detection",
|
||||||
|
"detektion": "detection",
|
||||||
|
"eskalation": "escalation",
|
||||||
|
"eskalationsprozess": "escalation",
|
||||||
|
"eskalationsverfahren": "escalation",
|
||||||
|
"benachrichtigungsprozess": "notification",
|
||||||
|
"benachrichtigungsverfahren": "notification",
|
||||||
|
"meldeprozess": "notification",
|
||||||
|
"meldeverfahren": "notification",
|
||||||
|
"meldesystem": "notification",
|
||||||
|
"benachrichtigungssystem": "notification",
|
||||||
|
"überwachung": "monitoring",
|
||||||
|
"monitoring": "monitoring",
|
||||||
|
"kontinuierliche überwachung": "monitoring",
|
||||||
|
"laufende überwachung": "monitoring",
|
||||||
|
"prüfung": "audit",
|
||||||
|
"überprüfung": "audit",
|
||||||
|
"kontrolle": "control_check",
|
||||||
|
"sicherheitskontrolle": "control_check",
|
||||||
|
"dokumentation": "documentation",
|
||||||
|
"aufzeichnungspflicht": "documentation",
|
||||||
|
"protokollierung": "logging",
|
||||||
|
"logführung": "logging",
|
||||||
|
"logmanagement": "logging",
|
||||||
|
"wiederherstellung": "recovery",
|
||||||
|
"notfallwiederherstellung": "recovery",
|
||||||
|
"disaster recovery": "recovery",
|
||||||
|
"notfallplan": "contingency_plan",
|
||||||
|
"notfallplanung": "contingency_plan",
|
||||||
|
"wiederanlaufplan": "contingency_plan",
|
||||||
|
"klassifizierung": "classification",
|
||||||
|
"kategorisierung": "classification",
|
||||||
|
"einstufung": "classification",
|
||||||
|
"segmentierung": "segmentation",
|
||||||
|
"netzwerksegmentierung": "segmentation",
|
||||||
|
"netzwerk-segmentierung": "segmentation",
|
||||||
|
"trennung": "segmentation",
|
||||||
|
"isolierung": "isolation",
|
||||||
|
"patch": "patch_mgmt",
|
||||||
|
"patchmanagement": "patch_mgmt",
|
||||||
|
"patch-management": "patch_mgmt",
|
||||||
|
"aktualisierung": "patch_mgmt",
|
||||||
|
"softwareaktualisierung": "patch_mgmt",
|
||||||
|
"härtung": "hardening",
|
||||||
|
"systemhärtung": "hardening",
|
||||||
|
"härtungsmaßnahme": "hardening",
|
||||||
|
"löschung": "deletion",
|
||||||
|
"datenlöschung": "deletion",
|
||||||
|
"löschkonzept": "deletion",
|
||||||
|
"anonymisierung": "anonymization",
|
||||||
|
"pseudonymisierung": "pseudonymization",
|
||||||
|
"zugangssteuerung": "access_control",
|
||||||
|
"zugangskontrolle": "access_control",
|
||||||
|
"zugriffssteuerung": "access_control",
|
||||||
|
"zugriffskontrolle": "access_control",
|
||||||
|
"schlüsselmanagement": "key_mgmt",
|
||||||
|
"schlüsselverwaltung": "key_mgmt",
|
||||||
|
"key management": "key_mgmt",
|
||||||
|
"zertifikatsverwaltung": "cert_mgmt",
|
||||||
|
"zertifikatsmanagement": "cert_mgmt",
|
||||||
|
"lieferant": "vendor",
|
||||||
|
"dienstleister": "vendor",
|
||||||
|
"auftragsverarbeiter": "vendor",
|
||||||
|
"drittanbieter": "vendor",
|
||||||
|
# Session management synonyms (2026-03-28)
|
||||||
|
"sitzung": "session",
|
||||||
|
"sitzungsverwaltung": "session_mgmt",
|
||||||
|
"session management": "session_mgmt",
|
||||||
|
"session-id": "session_token",
|
||||||
|
"sitzungstoken": "session_token",
|
||||||
|
"session-token": "session_token",
|
||||||
|
"idle timeout": "session_timeout",
|
||||||
|
"inaktivitäts-timeout": "session_timeout",
|
||||||
|
"inaktivitätszeitraum": "session_timeout",
|
||||||
|
"abmeldung": "logout",
|
||||||
|
"cookie-attribut": "cookie_security",
|
||||||
|
"secure-flag": "cookie_security",
|
||||||
|
"httponly": "cookie_security",
|
||||||
|
"samesite": "cookie_security",
|
||||||
|
"json web token": "jwt",
|
||||||
|
"bearer token": "jwt",
|
||||||
|
"föderierte assertion": "federated_assertion",
|
||||||
|
"saml assertion": "federated_assertion",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_title(title: str, max_len: int = 80) -> str:
|
||||||
|
"""Truncate title at word boundary to avoid mid-word cuts."""
|
||||||
|
if len(title) <= max_len:
|
||||||
|
return title
|
||||||
|
truncated = title[:max_len]
|
||||||
|
# Cut at last space to avoid mid-word truncation
|
||||||
|
last_space = truncated.rfind(" ")
|
||||||
|
if last_space > max_len // 2:
|
||||||
|
return truncated[:last_space]
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
def _normalize_object(object_raw: str) -> str:
|
def _normalize_object(object_raw: str) -> str:
|
||||||
"""Normalize object text to a snake_case key for merge hints.
|
"""Normalize object text to a snake_case key for merge hints.
|
||||||
|
|
||||||
Applies synonym mapping to collapse German terms to canonical forms
|
Applies synonym mapping to collapse German terms to canonical forms
|
||||||
(e.g., 'Richtlinie' -> 'policy', 'Verzeichnis' -> 'register').
|
(e.g., 'Richtlinie' -> 'policy', 'Verzeichnis' -> 'register').
|
||||||
|
Then strips qualifying prepositional phrases that would create
|
||||||
|
near-duplicate keys (e.g., 'bei Schwellenwertüberschreitung').
|
||||||
|
Truncates to 40 chars to collapse overly specific variants.
|
||||||
"""
|
"""
|
||||||
if not object_raw:
|
if not object_raw:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
obj_lower = object_raw.strip().lower()
|
obj_lower = object_raw.strip().lower()
|
||||||
|
|
||||||
|
# Strip qualifying prepositional phrases that don't change core identity.
|
||||||
|
# These create near-duplicate keys like "eskalationsprozess" vs
|
||||||
|
# "eskalationsprozess bei schwellenwertüberschreitung".
|
||||||
|
obj_lower = _QUALIFYING_PHRASE_RE.sub("", obj_lower).strip()
|
||||||
|
|
||||||
# Synonym mapping — find the longest matching synonym
|
# Synonym mapping — find the longest matching synonym
|
||||||
best_match = ""
|
best_match = ""
|
||||||
best_canonical = ""
|
best_canonical = ""
|
||||||
@@ -1444,7 +1693,54 @@ def _normalize_object(object_raw: str) -> str:
|
|||||||
for src, dst in [("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")]:
|
for src, dst in [("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")]:
|
||||||
obj = obj.replace(src, dst)
|
obj = obj.replace(src, dst)
|
||||||
obj = re.sub(r"[^a-z0-9_]", "", obj)
|
obj = re.sub(r"[^a-z0-9_]", "", obj)
|
||||||
return obj[:80] or "unknown"
|
|
||||||
|
# Strip trailing noise tokens (articles/prepositions stuck at the end)
|
||||||
|
obj = re.sub(r"(_(?:der|die|das|des|dem|den|fuer|bei|von|zur|zum|mit|auf|in|und|oder|aus|an|ueber|nach|gegen|unter|vor|zwischen|als|durch|ohne|wie))+$", "", obj)
|
||||||
|
|
||||||
|
# Truncate at 40 chars (at underscore boundary) to collapse
|
||||||
|
# overly specific suffixes that create near-duplicate keys.
|
||||||
|
obj = _truncate_at_boundary(obj, 40)
|
||||||
|
|
||||||
|
return obj or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# Regex to strip German qualifying prepositional phrases from object text.
|
||||||
|
# Matches patterns like "bei schwellenwertüberschreitung",
|
||||||
|
# "für kritische systeme", "gemäß artikel 32" etc.
|
||||||
|
_QUALIFYING_PHRASE_RE = re.compile(
|
||||||
|
r"\s+(?:"
|
||||||
|
r"bei\s+\w+"
|
||||||
|
r"|für\s+(?:die\s+|den\s+|das\s+|kritische\s+)?\w+"
|
||||||
|
r"|gemäß\s+\w+"
|
||||||
|
r"|nach\s+\w+"
|
||||||
|
r"|von\s+\w+"
|
||||||
|
r"|im\s+(?:falle?\s+|rahmen\s+)?\w+"
|
||||||
|
r"|mit\s+(?:den\s+|der\s+|dem\s+)?\w+"
|
||||||
|
r"|auf\s+(?:basis|grundlage)\s+\w+"
|
||||||
|
r"|zur\s+(?:einhaltung|sicherstellung|gewährleistung|vermeidung|erfüllung)\s*\w*"
|
||||||
|
r"|durch\s+(?:den\s+|die\s+|das\s+)?\w+"
|
||||||
|
r"|über\s+(?:den\s+|die\s+|das\s+)?\w+"
|
||||||
|
r"|unter\s+\w+"
|
||||||
|
r"|zwischen\s+\w+"
|
||||||
|
r"|innerhalb\s+\w+"
|
||||||
|
r"|gegenüber\s+\w+"
|
||||||
|
r"|hinsichtlich\s+\w+"
|
||||||
|
r"|bezüglich\s+\w+"
|
||||||
|
r"|einschließlich\s+\w+"
|
||||||
|
r").*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_at_boundary(text: str, max_len: int) -> str:
|
||||||
|
"""Truncate text at the last underscore boundary within max_len."""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
truncated = text[:max_len]
|
||||||
|
last_sep = truncated.rfind("_")
|
||||||
|
if last_sep > max_len // 2:
|
||||||
|
return truncated[:last_sep]
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
# ── 7b. Framework / Composite Detection ──────────────────────────────────
|
# ── 7b. Framework / Composite Detection ──────────────────────────────────
|
||||||
@@ -1461,11 +1757,33 @@ _COMPOSITE_OBJECT_KEYWORDS: list[str] = [
|
|||||||
"soc 2", "soc2", "enisa", "kritis",
|
"soc 2", "soc2", "enisa", "kritis",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Container objects that are too broad for atomic controls.
|
||||||
|
# These produce titles like "Sichere Sitzungsverwaltung umgesetzt" which
|
||||||
|
# are not auditable — they encompass multiple sub-requirements.
|
||||||
|
_CONTAINER_OBJECT_KEYWORDS: list[str] = [
|
||||||
|
"sitzungsverwaltung", "session management", "session-management",
|
||||||
|
"token-schutz", "tokenschutz",
|
||||||
|
"authentifizierungsmechanismen", "authentifizierungsmechanismus",
|
||||||
|
"sicherheitsmaßnahmen", "sicherheitsmassnahmen",
|
||||||
|
"schutzmaßnahmen", "schutzmassnahmen",
|
||||||
|
"zugriffskontrollmechanismen",
|
||||||
|
"sicherheitsarchitektur",
|
||||||
|
"sicherheitskontrollen",
|
||||||
|
"datenschutzmaßnahmen", "datenschutzmassnahmen",
|
||||||
|
"compliance-anforderungen",
|
||||||
|
"risikomanagementprozess",
|
||||||
|
]
|
||||||
|
|
||||||
_COMPOSITE_RE = re.compile(
|
_COMPOSITE_RE = re.compile(
|
||||||
"|".join(_FRAMEWORK_KEYWORDS + _COMPOSITE_OBJECT_KEYWORDS),
|
"|".join(_FRAMEWORK_KEYWORDS + _COMPOSITE_OBJECT_KEYWORDS),
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_CONTAINER_RE = re.compile(
|
||||||
|
"|".join(_CONTAINER_OBJECT_KEYWORDS),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_composite_obligation(obligation_text: str, object_: str) -> bool:
|
def _is_composite_obligation(obligation_text: str, object_: str) -> bool:
|
||||||
"""Detect framework-level / composite obligations that are NOT atomic.
|
"""Detect framework-level / composite obligations that are NOT atomic.
|
||||||
@@ -1477,6 +1795,17 @@ def _is_composite_obligation(obligation_text: str, object_: str) -> bool:
|
|||||||
return bool(_COMPOSITE_RE.search(combined))
|
return bool(_COMPOSITE_RE.search(combined))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_container_object(object_: str) -> bool:
|
||||||
|
"""Detect overly broad container objects that should not be atomic.
|
||||||
|
|
||||||
|
Objects like 'Sitzungsverwaltung' or 'Token-Schutz' encompass multiple
|
||||||
|
sub-requirements and produce non-auditable controls.
|
||||||
|
"""
|
||||||
|
if not object_:
|
||||||
|
return False
|
||||||
|
return bool(_CONTAINER_RE.search(object_))
|
||||||
|
|
||||||
|
|
||||||
# ── 7c. Output Validator (Negativregeln) ─────────────────────────────────
|
# ── 7c. Output Validator (Negativregeln) ─────────────────────────────────
|
||||||
|
|
||||||
def _validate_atomic_control(
|
def _validate_atomic_control(
|
||||||
@@ -1613,11 +1942,11 @@ def _compose_deterministic(
|
|||||||
# ── Title: "{Object} {Zustand}" ───────────────────────────
|
# ── Title: "{Object} {Zustand}" ───────────────────────────
|
||||||
state = _ACTION_STATE_SUFFIX.get(action_type, "umgesetzt")
|
state = _ACTION_STATE_SUFFIX.get(action_type, "umgesetzt")
|
||||||
if object_:
|
if object_:
|
||||||
title = f"{object_.strip()} {state}"[:80]
|
title = _truncate_title(f"{object_.strip()} {state}")
|
||||||
elif action:
|
elif action:
|
||||||
title = f"{action.strip().capitalize()} {state}"[:80]
|
title = _truncate_title(f"{action.strip().capitalize()} {state}")
|
||||||
else:
|
else:
|
||||||
title = f"{parent_title} {state}"[:80]
|
title = _truncate_title(f"{parent_title} {state}")
|
||||||
|
|
||||||
# ── Objective = obligation text (the normative statement) ─
|
# ── Objective = obligation text (the normative statement) ─
|
||||||
objective = obligation_text.strip()[:2000]
|
objective = obligation_text.strip()[:2000]
|
||||||
@@ -1678,7 +2007,7 @@ def _compose_deterministic(
|
|||||||
requirements=requirements,
|
requirements=requirements,
|
||||||
test_procedure=test_procedure,
|
test_procedure=test_procedure,
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
severity=_normalize_severity(parent_severity),
|
severity=_calibrate_severity(parent_severity, action_type),
|
||||||
category=parent_category or "governance",
|
category=parent_category or "governance",
|
||||||
)
|
)
|
||||||
# Attach extra metadata (stored in generation_metadata)
|
# Attach extra metadata (stored in generation_metadata)
|
||||||
@@ -1690,11 +2019,17 @@ def _compose_deterministic(
|
|||||||
atomic._deadline_hours = deadline_hours # type: ignore[attr-defined]
|
atomic._deadline_hours = deadline_hours # type: ignore[attr-defined]
|
||||||
atomic._frequency = frequency # type: ignore[attr-defined]
|
atomic._frequency = frequency # type: ignore[attr-defined]
|
||||||
|
|
||||||
# ── Composite / Framework detection ───────────────────────
|
# ── Composite / Framework / Container detection ────────────
|
||||||
is_composite = _is_composite_obligation(obligation_text, object_)
|
is_composite = _is_composite_obligation(obligation_text, object_)
|
||||||
atomic._is_composite = is_composite # type: ignore[attr-defined]
|
is_container = _is_container_object(object_)
|
||||||
atomic._atomicity = "composite" if is_composite else "atomic" # type: ignore[attr-defined]
|
atomic._is_composite = is_composite or is_container # type: ignore[attr-defined]
|
||||||
atomic._requires_decomposition = is_composite # type: ignore[attr-defined]
|
if is_composite:
|
||||||
|
atomic._atomicity = "composite" # type: ignore[attr-defined]
|
||||||
|
elif is_container:
|
||||||
|
atomic._atomicity = "container" # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
atomic._atomicity = "atomic" # type: ignore[attr-defined]
|
||||||
|
atomic._requires_decomposition = is_composite or is_container # type: ignore[attr-defined]
|
||||||
|
|
||||||
# ── Validate (log issues, never reject) ───────────────────
|
# ── Validate (log issues, never reject) ───────────────────
|
||||||
validation_issues = _validate_atomic_control(atomic, action_type, object_class)
|
validation_issues = _validate_atomic_control(atomic, action_type, object_class)
|
||||||
@@ -2315,6 +2650,7 @@ class DecompositionPass:
|
|||||||
SELECT 1 FROM canonical_controls ac
|
SELECT 1 FROM canonical_controls ac
|
||||||
WHERE ac.parent_control_uuid = oc.parent_control_uuid
|
WHERE ac.parent_control_uuid = oc.parent_control_uuid
|
||||||
AND ac.decomposition_method = 'pass0b'
|
AND ac.decomposition_method = 'pass0b'
|
||||||
|
AND ac.release_state NOT IN ('deprecated', 'duplicate')
|
||||||
AND ac.title LIKE '%' || LEFT(oc.action, 20) || '%'
|
AND ac.title LIKE '%' || LEFT(oc.action, 20) || '%'
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@@ -2877,10 +3213,31 @@ class DecompositionPass:
|
|||||||
"""Insert an atomic control and create parent link.
|
"""Insert an atomic control and create parent link.
|
||||||
|
|
||||||
Returns the UUID of the newly created control, or None on failure.
|
Returns the UUID of the newly created control, or None on failure.
|
||||||
|
Checks merge_hint to prevent duplicate controls under the same parent.
|
||||||
"""
|
"""
|
||||||
parent_uuid = obl["parent_uuid"]
|
parent_uuid = obl["parent_uuid"]
|
||||||
candidate_id = obl["candidate_id"]
|
candidate_id = obl["candidate_id"]
|
||||||
|
|
||||||
|
# ── Duplicate Guard: skip if same merge_hint already exists ──
|
||||||
|
merge_hint = getattr(atomic, "source_regulation", "") or ""
|
||||||
|
if merge_hint:
|
||||||
|
existing = self.db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id::text FROM canonical_controls
|
||||||
|
WHERE parent_control_uuid = CAST(:parent AS uuid)
|
||||||
|
AND generation_metadata->>'merge_group_hint' = :hint
|
||||||
|
AND release_state NOT IN ('rejected', 'deprecated', 'duplicate')
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"parent": parent_uuid, "hint": merge_hint},
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Duplicate guard: skipping %s — merge_hint %s already exists as %s",
|
||||||
|
candidate_id, merge_hint, existing[0],
|
||||||
|
)
|
||||||
|
return existing[0]
|
||||||
|
|
||||||
result = self.db.execute(
|
result = self.db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO canonical_controls (
|
INSERT INTO canonical_controls (
|
||||||
@@ -3135,6 +3492,7 @@ class DecompositionPass:
|
|||||||
SELECT 1 FROM canonical_controls ac
|
SELECT 1 FROM canonical_controls ac
|
||||||
WHERE ac.parent_control_uuid = oc.parent_control_uuid
|
WHERE ac.parent_control_uuid = oc.parent_control_uuid
|
||||||
AND ac.decomposition_method = 'pass0b'
|
AND ac.decomposition_method = 'pass0b'
|
||||||
|
AND ac.release_state NOT IN ('deprecated', 'duplicate')
|
||||||
AND ac.title LIKE '%' || LEFT(oc.action, 20) || '%'
|
AND ac.title LIKE '%' || LEFT(oc.action, 20) || '%'
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
@@ -3475,4 +3833,45 @@ def _normalize_severity(val: str) -> str:
|
|||||||
return "medium"
|
return "medium"
|
||||||
|
|
||||||
|
|
||||||
|
# Action-type-based severity calibration: not every atomic control
|
||||||
|
# inherits the parent's severity. Definition and review controls are
|
||||||
|
# typically medium, while implementation controls stay high.
|
||||||
|
_ACTION_SEVERITY_CAP: dict[str, str] = {
|
||||||
|
"define": "medium",
|
||||||
|
"review": "medium",
|
||||||
|
"document": "medium",
|
||||||
|
"report": "medium",
|
||||||
|
"test": "medium",
|
||||||
|
"implement": "high",
|
||||||
|
"configure": "high",
|
||||||
|
"monitor": "high",
|
||||||
|
"enforce": "high",
|
||||||
|
"prevent": "high",
|
||||||
|
"exclude": "high",
|
||||||
|
"forbid": "high",
|
||||||
|
"invalidate": "high",
|
||||||
|
"issue": "high",
|
||||||
|
"rotate": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Severity ordering for cap comparison
|
||||||
|
_SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrate_severity(parent_severity: str, action_type: str) -> str:
|
||||||
|
"""Calibrate severity based on action type.
|
||||||
|
|
||||||
|
Implementation/enforcement inherits parent severity.
|
||||||
|
Definition/review/test/documentation caps at medium.
|
||||||
|
"""
|
||||||
|
parent = _normalize_severity(parent_severity)
|
||||||
|
cap = _ACTION_SEVERITY_CAP.get(action_type)
|
||||||
|
if not cap:
|
||||||
|
return parent
|
||||||
|
# Return the lower of parent severity and action-type cap
|
||||||
|
if _SEVERITY_ORDER.get(parent, 1) <= _SEVERITY_ORDER.get(cap, 1):
|
||||||
|
return parent
|
||||||
|
return cap
|
||||||
|
|
||||||
|
|
||||||
# _template_fallback removed — replaced by _compose_deterministic engine
|
# _template_fallback removed — replaced by _compose_deterministic engine
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ class LLMProviderType(str, Enum):
|
|||||||
"""Supported LLM provider types."""
|
"""Supported LLM provider types."""
|
||||||
ANTHROPIC = "anthropic"
|
ANTHROPIC = "anthropic"
|
||||||
SELF_HOSTED = "self_hosted"
|
SELF_HOSTED = "self_hosted"
|
||||||
|
OLLAMA = "ollama" # Alias for self_hosted (Ollama-specific)
|
||||||
MOCK = "mock" # For testing
|
MOCK = "mock" # For testing
|
||||||
|
|
||||||
|
|
||||||
@@ -392,6 +393,7 @@ class SelfHostedProvider(LLMProvider):
|
|||||||
"model": self.model,
|
"model": self.model,
|
||||||
"prompt": full_prompt,
|
"prompt": full_prompt,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
|
"think": False, # Disable thinking mode (qwen3.5 etc.)
|
||||||
"options": {}
|
"options": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,7 +551,7 @@ def get_llm_config() -> LLMConfig:
|
|||||||
vault_path="breakpilot/api_keys/anthropic",
|
vault_path="breakpilot/api_keys/anthropic",
|
||||||
env_var="ANTHROPIC_API_KEY"
|
env_var="ANTHROPIC_API_KEY"
|
||||||
)
|
)
|
||||||
elif provider_type == LLMProviderType.SELF_HOSTED:
|
elif provider_type in (LLMProviderType.SELF_HOSTED, LLMProviderType.OLLAMA):
|
||||||
api_key = get_secret_from_vault_or_env(
|
api_key = get_secret_from_vault_or_env(
|
||||||
vault_path="breakpilot/api_keys/self_hosted_llm",
|
vault_path="breakpilot/api_keys/self_hosted_llm",
|
||||||
env_var="SELF_HOSTED_LLM_KEY"
|
env_var="SELF_HOSTED_LLM_KEY"
|
||||||
@@ -558,7 +560,7 @@ def get_llm_config() -> LLMConfig:
|
|||||||
# Select model based on provider type
|
# Select model based on provider type
|
||||||
if provider_type == LLMProviderType.ANTHROPIC:
|
if provider_type == LLMProviderType.ANTHROPIC:
|
||||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")
|
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")
|
||||||
elif provider_type == LLMProviderType.SELF_HOSTED:
|
elif provider_type in (LLMProviderType.SELF_HOSTED, LLMProviderType.OLLAMA):
|
||||||
model = os.getenv("SELF_HOSTED_LLM_MODEL", "qwen2.5:14b")
|
model = os.getenv("SELF_HOSTED_LLM_MODEL", "qwen2.5:14b")
|
||||||
else:
|
else:
|
||||||
model = "mock-model"
|
model = "mock-model"
|
||||||
@@ -591,7 +593,7 @@ def get_llm_provider(config: Optional[LLMConfig] = None) -> LLMProvider:
|
|||||||
return MockProvider(config)
|
return MockProvider(config)
|
||||||
return AnthropicProvider(config)
|
return AnthropicProvider(config)
|
||||||
|
|
||||||
elif config.provider_type == LLMProviderType.SELF_HOSTED:
|
elif config.provider_type in (LLMProviderType.SELF_HOSTED, LLMProviderType.OLLAMA):
|
||||||
if not config.base_url:
|
if not config.base_url:
|
||||||
logger.warning("No self-hosted LLM URL found, using mock provider")
|
logger.warning("No self-hosted LLM URL found, using mock provider")
|
||||||
return MockProvider(config)
|
return MockProvider(config)
|
||||||
|
|||||||
331
backend-compliance/compliance/services/v1_enrichment.py
Normal file
331
backend-compliance/compliance/services/v1_enrichment.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""V1 Control Enrichment Service — Match Eigenentwicklung controls to regulations.
|
||||||
|
|
||||||
|
Finds regulatory coverage for v1 controls (generation_strategy='ungrouped',
|
||||||
|
pipeline_version=1, no source_citation) by embedding similarity search.
|
||||||
|
|
||||||
|
Reuses embedding + Qdrant helpers from control_dedup.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from compliance.services.control_dedup import (
|
||||||
|
get_embedding,
|
||||||
|
qdrant_search_cross_regulation,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Similarity threshold — lower than dedup (0.85) since we want informational matches
|
||||||
|
# Typical top scores for v1 controls are 0.70-0.77
|
||||||
|
V1_MATCH_THRESHOLD = 0.70
|
||||||
|
V1_MAX_MATCHES = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _is_eigenentwicklung_query() -> str:
|
||||||
|
"""SQL WHERE clause identifying v1 Eigenentwicklung controls."""
|
||||||
|
return """
|
||||||
|
generation_strategy = 'ungrouped'
|
||||||
|
AND (pipeline_version = '1' OR pipeline_version IS NULL)
|
||||||
|
AND source_citation IS NULL
|
||||||
|
AND parent_control_uuid IS NULL
|
||||||
|
AND release_state NOT IN ('rejected', 'merged', 'deprecated')
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def count_v1_controls() -> int:
|
||||||
|
"""Count how many v1 Eigenentwicklung controls exist."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.execute(text(f"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE {_is_eigenentwicklung_query()}
|
||||||
|
""")).fetchone()
|
||||||
|
return row.cnt if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_v1_matches(
|
||||||
|
dry_run: bool = True,
|
||||||
|
batch_size: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Find regulatory matches for v1 Eigenentwicklung controls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dry_run: If True, only count — don't write matches.
|
||||||
|
batch_size: Number of v1 controls to process per call.
|
||||||
|
offset: Pagination offset (v1 control index).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Stats dict with counts, sample matches, and pagination info.
|
||||||
|
"""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
# 1. Load v1 controls (paginated)
|
||||||
|
v1_controls = db.execute(text(f"""
|
||||||
|
SELECT id, control_id, title, objective, category
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE {_is_eigenentwicklung_query()}
|
||||||
|
ORDER BY control_id
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""), {"limit": batch_size, "offset": offset}).fetchall()
|
||||||
|
|
||||||
|
# Count total for pagination
|
||||||
|
total_row = db.execute(text(f"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE {_is_eigenentwicklung_query()}
|
||||||
|
""")).fetchone()
|
||||||
|
total_v1 = total_row.cnt if total_row else 0
|
||||||
|
|
||||||
|
if not v1_controls:
|
||||||
|
return {
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"processed": 0,
|
||||||
|
"total_v1": total_v1,
|
||||||
|
"message": "Kein weiterer Batch — alle v1 Controls verarbeitet.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"total_v1": total_v1,
|
||||||
|
"offset": offset,
|
||||||
|
"batch_size": batch_size,
|
||||||
|
"sample_controls": [
|
||||||
|
{
|
||||||
|
"control_id": r.control_id,
|
||||||
|
"title": r.title,
|
||||||
|
"category": r.category,
|
||||||
|
}
|
||||||
|
for r in v1_controls[:20]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Process each v1 control
|
||||||
|
processed = 0
|
||||||
|
matches_inserted = 0
|
||||||
|
errors = []
|
||||||
|
sample_matches = []
|
||||||
|
|
||||||
|
for v1 in v1_controls:
|
||||||
|
try:
|
||||||
|
# Build search text
|
||||||
|
search_text = f"{v1.title} — {v1.objective}"
|
||||||
|
|
||||||
|
# Get embedding
|
||||||
|
embedding = await get_embedding(search_text)
|
||||||
|
if not embedding:
|
||||||
|
errors.append({
|
||||||
|
"control_id": v1.control_id,
|
||||||
|
"error": "Embedding fehlgeschlagen",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Search Qdrant (cross-regulation, no pattern filter)
|
||||||
|
# Collection is atomic_controls_dedup (contains ~51k atomare Controls)
|
||||||
|
results = await qdrant_search_cross_regulation(
|
||||||
|
embedding, top_k=20,
|
||||||
|
collection="atomic_controls_dedup",
|
||||||
|
)
|
||||||
|
|
||||||
|
# For each hit: resolve to a regulatory parent with source_citation.
|
||||||
|
# Atomic controls in Qdrant usually have parent_control_uuid → parent
|
||||||
|
# has the source_citation. We deduplicate by parent to avoid
|
||||||
|
# listing the same regulation multiple times.
|
||||||
|
rank = 0
|
||||||
|
seen_parents: set[str] = set()
|
||||||
|
|
||||||
|
for hit in results:
|
||||||
|
score = hit.get("score", 0)
|
||||||
|
if score < V1_MATCH_THRESHOLD:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = hit.get("payload", {})
|
||||||
|
matched_uuid = payload.get("control_uuid")
|
||||||
|
if not matched_uuid or matched_uuid == str(v1.id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try the matched control itself first, then its parent
|
||||||
|
matched_row = db.execute(text("""
|
||||||
|
SELECT c.id, c.control_id, c.title, c.source_citation,
|
||||||
|
c.severity, c.category, c.parent_control_uuid
|
||||||
|
FROM canonical_controls c
|
||||||
|
WHERE c.id = CAST(:uuid AS uuid)
|
||||||
|
"""), {"uuid": matched_uuid}).fetchone()
|
||||||
|
|
||||||
|
if not matched_row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve to regulatory control (one with source_citation)
|
||||||
|
reg_row = matched_row
|
||||||
|
if not reg_row.source_citation and reg_row.parent_control_uuid:
|
||||||
|
# Look up parent — the parent has the source_citation
|
||||||
|
parent_row = db.execute(text("""
|
||||||
|
SELECT id, control_id, title, source_citation,
|
||||||
|
severity, category, parent_control_uuid
|
||||||
|
FROM canonical_controls
|
||||||
|
WHERE id = CAST(:uuid AS uuid)
|
||||||
|
AND source_citation IS NOT NULL
|
||||||
|
"""), {"uuid": str(reg_row.parent_control_uuid)}).fetchone()
|
||||||
|
if parent_row:
|
||||||
|
reg_row = parent_row
|
||||||
|
|
||||||
|
if not reg_row.source_citation:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Deduplicate by parent UUID
|
||||||
|
parent_key = str(reg_row.id)
|
||||||
|
if parent_key in seen_parents:
|
||||||
|
continue
|
||||||
|
seen_parents.add(parent_key)
|
||||||
|
|
||||||
|
rank += 1
|
||||||
|
if rank > V1_MAX_MATCHES:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract source info
|
||||||
|
source_citation = reg_row.source_citation or {}
|
||||||
|
matched_source = source_citation.get("source") if isinstance(source_citation, dict) else None
|
||||||
|
matched_article = source_citation.get("article") if isinstance(source_citation, dict) else None
|
||||||
|
|
||||||
|
# Insert match — link to the regulatory parent (not the atomic child)
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO v1_control_matches
|
||||||
|
(v1_control_uuid, matched_control_uuid, similarity_score,
|
||||||
|
match_rank, matched_source, matched_article, match_method)
|
||||||
|
VALUES
|
||||||
|
(CAST(:v1_uuid AS uuid), CAST(:matched_uuid AS uuid), :score,
|
||||||
|
:rank, :source, :article, 'embedding')
|
||||||
|
ON CONFLICT (v1_control_uuid, matched_control_uuid) DO UPDATE
|
||||||
|
SET similarity_score = EXCLUDED.similarity_score,
|
||||||
|
match_rank = EXCLUDED.match_rank
|
||||||
|
"""), {
|
||||||
|
"v1_uuid": str(v1.id),
|
||||||
|
"matched_uuid": str(reg_row.id),
|
||||||
|
"score": round(score, 3),
|
||||||
|
"rank": rank,
|
||||||
|
"source": matched_source,
|
||||||
|
"article": matched_article,
|
||||||
|
})
|
||||||
|
matches_inserted += 1
|
||||||
|
|
||||||
|
# Collect sample
|
||||||
|
if len(sample_matches) < 20:
|
||||||
|
sample_matches.append({
|
||||||
|
"v1_control_id": v1.control_id,
|
||||||
|
"v1_title": v1.title,
|
||||||
|
"matched_control_id": reg_row.control_id,
|
||||||
|
"matched_title": reg_row.title,
|
||||||
|
"matched_source": matched_source,
|
||||||
|
"matched_article": matched_article,
|
||||||
|
"similarity_score": round(score, 3),
|
||||||
|
"match_rank": rank,
|
||||||
|
})
|
||||||
|
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("V1 enrichment error for %s: %s", v1.control_id, e)
|
||||||
|
errors.append({
|
||||||
|
"control_id": v1.control_id,
|
||||||
|
"error": str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
next_offset = offset + batch_size if len(v1_controls) == batch_size else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": False,
|
||||||
|
"offset": offset,
|
||||||
|
"batch_size": batch_size,
|
||||||
|
"next_offset": next_offset,
|
||||||
|
"total_v1": total_v1,
|
||||||
|
"processed": processed,
|
||||||
|
"matches_inserted": matches_inserted,
|
||||||
|
"errors": errors[:10],
|
||||||
|
"sample_matches": sample_matches,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_v1_matches(control_uuid: str) -> list[dict]:
|
||||||
|
"""Get all regulatory matches for a specific v1 control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
control_uuid: The UUID of the v1 control.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of match dicts with control details.
|
||||||
|
"""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
m.similarity_score,
|
||||||
|
m.match_rank,
|
||||||
|
m.matched_source,
|
||||||
|
m.matched_article,
|
||||||
|
m.match_method,
|
||||||
|
c.control_id AS matched_control_id,
|
||||||
|
c.title AS matched_title,
|
||||||
|
c.objective AS matched_objective,
|
||||||
|
c.severity AS matched_severity,
|
||||||
|
c.category AS matched_category,
|
||||||
|
c.source_citation AS matched_source_citation
|
||||||
|
FROM v1_control_matches m
|
||||||
|
JOIN canonical_controls c ON c.id = m.matched_control_uuid
|
||||||
|
WHERE m.v1_control_uuid = CAST(:uuid AS uuid)
|
||||||
|
ORDER BY m.match_rank
|
||||||
|
"""), {"uuid": control_uuid}).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"matched_control_id": r.matched_control_id,
|
||||||
|
"matched_title": r.matched_title,
|
||||||
|
"matched_objective": r.matched_objective,
|
||||||
|
"matched_severity": r.matched_severity,
|
||||||
|
"matched_category": r.matched_category,
|
||||||
|
"matched_source": r.matched_source,
|
||||||
|
"matched_article": r.matched_article,
|
||||||
|
"matched_source_citation": r.matched_source_citation,
|
||||||
|
"similarity_score": float(r.similarity_score),
|
||||||
|
"match_rank": r.match_rank,
|
||||||
|
"match_method": r.match_method,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_v1_enrichment_stats() -> dict:
|
||||||
|
"""Get overview stats for v1 enrichment."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
total_v1 = db.execute(text(f"""
|
||||||
|
SELECT COUNT(*) AS cnt FROM canonical_controls
|
||||||
|
WHERE {_is_eigenentwicklung_query()}
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
matched_v1 = db.execute(text(f"""
|
||||||
|
SELECT COUNT(DISTINCT m.v1_control_uuid) AS cnt
|
||||||
|
FROM v1_control_matches m
|
||||||
|
JOIN canonical_controls c ON c.id = m.v1_control_uuid
|
||||||
|
WHERE {_is_eigenentwicklung_query().replace('release_state', 'c.release_state').replace('generation_strategy', 'c.generation_strategy').replace('pipeline_version', 'c.pipeline_version').replace('source_citation', 'c.source_citation').replace('parent_control_uuid', 'c.parent_control_uuid')}
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
total_matches = db.execute(text("""
|
||||||
|
SELECT COUNT(*) AS cnt FROM v1_control_matches
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
avg_score = db.execute(text("""
|
||||||
|
SELECT AVG(similarity_score) AS avg_score FROM v1_control_matches
|
||||||
|
""")).fetchone()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_v1_controls": total_v1.cnt if total_v1 else 0,
|
||||||
|
"v1_with_matches": matched_v1.cnt if matched_v1 else 0,
|
||||||
|
"v1_without_matches": (total_v1.cnt if total_v1 else 0) - (matched_v1.cnt if matched_v1 else 0),
|
||||||
|
"total_matches": total_matches.cnt if total_matches else 0,
|
||||||
|
"avg_similarity_score": round(float(avg_score.avg_score), 3) if avg_score and avg_score.avg_score else None,
|
||||||
|
}
|
||||||
16
backend-compliance/migrations/079_evidence_type.sql
Normal file
16
backend-compliance/migrations/079_evidence_type.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration 079: Add evidence_type to canonical_controls
|
||||||
|
-- Classifies HOW a control is evidenced:
|
||||||
|
-- code = Technical control, verifiable in source code / IaC / CI-CD
|
||||||
|
-- process = Organizational / governance control, verified via documents / policies
|
||||||
|
-- hybrid = Both code and process evidence required
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'compliance' AND table_name = 'canonical_controls') THEN
|
||||||
|
ALTER TABLE canonical_controls ADD COLUMN IF NOT EXISTS
|
||||||
|
evidence_type VARCHAR(20) DEFAULT NULL
|
||||||
|
CHECK (evidence_type IN ('code', 'process', 'hybrid'));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cc_evidence_type ON canonical_controls(evidence_type);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
18
backend-compliance/migrations/080_v1_control_matches.sql
Normal file
18
backend-compliance/migrations/080_v1_control_matches.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- V1 Control Enrichment: Cross-reference table for matching
|
||||||
|
-- Eigenentwicklung (v1, ungrouped, no source) → regulatorische Controls
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS v1_control_matches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
v1_control_uuid UUID NOT NULL REFERENCES canonical_controls(id) ON DELETE CASCADE,
|
||||||
|
matched_control_uuid UUID NOT NULL REFERENCES canonical_controls(id) ON DELETE CASCADE,
|
||||||
|
similarity_score NUMERIC(4,3) NOT NULL,
|
||||||
|
match_rank SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
matched_source TEXT, -- e.g. "DSGVO (EU) 2016/679"
|
||||||
|
matched_article TEXT, -- e.g. "Art. 32"
|
||||||
|
match_method VARCHAR(30) NOT NULL DEFAULT 'embedding',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_v1_match UNIQUE (v1_control_uuid, matched_control_uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_v1m_v1 ON v1_control_matches(v1_control_uuid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_v1m_matched ON v1_control_matches(matched_control_uuid);
|
||||||
11
backend-compliance/migrations/081_obligation_dedup_state.sql
Normal file
11
backend-compliance/migrations/081_obligation_dedup_state.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration 081: Add 'duplicate' release_state for obligation deduplication
|
||||||
|
--
|
||||||
|
-- Allows marking duplicate obligation_candidates as 'duplicate' instead of
|
||||||
|
-- deleting them, preserving traceability via merged_into_id.
|
||||||
|
|
||||||
|
ALTER TABLE obligation_candidates
|
||||||
|
DROP CONSTRAINT IF EXISTS obligation_candidates_release_state_check;
|
||||||
|
|
||||||
|
ALTER TABLE obligation_candidates
|
||||||
|
ADD CONSTRAINT obligation_candidates_release_state_check
|
||||||
|
CHECK (release_state IN ('extracted', 'validated', 'rejected', 'composed', 'merged', 'duplicate'));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Widen source_article and source_regulation to TEXT to handle long NIST references
|
||||||
|
-- e.g. "SC-22 (und weitere redaktionelle Änderungen SC-7, SC-14, SC-17, ...)"
|
||||||
|
ALTER TABLE control_parent_links ALTER COLUMN source_article TYPE TEXT;
|
||||||
|
ALTER TABLE control_parent_links ALTER COLUMN source_regulation TYPE TEXT;
|
||||||
20
backend-compliance/migrations/083_ai_act_decision_tree.sql
Normal file
20
backend-compliance/migrations/083_ai_act_decision_tree.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration 083: AI Act Decision Tree Results
|
||||||
|
-- Stores results from the two-axis AI Act classification (High-Risk + GPAI)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_act_decision_tree_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
project_id UUID,
|
||||||
|
system_name VARCHAR(500) NOT NULL,
|
||||||
|
system_description TEXT,
|
||||||
|
answers JSONB NOT NULL DEFAULT '{}',
|
||||||
|
high_risk_level VARCHAR(50) NOT NULL DEFAULT 'not_applicable',
|
||||||
|
gpai_result JSONB NOT NULL DEFAULT '{}',
|
||||||
|
combined_obligations JSONB DEFAULT '[]',
|
||||||
|
applicable_articles JSONB DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_act_dt_tenant ON ai_act_decision_tree_results(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_act_dt_project ON ai_act_decision_tree_results(project_id) WHERE project_id IS NOT NULL;
|
||||||
@@ -443,18 +443,105 @@ class TestControlsMeta:
|
|||||||
db.__enter__ = MagicMock(return_value=db)
|
db.__enter__ = MagicMock(return_value=db)
|
||||||
db.__exit__ = MagicMock(return_value=False)
|
db.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
# 4 sequential execute() calls
|
# Faceted meta does many execute() calls — use a default mock
|
||||||
total_r = MagicMock(); total_r.scalar.return_value = 100
|
scalar_r = MagicMock()
|
||||||
domain_r = MagicMock(); domain_r.fetchall.return_value = []
|
scalar_r.scalar.return_value = 100
|
||||||
source_r = MagicMock(); source_r.fetchall.return_value = []
|
scalar_r.fetchall.return_value = []
|
||||||
nosrc_r = MagicMock(); nosrc_r.scalar.return_value = 20
|
db.execute.return_value = scalar_r
|
||||||
db.execute.side_effect = [total_r, domain_r, source_r, nosrc_r]
|
|
||||||
mock_cls.return_value = db
|
mock_cls.return_value = db
|
||||||
|
|
||||||
resp = _client.get("/api/compliance/v1/canonical/controls-meta")
|
resp = _client.get("/api/compliance/v1/canonical/controls-meta")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["total"] == 100
|
assert data["total"] == 100
|
||||||
assert data["no_source_count"] == 20
|
|
||||||
assert isinstance(data["domains"], list)
|
assert isinstance(data["domains"], list)
|
||||||
assert isinstance(data["sources"], list)
|
assert isinstance(data["sources"], list)
|
||||||
|
assert "type_counts" in data
|
||||||
|
assert "severity_counts" in data
|
||||||
|
assert "verification_method_counts" in data
|
||||||
|
assert "category_counts" in data
|
||||||
|
assert "evidence_type_counts" in data
|
||||||
|
assert "release_state_counts" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestObligationDedup:
|
||||||
|
"""Tests for obligation deduplication endpoints."""
|
||||||
|
|
||||||
|
@patch("compliance.api.canonical_control_routes.SessionLocal")
|
||||||
|
def test_dedup_dry_run(self, mock_cls):
|
||||||
|
db = MagicMock()
|
||||||
|
db.__enter__ = MagicMock(return_value=db)
|
||||||
|
db.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_cls.return_value = db
|
||||||
|
|
||||||
|
# Mock: 2 duplicate groups
|
||||||
|
dup_row1 = MagicMock(candidate_id="OC-AUTH-001-01", cnt=3)
|
||||||
|
dup_row2 = MagicMock(candidate_id="OC-AUTH-001-02", cnt=2)
|
||||||
|
|
||||||
|
# Entries for group 1
|
||||||
|
import uuid
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
uid3 = uuid.uuid4()
|
||||||
|
entry1 = MagicMock(id=uid1, candidate_id="OC-AUTH-001-01", obligation_text="Text A", release_state="composed", created_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||||
|
entry2 = MagicMock(id=uid2, candidate_id="OC-AUTH-001-01", obligation_text="Text B", release_state="composed", created_at=datetime(2026, 1, 2, tzinfo=timezone.utc))
|
||||||
|
entry3 = MagicMock(id=uid3, candidate_id="OC-AUTH-001-01", obligation_text="Text C", release_state="composed", created_at=datetime(2026, 1, 3, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# Entries for group 2
|
||||||
|
uid4 = uuid.uuid4()
|
||||||
|
uid5 = uuid.uuid4()
|
||||||
|
entry4 = MagicMock(id=uid4, candidate_id="OC-AUTH-001-02", obligation_text="Text D", release_state="composed", created_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||||
|
entry5 = MagicMock(id=uid5, candidate_id="OC-AUTH-001-02", obligation_text="Text E", release_state="composed", created_at=datetime(2026, 1, 2, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# Side effects: 1) dup groups, 2) total count, 3) entries grp1, 4) entries grp2
|
||||||
|
mock_result_groups = MagicMock()
|
||||||
|
mock_result_groups.fetchall.return_value = [dup_row1, dup_row2]
|
||||||
|
mock_result_total = MagicMock()
|
||||||
|
mock_result_total.scalar.return_value = 2
|
||||||
|
mock_result_entries1 = MagicMock()
|
||||||
|
mock_result_entries1.fetchall.return_value = [entry1, entry2, entry3]
|
||||||
|
mock_result_entries2 = MagicMock()
|
||||||
|
mock_result_entries2.fetchall.return_value = [entry4, entry5]
|
||||||
|
|
||||||
|
db.execute.side_effect = [mock_result_groups, mock_result_total, mock_result_entries1, mock_result_entries2]
|
||||||
|
|
||||||
|
resp = _client.post("/api/compliance/v1/canonical/obligations/dedup?dry_run=true")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["dry_run"] is True
|
||||||
|
assert data["stats"]["total_duplicate_groups"] == 2
|
||||||
|
assert data["stats"]["kept"] == 2
|
||||||
|
assert data["stats"]["marked_duplicate"] == 3 # 2 from grp1 + 1 from grp2
|
||||||
|
# Dry run: no commit
|
||||||
|
db.commit.assert_not_called()
|
||||||
|
|
||||||
|
@patch("compliance.api.canonical_control_routes.SessionLocal")
|
||||||
|
def test_dedup_stats(self, mock_cls):
|
||||||
|
db = MagicMock()
|
||||||
|
db.__enter__ = MagicMock(return_value=db)
|
||||||
|
db.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_cls.return_value = db
|
||||||
|
|
||||||
|
# total, by_state, dup_groups, removable
|
||||||
|
mock_total = MagicMock()
|
||||||
|
mock_total.scalar.return_value = 76046
|
||||||
|
mock_states = MagicMock()
|
||||||
|
mock_states.fetchall.return_value = [
|
||||||
|
MagicMock(release_state="composed", cnt=41217),
|
||||||
|
MagicMock(release_state="duplicate", cnt=34829),
|
||||||
|
]
|
||||||
|
mock_dup_groups = MagicMock()
|
||||||
|
mock_dup_groups.scalar.return_value = 0
|
||||||
|
mock_removable = MagicMock()
|
||||||
|
mock_removable.scalar.return_value = 0
|
||||||
|
|
||||||
|
db.execute.side_effect = [mock_total, mock_states, mock_dup_groups, mock_removable]
|
||||||
|
|
||||||
|
resp = _client.get("/api/compliance/v1/canonical/obligations/dedup-stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_obligations"] == 76046
|
||||||
|
assert data["by_state"]["composed"] == 41217
|
||||||
|
assert data["by_state"]["duplicate"] == 34829
|
||||||
|
assert data["pending_duplicate_groups"] == 0
|
||||||
|
assert data["pending_removable_duplicates"] == 0
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ from compliance.services.decomposition_pass import (
|
|||||||
_format_citation,
|
_format_citation,
|
||||||
_compute_extraction_confidence,
|
_compute_extraction_confidence,
|
||||||
_normalize_severity,
|
_normalize_severity,
|
||||||
|
_calibrate_severity,
|
||||||
|
_truncate_title,
|
||||||
_compose_deterministic,
|
_compose_deterministic,
|
||||||
_classify_action,
|
_classify_action,
|
||||||
_classify_object,
|
_classify_object,
|
||||||
@@ -63,6 +65,9 @@ from compliance.services.decomposition_pass import (
|
|||||||
_PATTERN_CANDIDATES_MAP,
|
_PATTERN_CANDIDATES_MAP,
|
||||||
_PATTERN_CANDIDATES_BY_ACTION,
|
_PATTERN_CANDIDATES_BY_ACTION,
|
||||||
_is_composite_obligation,
|
_is_composite_obligation,
|
||||||
|
_is_container_object,
|
||||||
|
_ACTION_TEMPLATES,
|
||||||
|
_ACTION_SEVERITY_CAP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -704,7 +709,8 @@ class TestComposeDeterministic:
|
|||||||
# Object placeholder should use parent_title
|
# Object placeholder should use parent_title
|
||||||
assert "System Security" in ac.test_procedure[0]
|
assert "System Security" in ac.test_procedure[0]
|
||||||
|
|
||||||
def test_severity_inherited(self):
|
def test_severity_calibrated(self):
|
||||||
|
# implement caps at high — critical is reserved for parent-level controls
|
||||||
ac = _compose_deterministic(
|
ac = _compose_deterministic(
|
||||||
obligation_text="Kritische Pflicht",
|
obligation_text="Kritische Pflicht",
|
||||||
action="implementieren",
|
action="implementieren",
|
||||||
@@ -715,7 +721,7 @@ class TestComposeDeterministic:
|
|||||||
is_test=False,
|
is_test=False,
|
||||||
is_reporting=False,
|
is_reporting=False,
|
||||||
)
|
)
|
||||||
assert ac.severity == "critical"
|
assert ac.severity == "high"
|
||||||
|
|
||||||
def test_category_inherited(self):
|
def test_category_inherited(self):
|
||||||
ac = _compose_deterministic(
|
ac = _compose_deterministic(
|
||||||
@@ -971,6 +977,76 @@ class TestObjectNormalization:
|
|||||||
assert "ue" in result
|
assert "ue" in result
|
||||||
assert "ä" not in result
|
assert "ä" not in result
|
||||||
|
|
||||||
|
# --- New tests for improved normalization (2026-03-28) ---
|
||||||
|
|
||||||
|
def test_qualifying_phrase_stripped(self):
|
||||||
|
"""Prepositional qualifiers like 'bei X' are stripped."""
|
||||||
|
base = _normalize_object("Eskalationsprozess")
|
||||||
|
qualified = _normalize_object(
|
||||||
|
"Eskalationsprozess bei Schwellenwertüberschreitung"
|
||||||
|
)
|
||||||
|
assert base == qualified
|
||||||
|
|
||||||
|
def test_fuer_phrase_stripped(self):
|
||||||
|
"""'für kritische Systeme' qualifier is stripped."""
|
||||||
|
base = _normalize_object("Backup-Verfahren")
|
||||||
|
qualified = _normalize_object("Backup-Verfahren für kritische Systeme")
|
||||||
|
assert base == qualified
|
||||||
|
|
||||||
|
def test_gemaess_phrase_stripped(self):
|
||||||
|
"""'gemäß Artikel 32' qualifier is stripped."""
|
||||||
|
base = _normalize_object("Verschlüsselung")
|
||||||
|
qualified = _normalize_object("Verschlüsselung gemäß Artikel 32")
|
||||||
|
assert base == qualified
|
||||||
|
|
||||||
|
def test_truncation_at_40_chars(self):
|
||||||
|
"""Objects truncated at 40 chars at word boundary."""
|
||||||
|
long_obj = "interner_eskalationsprozess_bei_schwellenwertueberschreitung_und_mehr"
|
||||||
|
result = _normalize_object(long_obj)
|
||||||
|
assert len(result) <= 40
|
||||||
|
|
||||||
|
def test_near_synonym_erkennung(self):
|
||||||
|
"""'Früherkennung' and 'frühzeitige Erkennung' collapse."""
|
||||||
|
a = _normalize_object("Früherkennung von Anomalien")
|
||||||
|
b = _normalize_object("frühzeitige Erkennung von Angriffen")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
def test_near_synonym_eskalation(self):
|
||||||
|
"""'Eskalationsprozess' and 'Eskalationsverfahren' collapse."""
|
||||||
|
a = _normalize_object("Eskalationsprozess")
|
||||||
|
b = _normalize_object("Eskalationsverfahren")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
def test_near_synonym_meldeprozess(self):
|
||||||
|
"""'Meldeprozess' and 'Meldeverfahren' collapse to notification."""
|
||||||
|
a = _normalize_object("Meldeprozess")
|
||||||
|
b = _normalize_object("Meldeverfahren")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
def test_near_synonym_ueberwachung(self):
|
||||||
|
"""'Überwachung' and 'Monitoring' collapse."""
|
||||||
|
a = _normalize_object("Überwachung")
|
||||||
|
b = _normalize_object("Monitoring")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
def test_trailing_noise_stripped(self):
|
||||||
|
"""Trailing articles/prepositions are stripped."""
|
||||||
|
result = _normalize_object("Schutz der")
|
||||||
|
assert not result.endswith("_der")
|
||||||
|
|
||||||
|
def test_vendor_synonyms(self):
|
||||||
|
"""Lieferant/Dienstleister/Auftragsverarbeiter collapse to vendor."""
|
||||||
|
a = _normalize_object("Lieferant")
|
||||||
|
b = _normalize_object("Dienstleister")
|
||||||
|
c = _normalize_object("Auftragsverarbeiter")
|
||||||
|
assert a == b == c
|
||||||
|
|
||||||
|
def test_patch_mgmt_synonyms(self):
|
||||||
|
"""Patchmanagement/Aktualisierung collapse."""
|
||||||
|
a = _normalize_object("Patchmanagement")
|
||||||
|
b = _normalize_object("Softwareaktualisierung")
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GAP 5: OUTPUT VALIDATOR TESTS
|
# GAP 5: OUTPUT VALIDATOR TESTS
|
||||||
@@ -2431,3 +2507,444 @@ class TestPass0bWithEnrichment:
|
|||||||
|
|
||||||
# Invalid JSON
|
# Invalid JSON
|
||||||
assert _parse_citation("not json") == {}
|
assert _parse_citation("not json") == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TRUNCATE TITLE TESTS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTruncateTitle:
|
||||||
|
"""Tests for _truncate_title — word-boundary truncation."""
|
||||||
|
|
||||||
|
def test_short_title_unchanged(self):
|
||||||
|
assert _truncate_title("Rate-Limiting umgesetzt") == "Rate-Limiting umgesetzt"
|
||||||
|
|
||||||
|
def test_exactly_80_unchanged(self):
|
||||||
|
title = "A" * 80
|
||||||
|
assert _truncate_title(title) == title
|
||||||
|
|
||||||
|
def test_long_title_cuts_at_word_boundary(self):
|
||||||
|
title = "Maximale Payload-Groessen fuer API-Anfragen und API-Antworten definiert und technisch durchgesetzt"
|
||||||
|
result = _truncate_title(title)
|
||||||
|
assert len(result) <= 80
|
||||||
|
assert not result.endswith(" ")
|
||||||
|
# Should not cut mid-word
|
||||||
|
assert result[-1].isalpha() or result[-1] in ("-", ")")
|
||||||
|
|
||||||
|
def test_no_mid_word_cut(self):
|
||||||
|
# "definieren" would be cut to "defin" with naive [:80]
|
||||||
|
title = "x" * 75 + " definieren"
|
||||||
|
result = _truncate_title(title)
|
||||||
|
assert "defin" not in result or "definieren" in result
|
||||||
|
|
||||||
|
def test_custom_max_len(self):
|
||||||
|
result = _truncate_title("Rate-Limiting fuer alle Endpunkte", max_len=20)
|
||||||
|
assert len(result) <= 20
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SEVERITY CALIBRATION TESTS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCalibrateSeverity:
|
||||||
|
"""Tests for _calibrate_severity — action-type-based severity."""
|
||||||
|
|
||||||
|
def test_implement_keeps_high(self):
|
||||||
|
assert _calibrate_severity("high", "implement") == "high"
|
||||||
|
|
||||||
|
def test_define_caps_to_medium(self):
|
||||||
|
assert _calibrate_severity("high", "define") == "medium"
|
||||||
|
|
||||||
|
def test_review_caps_to_medium(self):
|
||||||
|
assert _calibrate_severity("high", "review") == "medium"
|
||||||
|
|
||||||
|
def test_test_caps_to_medium(self):
|
||||||
|
assert _calibrate_severity("high", "test") == "medium"
|
||||||
|
|
||||||
|
def test_document_caps_to_medium(self):
|
||||||
|
assert _calibrate_severity("high", "document") == "medium"
|
||||||
|
|
||||||
|
def test_monitor_keeps_high(self):
|
||||||
|
assert _calibrate_severity("high", "monitor") == "high"
|
||||||
|
|
||||||
|
def test_low_parent_stays_low(self):
|
||||||
|
# Even for implement, if parent is low, stays low
|
||||||
|
assert _calibrate_severity("low", "implement") == "low"
|
||||||
|
|
||||||
|
def test_medium_parent_define_stays_medium(self):
|
||||||
|
assert _calibrate_severity("medium", "define") == "medium"
|
||||||
|
|
||||||
|
def test_unknown_action_inherits_parent(self):
|
||||||
|
assert _calibrate_severity("high", "unknown_action") == "high"
|
||||||
|
|
||||||
|
def test_critical_implement_caps_to_high(self):
|
||||||
|
# implement caps at high — critical is reserved for parent-level controls
|
||||||
|
assert _calibrate_severity("critical", "implement") == "high"
|
||||||
|
|
||||||
|
def test_critical_define_caps_to_medium(self):
|
||||||
|
assert _calibrate_severity("critical", "define") == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# COMPOSE DETERMINISTIC — SEVERITY CALIBRATION INTEGRATION
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestComposeDeterministicSeverity:
|
||||||
|
"""Verify _compose_deterministic uses calibrated severity."""
|
||||||
|
|
||||||
|
def test_define_action_gets_medium(self):
|
||||||
|
atomic = _compose_deterministic(
|
||||||
|
obligation_text="Payload-Grenzen sind verbindlich festzulegen.",
|
||||||
|
action="definieren",
|
||||||
|
object_="Payload-Grenzen",
|
||||||
|
parent_title="API Ressourcen",
|
||||||
|
parent_severity="high",
|
||||||
|
parent_category="security",
|
||||||
|
is_test=False,
|
||||||
|
is_reporting=False,
|
||||||
|
)
|
||||||
|
assert atomic.severity == "medium"
|
||||||
|
|
||||||
|
def test_implement_action_keeps_high(self):
|
||||||
|
atomic = _compose_deterministic(
|
||||||
|
obligation_text="Rate-Limiting muss technisch umgesetzt werden.",
|
||||||
|
action="implementieren",
|
||||||
|
object_="Rate-Limiting",
|
||||||
|
parent_title="API Ressourcen",
|
||||||
|
parent_severity="high",
|
||||||
|
parent_category="security",
|
||||||
|
is_test=False,
|
||||||
|
is_reporting=False,
|
||||||
|
)
|
||||||
|
assert atomic.severity == "high"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ERROR CLASS 1: NEGATIVE / PROHIBITIVE ACTION CLASSIFICATION
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegativeActions:
|
||||||
|
"""Tests for prohibitive action keywords → prevent/exclude/forbid."""
|
||||||
|
|
||||||
|
def test_duerfen_keine_maps_to_prevent(self):
|
||||||
|
assert _classify_action("dürfen keine") == "prevent"
|
||||||
|
|
||||||
|
def test_duerfen_nicht_maps_to_prevent(self):
|
||||||
|
assert _classify_action("dürfen nicht") == "prevent"
|
||||||
|
|
||||||
|
def test_darf_keine_maps_to_prevent(self):
|
||||||
|
assert _classify_action("darf keine") == "prevent"
|
||||||
|
|
||||||
|
def test_verboten_maps_to_forbid(self):
|
||||||
|
assert _classify_action("verboten") == "forbid"
|
||||||
|
|
||||||
|
def test_untersagt_maps_to_forbid(self):
|
||||||
|
assert _classify_action("untersagt") == "forbid"
|
||||||
|
|
||||||
|
def test_nicht_zulaessig_maps_to_forbid(self):
|
||||||
|
assert _classify_action("nicht zulässig") == "forbid"
|
||||||
|
|
||||||
|
def test_nicht_erlaubt_maps_to_forbid(self):
|
||||||
|
assert _classify_action("nicht erlaubt") == "forbid"
|
||||||
|
|
||||||
|
def test_nicht_enthalten_maps_to_exclude(self):
|
||||||
|
assert _classify_action("nicht enthalten") == "exclude"
|
||||||
|
|
||||||
|
def test_ausschliessen_maps_to_exclude(self):
|
||||||
|
assert _classify_action("ausschließen") == "exclude"
|
||||||
|
|
||||||
|
def test_verhindern_maps_to_prevent(self):
|
||||||
|
assert _classify_action("verhindern") == "prevent"
|
||||||
|
|
||||||
|
def test_unterbinden_maps_to_prevent(self):
|
||||||
|
assert _classify_action("unterbinden") == "prevent"
|
||||||
|
|
||||||
|
def test_ablehnen_maps_to_exclude(self):
|
||||||
|
assert _classify_action("ablehnen") == "exclude"
|
||||||
|
|
||||||
|
def test_nicht_uebertragen_maps_to_prevent(self):
|
||||||
|
assert _classify_action("nicht übertragen") == "prevent"
|
||||||
|
|
||||||
|
def test_nicht_gespeichert_maps_to_prevent(self):
|
||||||
|
assert _classify_action("nicht gespeichert") == "prevent"
|
||||||
|
|
||||||
|
def test_negative_action_has_higher_priority_than_implement(self):
|
||||||
|
"""Negative keywords at start of ACTION_PRIORITY → picked over lower ones."""
|
||||||
|
result = _classify_action("verhindern und dokumentieren")
|
||||||
|
assert result == "prevent"
|
||||||
|
|
||||||
|
def test_prevent_template_exists(self):
|
||||||
|
assert "prevent" in _ACTION_TEMPLATES
|
||||||
|
assert "test_procedure" in _ACTION_TEMPLATES["prevent"]
|
||||||
|
assert "evidence" in _ACTION_TEMPLATES["prevent"]
|
||||||
|
|
||||||
|
def test_exclude_template_exists(self):
|
||||||
|
assert "exclude" in _ACTION_TEMPLATES
|
||||||
|
assert "test_procedure" in _ACTION_TEMPLATES["exclude"]
|
||||||
|
|
||||||
|
def test_forbid_template_exists(self):
|
||||||
|
assert "forbid" in _ACTION_TEMPLATES
|
||||||
|
assert "test_procedure" in _ACTION_TEMPLATES["forbid"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ERROR CLASS 1b: SESSION / LIFECYCLE ACTIONS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionActions:
|
||||||
|
"""Tests for session lifecycle action keywords."""
|
||||||
|
|
||||||
|
def test_ungueltig_machen_maps_to_invalidate(self):
|
||||||
|
assert _classify_action("ungültig machen") == "invalidate"
|
||||||
|
|
||||||
|
def test_invalidieren_maps_to_invalidate(self):
|
||||||
|
assert _classify_action("invalidieren") == "invalidate"
|
||||||
|
|
||||||
|
def test_widerrufen_maps_to_invalidate(self):
|
||||||
|
assert _classify_action("widerrufen") == "invalidate"
|
||||||
|
|
||||||
|
def test_session_beenden_maps_to_invalidate(self):
|
||||||
|
assert _classify_action("session beenden") == "invalidate"
|
||||||
|
|
||||||
|
def test_vergeben_maps_to_issue(self):
|
||||||
|
assert _classify_action("vergeben") == "issue"
|
||||||
|
|
||||||
|
def test_erzeugen_maps_to_issue(self):
|
||||||
|
assert _classify_action("erzeugen") == "issue"
|
||||||
|
|
||||||
|
def test_rotieren_maps_to_rotate(self):
|
||||||
|
assert _classify_action("rotieren") == "rotate"
|
||||||
|
|
||||||
|
def test_erneuern_maps_to_rotate(self):
|
||||||
|
assert _classify_action("erneuern") == "rotate"
|
||||||
|
|
||||||
|
def test_durchsetzen_maps_to_enforce(self):
|
||||||
|
assert _classify_action("durchsetzen") == "enforce"
|
||||||
|
|
||||||
|
def test_erzwingen_maps_to_enforce(self):
|
||||||
|
assert _classify_action("erzwingen") == "enforce"
|
||||||
|
|
||||||
|
def test_invalidate_template_exists(self):
|
||||||
|
assert "invalidate" in _ACTION_TEMPLATES
|
||||||
|
assert "test_procedure" in _ACTION_TEMPLATES["invalidate"]
|
||||||
|
|
||||||
|
def test_issue_template_exists(self):
|
||||||
|
assert "issue" in _ACTION_TEMPLATES
|
||||||
|
|
||||||
|
def test_rotate_template_exists(self):
|
||||||
|
assert "rotate" in _ACTION_TEMPLATES
|
||||||
|
|
||||||
|
def test_enforce_template_exists(self):
|
||||||
|
assert "enforce" in _ACTION_TEMPLATES
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ERROR CLASS 2: CONTAINER OBJECT DETECTION
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestContainerObjectDetection:
|
||||||
|
"""Tests for _is_container_object — broad objects that need decomposition."""
|
||||||
|
|
||||||
|
def test_sitzungsverwaltung_is_container(self):
|
||||||
|
assert _is_container_object("Sitzungsverwaltung") is True
|
||||||
|
|
||||||
|
def test_session_management_is_container(self):
|
||||||
|
assert _is_container_object("Session Management") is True
|
||||||
|
|
||||||
|
def test_token_schutz_is_container(self):
|
||||||
|
assert _is_container_object("Token-Schutz") is True
|
||||||
|
|
||||||
|
def test_authentifizierungsmechanismen_is_container(self):
|
||||||
|
assert _is_container_object("Authentifizierungsmechanismen") is True
|
||||||
|
|
||||||
|
def test_sicherheitsmassnahmen_is_container(self):
|
||||||
|
assert _is_container_object("Sicherheitsmaßnahmen") is True
|
||||||
|
|
||||||
|
def test_zugriffskontrollmechanismen_is_container(self):
|
||||||
|
assert _is_container_object("Zugriffskontrollmechanismen") is True
|
||||||
|
|
||||||
|
def test_sicherheitsarchitektur_is_container(self):
|
||||||
|
assert _is_container_object("Sicherheitsarchitektur") is True
|
||||||
|
|
||||||
|
def test_compliance_anforderungen_is_container(self):
|
||||||
|
assert _is_container_object("Compliance-Anforderungen") is True
|
||||||
|
|
||||||
|
def test_session_id_is_not_container(self):
|
||||||
|
"""Specific objects like Session-ID are NOT containers."""
|
||||||
|
assert _is_container_object("Session-ID") is False
|
||||||
|
|
||||||
|
def test_firewall_is_not_container(self):
|
||||||
|
assert _is_container_object("Firewall") is False
|
||||||
|
|
||||||
|
def test_mfa_is_not_container(self):
|
||||||
|
assert _is_container_object("MFA") is False
|
||||||
|
|
||||||
|
def test_verschluesselung_is_not_container(self):
|
||||||
|
assert _is_container_object("Verschlüsselung") is False
|
||||||
|
|
||||||
|
def test_cookie_is_not_container(self):
|
||||||
|
assert _is_container_object("Session-Cookie") is False
|
||||||
|
|
||||||
|
def test_empty_string_is_not_container(self):
|
||||||
|
assert _is_container_object("") is False
|
||||||
|
|
||||||
|
def test_none_is_not_container(self):
|
||||||
|
assert _is_container_object(None) is False
|
||||||
|
|
||||||
|
def test_container_in_compose_sets_atomicity(self):
|
||||||
|
"""Container objects set _atomicity='container' and _requires_decomposition."""
|
||||||
|
ac = _compose_deterministic(
|
||||||
|
obligation_text="Sitzungsverwaltung muss abgesichert werden",
|
||||||
|
action="implementieren",
|
||||||
|
object_="Sitzungsverwaltung",
|
||||||
|
parent_title="Session Security",
|
||||||
|
parent_severity="high",
|
||||||
|
parent_category="security",
|
||||||
|
is_test=False,
|
||||||
|
is_reporting=False,
|
||||||
|
)
|
||||||
|
assert ac._atomicity == "container"
|
||||||
|
assert ac._requires_decomposition is True
|
||||||
|
|
||||||
|
def test_specific_object_is_atomic(self):
|
||||||
|
"""Specific objects like Session-ID stay atomic."""
|
||||||
|
ac = _compose_deterministic(
|
||||||
|
obligation_text="Session-ID muss nach Logout gelöscht werden",
|
||||||
|
action="implementieren",
|
||||||
|
object_="Session-ID",
|
||||||
|
parent_title="Session Security",
|
||||||
|
parent_severity="high",
|
||||||
|
parent_category="security",
|
||||||
|
is_test=False,
|
||||||
|
is_reporting=False,
|
||||||
|
)
|
||||||
|
assert ac._atomicity == "atomic"
|
||||||
|
assert ac._requires_decomposition is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ERROR CLASS 3: SESSION-SPECIFIC OBJECT CLASSES
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionObjectClasses:
|
||||||
|
"""Tests for session/cookie/jwt/federated_assertion object classification."""
|
||||||
|
|
||||||
|
def test_session_class(self):
|
||||||
|
assert _classify_object("Session") == "session"
|
||||||
|
|
||||||
|
def test_sitzung_class(self):
|
||||||
|
assert _classify_object("Sitzung") == "session"
|
||||||
|
|
||||||
|
def test_session_id_class(self):
|
||||||
|
assert _classify_object("Session-ID") == "session"
|
||||||
|
|
||||||
|
def test_session_token_class(self):
|
||||||
|
assert _classify_object("Session-Token") == "session"
|
||||||
|
|
||||||
|
def test_idle_timeout_class(self):
|
||||||
|
assert _classify_object("Idle Timeout") == "session"
|
||||||
|
|
||||||
|
def test_logout_matches_record_via_log(self):
|
||||||
|
"""'Logout' matches 'log' in record class (checked before session)."""
|
||||||
|
# Ordering: record class checked before session — "log" substring matches
|
||||||
|
assert _classify_object("Logout") == "record"
|
||||||
|
|
||||||
|
def test_abmeldung_matches_report_via_meldung(self):
|
||||||
|
"""'Abmeldung' matches 'meldung' in report class (checked before session)."""
|
||||||
|
assert _classify_object("Abmeldung") == "report"
|
||||||
|
|
||||||
|
def test_cookie_class(self):
|
||||||
|
assert _classify_object("Cookie") == "cookie"
|
||||||
|
|
||||||
|
def test_session_cookie_matches_session_first(self):
|
||||||
|
"""'Session-Cookie' matches 'session' in session class (checked before cookie)."""
|
||||||
|
assert _classify_object("Session-Cookie") == "session"
|
||||||
|
|
||||||
|
def test_secure_flag_class(self):
|
||||||
|
assert _classify_object("Secure-Flag") == "cookie"
|
||||||
|
|
||||||
|
def test_httponly_class(self):
|
||||||
|
assert _classify_object("HttpOnly") == "cookie"
|
||||||
|
|
||||||
|
def test_samesite_class(self):
|
||||||
|
assert _classify_object("SameSite") == "cookie"
|
||||||
|
|
||||||
|
def test_jwt_class(self):
|
||||||
|
assert _classify_object("JWT") == "jwt"
|
||||||
|
|
||||||
|
def test_json_web_token_class(self):
|
||||||
|
assert _classify_object("JSON Web Token") == "jwt"
|
||||||
|
|
||||||
|
def test_bearer_token_class(self):
|
||||||
|
assert _classify_object("Bearer Token") == "jwt"
|
||||||
|
|
||||||
|
def test_saml_assertion_class(self):
|
||||||
|
assert _classify_object("SAML Assertion") == "federated_assertion"
|
||||||
|
|
||||||
|
def test_oidc_class(self):
|
||||||
|
assert _classify_object("OIDC Provider") == "federated_assertion"
|
||||||
|
|
||||||
|
def test_openid_class(self):
|
||||||
|
assert _classify_object("OpenID Connect") == "federated_assertion"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ERROR CLASS 4: SEVERITY CAPS FOR NEW ACTION TYPES
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewActionSeverityCaps:
|
||||||
|
"""Tests for _ACTION_SEVERITY_CAP on new action types."""
|
||||||
|
|
||||||
|
def test_prevent_capped_at_high(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("prevent") == "high"
|
||||||
|
|
||||||
|
def test_exclude_capped_at_high(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("exclude") == "high"
|
||||||
|
|
||||||
|
def test_forbid_capped_at_high(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("forbid") == "high"
|
||||||
|
|
||||||
|
def test_invalidate_capped_at_high(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("invalidate") == "high"
|
||||||
|
|
||||||
|
def test_issue_capped_at_high(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("issue") == "high"
|
||||||
|
|
||||||
|
def test_rotate_capped_at_medium(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("rotate") == "medium"
|
||||||
|
|
||||||
|
def test_enforce_capped_at_high(self):
|
||||||
|
assert _ACTION_SEVERITY_CAP.get("enforce") == "high"
|
||||||
|
|
||||||
|
def test_prevent_action_severity_in_compose(self):
|
||||||
|
"""prevent + critical parent → capped to high."""
|
||||||
|
ac = _compose_deterministic(
|
||||||
|
obligation_text="Session-Tokens dürfen nicht im Klartext gespeichert werden",
|
||||||
|
action="verhindern",
|
||||||
|
object_="Klartextspeicherung",
|
||||||
|
parent_title="Token Security",
|
||||||
|
parent_severity="critical",
|
||||||
|
parent_category="security",
|
||||||
|
is_test=False,
|
||||||
|
is_reporting=False,
|
||||||
|
)
|
||||||
|
assert ac.severity == "high"
|
||||||
|
|
||||||
|
def test_rotate_action_severity_in_compose(self):
|
||||||
|
"""rotate + high parent → capped to medium."""
|
||||||
|
ac = _compose_deterministic(
|
||||||
|
obligation_text="Session-Tokens müssen regelmäßig rotiert werden",
|
||||||
|
action="rotieren",
|
||||||
|
object_="Session-Token",
|
||||||
|
parent_title="Token Lifecycle",
|
||||||
|
parent_severity="high",
|
||||||
|
parent_category="security",
|
||||||
|
is_test=False,
|
||||||
|
is_reporting=False,
|
||||||
|
)
|
||||||
|
assert ac.severity == "medium"
|
||||||
|
|||||||
79
backend-compliance/tests/test_evidence_type.py
Normal file
79
backend-compliance/tests/test_evidence_type.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Tests for evidence_type classification heuristic."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from compliance.api.canonical_control_routes import _classify_evidence_type
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyEvidenceType:
|
||||||
|
"""Tests for _classify_evidence_type()."""
|
||||||
|
|
||||||
|
# --- Code domains ---
|
||||||
|
def test_sec_is_code(self):
|
||||||
|
assert _classify_evidence_type("SEC-042", None) == "code"
|
||||||
|
|
||||||
|
def test_auth_is_code(self):
|
||||||
|
assert _classify_evidence_type("AUTH-001", None) == "code"
|
||||||
|
|
||||||
|
def test_crypt_is_code(self):
|
||||||
|
assert _classify_evidence_type("CRYPT-003", None) == "code"
|
||||||
|
|
||||||
|
def test_cryp_is_code(self):
|
||||||
|
assert _classify_evidence_type("CRYP-010", None) == "code"
|
||||||
|
|
||||||
|
def test_net_is_code(self):
|
||||||
|
assert _classify_evidence_type("NET-015", None) == "code"
|
||||||
|
|
||||||
|
def test_log_is_code(self):
|
||||||
|
assert _classify_evidence_type("LOG-007", None) == "code"
|
||||||
|
|
||||||
|
def test_acc_is_code(self):
|
||||||
|
assert _classify_evidence_type("ACC-012", None) == "code"
|
||||||
|
|
||||||
|
def test_api_is_code(self):
|
||||||
|
assert _classify_evidence_type("API-001", None) == "code"
|
||||||
|
|
||||||
|
# --- Process domains ---
|
||||||
|
def test_gov_is_process(self):
|
||||||
|
assert _classify_evidence_type("GOV-001", None) == "process"
|
||||||
|
|
||||||
|
def test_comp_is_process(self):
|
||||||
|
assert _classify_evidence_type("COMP-001", None) == "process"
|
||||||
|
|
||||||
|
def test_fin_is_process(self):
|
||||||
|
assert _classify_evidence_type("FIN-001", None) == "process"
|
||||||
|
|
||||||
|
def test_hr_is_process(self):
|
||||||
|
assert _classify_evidence_type("HR-001", None) == "process"
|
||||||
|
|
||||||
|
def test_org_is_process(self):
|
||||||
|
assert _classify_evidence_type("ORG-001", None) == "process"
|
||||||
|
|
||||||
|
def test_env_is_process(self):
|
||||||
|
assert _classify_evidence_type("ENV-001", None) == "process"
|
||||||
|
|
||||||
|
# --- Hybrid domains ---
|
||||||
|
def test_data_is_hybrid(self):
|
||||||
|
assert _classify_evidence_type("DATA-005", None) == "hybrid"
|
||||||
|
|
||||||
|
def test_ai_is_hybrid(self):
|
||||||
|
assert _classify_evidence_type("AI-001", None) == "hybrid"
|
||||||
|
|
||||||
|
def test_inc_is_hybrid(self):
|
||||||
|
assert _classify_evidence_type("INC-003", None) == "hybrid"
|
||||||
|
|
||||||
|
def test_iam_is_hybrid(self):
|
||||||
|
assert _classify_evidence_type("IAM-001", None) == "hybrid"
|
||||||
|
|
||||||
|
# --- Category fallback ---
|
||||||
|
def test_unknown_domain_encryption_category(self):
|
||||||
|
assert _classify_evidence_type("XYZ-001", "encryption") == "code"
|
||||||
|
|
||||||
|
def test_unknown_domain_governance_category(self):
|
||||||
|
assert _classify_evidence_type("XYZ-001", "governance") == "process"
|
||||||
|
|
||||||
|
def test_unknown_domain_no_category(self):
|
||||||
|
assert _classify_evidence_type("XYZ-001", None) == "process"
|
||||||
|
|
||||||
|
def test_empty_control_id(self):
|
||||||
|
assert _classify_evidence_type("", None) == "process"
|
||||||
259
backend-compliance/tests/test_rationale_backfill.py
Normal file
259
backend-compliance/tests/test_rationale_backfill.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""Tests for the rationale backfill endpoint logic."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from compliance.api.canonical_control_routes import backfill_rationale
|
||||||
|
|
||||||
|
|
||||||
|
class TestRationaleBackfillDryRun:
|
||||||
|
"""Dry-run mode should return statistics without touching DB."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dry_run_returns_stats(self):
|
||||||
|
mock_parents = [
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid="uuid-1",
|
||||||
|
control_id="ACC-001",
|
||||||
|
title="Access Control",
|
||||||
|
category="access",
|
||||||
|
source_name="OWASP ASVS",
|
||||||
|
child_count=12,
|
||||||
|
),
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid="uuid-2",
|
||||||
|
control_id="SEC-042",
|
||||||
|
title="Encryption",
|
||||||
|
category="encryption",
|
||||||
|
source_name="NIST SP 800-53",
|
||||||
|
child_count=5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_parents
|
||||||
|
|
||||||
|
result = await backfill_rationale(dry_run=True, batch_size=50, offset=0)
|
||||||
|
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["total_parents"] == 2
|
||||||
|
assert result["total_children"] == 17
|
||||||
|
assert result["estimated_llm_calls"] == 2
|
||||||
|
assert len(result["sample_parents"]) == 2
|
||||||
|
assert result["sample_parents"][0]["control_id"] == "ACC-001"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRationaleBackfillExecution:
|
||||||
|
"""Execution mode should call LLM and update DB."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_processes_batch_and_updates(self):
|
||||||
|
mock_parents = [
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid="uuid-1",
|
||||||
|
control_id="ACC-001",
|
||||||
|
title="Access Control",
|
||||||
|
category="access",
|
||||||
|
source_name="OWASP ASVS",
|
||||||
|
child_count=5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_llm_response = MagicMock()
|
||||||
|
mock_llm_response.content = (
|
||||||
|
"Die uebergeordneten Anforderungen an Zugriffskontrolle aus "
|
||||||
|
"OWASP ASVS erfordern eine Zerlegung in atomare Massnahmen, "
|
||||||
|
"um jede Einzelmassnahme unabhaengig testbar zu machen."
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_update_result = MagicMock()
|
||||||
|
mock_update_result.rowcount = 5
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_parents
|
||||||
|
# Second call is the UPDATE
|
||||||
|
db.execute.return_value.rowcount = 5
|
||||||
|
|
||||||
|
with patch("compliance.services.llm_provider.get_llm_provider") as mock_get:
|
||||||
|
mock_provider = AsyncMock()
|
||||||
|
mock_provider.complete.return_value = mock_llm_response
|
||||||
|
mock_get.return_value = mock_provider
|
||||||
|
|
||||||
|
result = await backfill_rationale(
|
||||||
|
dry_run=False, batch_size=50, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["dry_run"] is False
|
||||||
|
assert result["processed_parents"] == 1
|
||||||
|
assert len(result["errors"]) == 0
|
||||||
|
assert len(result["sample_rationales"]) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_batch_returns_done(self):
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = []
|
||||||
|
|
||||||
|
result = await backfill_rationale(
|
||||||
|
dry_run=False, batch_size=50, offset=9999,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["processed"] == 0
|
||||||
|
assert "Kein weiterer Batch" in result["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_error_captured(self):
|
||||||
|
mock_parents = [
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid="uuid-1",
|
||||||
|
control_id="SEC-100",
|
||||||
|
title="Network Security",
|
||||||
|
category="network",
|
||||||
|
source_name="ISO 27001",
|
||||||
|
child_count=3,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_parents
|
||||||
|
|
||||||
|
with patch("compliance.services.llm_provider.get_llm_provider") as mock_get:
|
||||||
|
mock_provider = AsyncMock()
|
||||||
|
mock_provider.complete.side_effect = Exception("Ollama timeout")
|
||||||
|
mock_get.return_value = mock_provider
|
||||||
|
|
||||||
|
result = await backfill_rationale(
|
||||||
|
dry_run=False, batch_size=50, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["processed_parents"] == 0
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
assert "Ollama timeout" in result["errors"][0]["error"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_short_response_skipped(self):
|
||||||
|
mock_parents = [
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid="uuid-1",
|
||||||
|
control_id="GOV-001",
|
||||||
|
title="Governance",
|
||||||
|
category="governance",
|
||||||
|
source_name="ISO 27001",
|
||||||
|
child_count=2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_llm_response = MagicMock()
|
||||||
|
mock_llm_response.content = "OK" # Too short
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_parents
|
||||||
|
|
||||||
|
with patch("compliance.services.llm_provider.get_llm_provider") as mock_get:
|
||||||
|
mock_provider = AsyncMock()
|
||||||
|
mock_provider.complete.return_value = mock_llm_response
|
||||||
|
mock_get.return_value = mock_provider
|
||||||
|
|
||||||
|
result = await backfill_rationale(
|
||||||
|
dry_run=False, batch_size=50, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["processed_parents"] == 0
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
assert "zu kurz" in result["errors"][0]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRationalePagination:
|
||||||
|
"""Pagination logic should work correctly."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_next_offset_set_when_more_remain(self):
|
||||||
|
# 3 parents, batch_size=2 → next_offset=2
|
||||||
|
mock_parents = [
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid=f"uuid-{i}",
|
||||||
|
control_id=f"SEC-{i:03d}",
|
||||||
|
title=f"Control {i}",
|
||||||
|
category="security",
|
||||||
|
source_name="NIST",
|
||||||
|
child_count=2,
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_llm_response = MagicMock()
|
||||||
|
mock_llm_response.content = (
|
||||||
|
"Sicherheitsanforderungen aus NIST erfordern atomare "
|
||||||
|
"Massnahmen fuer unabhaengige Testbarkeit."
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_parents
|
||||||
|
db.execute.return_value.rowcount = 2
|
||||||
|
|
||||||
|
with patch("compliance.services.llm_provider.get_llm_provider") as mock_get:
|
||||||
|
mock_provider = AsyncMock()
|
||||||
|
mock_provider.complete.return_value = mock_llm_response
|
||||||
|
mock_get.return_value = mock_provider
|
||||||
|
|
||||||
|
result = await backfill_rationale(
|
||||||
|
dry_run=False, batch_size=2, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["next_offset"] == 2
|
||||||
|
assert result["processed_parents"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_next_offset_none_when_done(self):
|
||||||
|
mock_parents = [
|
||||||
|
MagicMock(
|
||||||
|
parent_uuid="uuid-1",
|
||||||
|
control_id="SEC-001",
|
||||||
|
title="Control 1",
|
||||||
|
category="security",
|
||||||
|
source_name="NIST",
|
||||||
|
child_count=2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_llm_response = MagicMock()
|
||||||
|
mock_llm_response.content = (
|
||||||
|
"Sicherheitsanforderungen erfordern atomare Massnahmen."
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("compliance.api.canonical_control_routes.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_parents
|
||||||
|
db.execute.return_value.rowcount = 2
|
||||||
|
|
||||||
|
with patch("compliance.services.llm_provider.get_llm_provider") as mock_get:
|
||||||
|
mock_provider = AsyncMock()
|
||||||
|
mock_provider.complete.return_value = mock_llm_response
|
||||||
|
mock_get.return_value = mock_provider
|
||||||
|
|
||||||
|
result = await backfill_rationale(
|
||||||
|
dry_run=False, batch_size=50, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["next_offset"] is None
|
||||||
102
backend-compliance/tests/test_source_type_classification.py
Normal file
102
backend-compliance/tests/test_source_type_classification.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Tests for source_type_classification module."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from compliance.data.source_type_classification import (
|
||||||
|
classify_source_regulation,
|
||||||
|
cap_normative_strength,
|
||||||
|
get_highest_source_type,
|
||||||
|
SOURCE_TYPE_LAW,
|
||||||
|
SOURCE_TYPE_GUIDELINE,
|
||||||
|
SOURCE_TYPE_FRAMEWORK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifySourceRegulation:
|
||||||
|
"""Tests for classify_source_regulation()."""
|
||||||
|
|
||||||
|
def test_eu_regulation(self):
|
||||||
|
assert classify_source_regulation("DSGVO (EU) 2016/679") == SOURCE_TYPE_LAW
|
||||||
|
|
||||||
|
def test_eu_directive(self):
|
||||||
|
assert classify_source_regulation("NIS2-Richtlinie (EU) 2022/2555") == SOURCE_TYPE_LAW
|
||||||
|
|
||||||
|
def test_national_law(self):
|
||||||
|
assert classify_source_regulation("Bundesdatenschutzgesetz (BDSG)") == SOURCE_TYPE_LAW
|
||||||
|
|
||||||
|
def test_edpb_guideline(self):
|
||||||
|
assert classify_source_regulation("EDPB Leitlinien 01/2020 (Datentransfers)") == SOURCE_TYPE_GUIDELINE
|
||||||
|
|
||||||
|
def test_bsi_standard(self):
|
||||||
|
assert classify_source_regulation("BSI-TR-03161-1") == SOURCE_TYPE_GUIDELINE
|
||||||
|
|
||||||
|
def test_wp29_guideline(self):
|
||||||
|
assert classify_source_regulation("WP260 Leitlinien (Transparenz)") == SOURCE_TYPE_GUIDELINE
|
||||||
|
|
||||||
|
def test_enisa_framework(self):
|
||||||
|
assert classify_source_regulation("ENISA Supply Chain Good Practices") == SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
def test_nist_framework(self):
|
||||||
|
assert classify_source_regulation("NIST Cybersecurity Framework 2.0") == SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
def test_owasp_framework(self):
|
||||||
|
assert classify_source_regulation("OWASP Top 10 (2021)") == SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
def test_unknown_defaults_to_framework(self):
|
||||||
|
assert classify_source_regulation("Some Unknown Source") == SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert classify_source_regulation("") == SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
def test_heuristic_verordnung(self):
|
||||||
|
assert classify_source_regulation("Neue Verordnung 2027") == SOURCE_TYPE_LAW
|
||||||
|
|
||||||
|
def test_heuristic_nist(self):
|
||||||
|
assert classify_source_regulation("NIST Future Standard") == SOURCE_TYPE_FRAMEWORK
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapNormativeStrength:
|
||||||
|
"""Tests for cap_normative_strength()."""
|
||||||
|
|
||||||
|
def test_must_from_law_stays(self):
|
||||||
|
assert cap_normative_strength("must", SOURCE_TYPE_LAW) == "must"
|
||||||
|
|
||||||
|
def test_should_from_law_stays(self):
|
||||||
|
assert cap_normative_strength("should", SOURCE_TYPE_LAW) == "should"
|
||||||
|
|
||||||
|
def test_must_from_guideline_capped(self):
|
||||||
|
assert cap_normative_strength("must", SOURCE_TYPE_GUIDELINE) == "should"
|
||||||
|
|
||||||
|
def test_should_from_guideline_stays(self):
|
||||||
|
assert cap_normative_strength("should", SOURCE_TYPE_GUIDELINE) == "should"
|
||||||
|
|
||||||
|
def test_must_from_framework_capped(self):
|
||||||
|
assert cap_normative_strength("must", SOURCE_TYPE_FRAMEWORK) == "may"
|
||||||
|
|
||||||
|
def test_should_from_framework_capped(self):
|
||||||
|
assert cap_normative_strength("should", SOURCE_TYPE_FRAMEWORK) == "may"
|
||||||
|
|
||||||
|
def test_may_from_framework_stays(self):
|
||||||
|
assert cap_normative_strength("may", SOURCE_TYPE_FRAMEWORK) == "may"
|
||||||
|
|
||||||
|
def test_may_from_law_stays(self):
|
||||||
|
assert cap_normative_strength("may", SOURCE_TYPE_LAW) == "may"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetHighestSourceType:
|
||||||
|
"""Tests for get_highest_source_type()."""
|
||||||
|
|
||||||
|
def test_law_wins(self):
|
||||||
|
assert get_highest_source_type(["framework", "law"]) == "law"
|
||||||
|
|
||||||
|
def test_guideline_over_framework(self):
|
||||||
|
assert get_highest_source_type(["framework", "guideline"]) == "guideline"
|
||||||
|
|
||||||
|
def test_single_framework(self):
|
||||||
|
assert get_highest_source_type(["framework"]) == "framework"
|
||||||
|
|
||||||
|
def test_empty_defaults_to_framework(self):
|
||||||
|
assert get_highest_source_type([]) == "framework"
|
||||||
|
|
||||||
|
def test_all_three(self):
|
||||||
|
assert get_highest_source_type(["framework", "guideline", "law"]) == "law"
|
||||||
234
backend-compliance/tests/test_v1_enrichment.py
Normal file
234
backend-compliance/tests/test_v1_enrichment.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""Tests for V1 Control Enrichment (Eigenentwicklung matching)."""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from compliance.services.v1_enrichment import (
|
||||||
|
enrich_v1_matches,
|
||||||
|
get_v1_matches,
|
||||||
|
count_v1_controls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1EnrichmentDryRun:
|
||||||
|
"""Dry-run mode should return statistics without touching DB."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dry_run_returns_stats(self):
|
||||||
|
mock_v1 = [
|
||||||
|
MagicMock(
|
||||||
|
id="uuid-v1-1",
|
||||||
|
control_id="ACC-013",
|
||||||
|
title="Zugriffskontrolle",
|
||||||
|
objective="Zugriff einschraenken",
|
||||||
|
category="access",
|
||||||
|
),
|
||||||
|
MagicMock(
|
||||||
|
id="uuid-v1-2",
|
||||||
|
control_id="SEC-005",
|
||||||
|
title="Verschluesselung",
|
||||||
|
objective="Daten verschluesseln",
|
||||||
|
category="encryption",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_count = MagicMock(cnt=863)
|
||||||
|
|
||||||
|
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
# First call: v1 controls, second call: count
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_v1
|
||||||
|
db.execute.return_value.fetchone.return_value = mock_count
|
||||||
|
|
||||||
|
result = await enrich_v1_matches(dry_run=True, batch_size=100, offset=0)
|
||||||
|
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["total_v1"] == 863
|
||||||
|
assert len(result["sample_controls"]) == 2
|
||||||
|
assert result["sample_controls"][0]["control_id"] == "ACC-013"
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1EnrichmentExecution:
|
||||||
|
"""Execution mode should find matches and insert them."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_processes_and_inserts_matches(self):
|
||||||
|
mock_v1 = [
|
||||||
|
MagicMock(
|
||||||
|
id="uuid-v1-1",
|
||||||
|
control_id="ACC-013",
|
||||||
|
title="Zugriffskontrolle",
|
||||||
|
objective="Zugriff auf Systeme einschraenken",
|
||||||
|
category="access",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_count = MagicMock(cnt=1)
|
||||||
|
|
||||||
|
# Atomic control found in Qdrant (has parent, no source_citation)
|
||||||
|
mock_atomic_row = MagicMock(
|
||||||
|
id="uuid-atomic-1",
|
||||||
|
control_id="SEC-042-A01",
|
||||||
|
title="Verschluesselung (atomar)",
|
||||||
|
source_citation=None, # Atomic controls don't have source_citation
|
||||||
|
parent_control_uuid="uuid-reg-1",
|
||||||
|
severity="high",
|
||||||
|
category="encryption",
|
||||||
|
)
|
||||||
|
# Parent control (has source_citation)
|
||||||
|
mock_parent_row = MagicMock(
|
||||||
|
id="uuid-reg-1",
|
||||||
|
control_id="SEC-042",
|
||||||
|
title="Verschluesselung personenbezogener Daten",
|
||||||
|
source_citation={"source": "DSGVO (EU) 2016/679", "article": "Art. 32"},
|
||||||
|
parent_control_uuid=None,
|
||||||
|
severity="high",
|
||||||
|
category="encryption",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_qdrant_results = [
|
||||||
|
{
|
||||||
|
"score": 0.89,
|
||||||
|
"payload": {
|
||||||
|
"control_uuid": "uuid-atomic-1",
|
||||||
|
"control_id": "SEC-042-A01",
|
||||||
|
"title": "Verschluesselung (atomar)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"score": 0.65, # Below threshold
|
||||||
|
"payload": {
|
||||||
|
"control_uuid": "uuid-reg-2",
|
||||||
|
"control_id": "SEC-100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
# Route queries to correct mock data
|
||||||
|
def side_effect_execute(query, params=None):
|
||||||
|
result = MagicMock()
|
||||||
|
query_str = str(query)
|
||||||
|
result.fetchall.return_value = mock_v1
|
||||||
|
if "COUNT" in query_str:
|
||||||
|
result.fetchone.return_value = mock_count
|
||||||
|
elif "source_citation IS NOT NULL" in query_str:
|
||||||
|
# Parent lookup
|
||||||
|
result.fetchone.return_value = mock_parent_row
|
||||||
|
elif "c.id = CAST" in query_str or "canonical_controls c" in query_str:
|
||||||
|
# Direct atomic control lookup
|
||||||
|
result.fetchone.return_value = mock_atomic_row
|
||||||
|
else:
|
||||||
|
result.fetchone.return_value = mock_count
|
||||||
|
return result
|
||||||
|
|
||||||
|
db.execute.side_effect = side_effect_execute
|
||||||
|
|
||||||
|
with patch("compliance.services.v1_enrichment.get_embedding") as mock_embed, \
|
||||||
|
patch("compliance.services.v1_enrichment.qdrant_search_cross_regulation") as mock_qdrant:
|
||||||
|
mock_embed.return_value = [0.1] * 1024
|
||||||
|
mock_qdrant.return_value = mock_qdrant_results
|
||||||
|
|
||||||
|
result = await enrich_v1_matches(dry_run=False, batch_size=100, offset=0)
|
||||||
|
|
||||||
|
assert result["dry_run"] is False
|
||||||
|
assert result["processed"] == 1
|
||||||
|
assert result["matches_inserted"] == 1
|
||||||
|
assert len(result["sample_matches"]) == 1
|
||||||
|
assert result["sample_matches"][0]["matched_control_id"] == "SEC-042"
|
||||||
|
assert result["sample_matches"][0]["similarity_score"] == 0.89
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_batch_returns_done(self):
|
||||||
|
mock_count = MagicMock(cnt=863)
|
||||||
|
|
||||||
|
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = []
|
||||||
|
db.execute.return_value.fetchone.return_value = mock_count
|
||||||
|
|
||||||
|
result = await enrich_v1_matches(dry_run=False, batch_size=100, offset=9999)
|
||||||
|
|
||||||
|
assert result["processed"] == 0
|
||||||
|
assert "alle v1 Controls verarbeitet" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1MatchesEndpoint:
|
||||||
|
"""Test the matches retrieval."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_matches(self):
|
||||||
|
mock_rows = [
|
||||||
|
MagicMock(
|
||||||
|
matched_control_id="SEC-042",
|
||||||
|
matched_title="Verschluesselung",
|
||||||
|
matched_objective="Daten verschluesseln",
|
||||||
|
matched_severity="high",
|
||||||
|
matched_category="encryption",
|
||||||
|
matched_source="DSGVO (EU) 2016/679",
|
||||||
|
matched_article="Art. 32",
|
||||||
|
matched_source_citation={"source": "DSGVO (EU) 2016/679"},
|
||||||
|
similarity_score=0.89,
|
||||||
|
match_rank=1,
|
||||||
|
match_method="embedding",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = mock_rows
|
||||||
|
|
||||||
|
result = await get_v1_matches("uuid-v1-1")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["matched_control_id"] == "SEC-042"
|
||||||
|
assert result[0]["similarity_score"] == 0.89
|
||||||
|
assert result[0]["matched_source"] == "DSGVO (EU) 2016/679"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_matches(self):
|
||||||
|
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchall.return_value = []
|
||||||
|
|
||||||
|
result = await get_v1_matches("uuid-nonexistent")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestEigenentwicklungDetection:
|
||||||
|
"""Verify the Eigenentwicklung detection query."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_count_v1_controls(self):
|
||||||
|
mock_count = MagicMock(cnt=863)
|
||||||
|
|
||||||
|
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
|
||||||
|
db = MagicMock()
|
||||||
|
mock_session.return_value.__enter__ = MagicMock(return_value=db)
|
||||||
|
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
db.execute.return_value.fetchone.return_value = mock_count
|
||||||
|
|
||||||
|
result = await count_v1_controls()
|
||||||
|
|
||||||
|
assert result == 863
|
||||||
|
# Verify the query includes all conditions
|
||||||
|
call_args = db.execute.call_args[0][0]
|
||||||
|
query_str = str(call_args)
|
||||||
|
assert "generation_strategy = 'ungrouped'" in query_str
|
||||||
|
assert "source_citation IS NULL" in query_str
|
||||||
|
assert "parent_control_uuid IS NULL" in query_str
|
||||||
@@ -1,18 +1,88 @@
|
|||||||
# Anti-Fake-Evidence Architektur
|
# Anti-Fake-Evidence Architektur
|
||||||
|
|
||||||
**Status:** Phase 2 (aktiv seit 2026-03-23)
|
**Status:** Phase 3 (aktiv seit 2026-03-23)
|
||||||
**Prefix:** CP-AFE
|
**Prefix:** CP-AFE
|
||||||
**Motivation:** Delve-Vorfall (Maerz 2026) — Compliance-Theater verhindern
|
**Motivation:** Delve-Vorfall (Maerz 2026) — Compliance-Theater verhindern
|
||||||
|
|
||||||
## Motivation
|
---
|
||||||
|
|
||||||
Der Delve-Vorfall zeigte, wie Compliance-Automation zur Haftungsfalle wird:
|
## Warum dieses System existiert
|
||||||
|
|
||||||
- LLM-generierte Inhalte wurden als echte Nachweise behandelt
|
### Das Delve-Problem
|
||||||
- Controls ohne Evidence standen auf "pass"
|
|
||||||
- 100%-Compliance-Claims ohne Validierung
|
|
||||||
|
|
||||||
Die Anti-Fake-Evidence Architektur implementiert 6 Guardrails, die sicherstellen, dass nur **nachgewiesene** Compliance als solche dargestellt wird.
|
Im Maerz 2026 wurde bekannt, wie die Compliance-Plattform Delve ein Compliance-Audit
|
||||||
|
bestand — ohne dass die Firma tatsaechlich compliant war:
|
||||||
|
|
||||||
|
1. **LLM-generierte Nachweise** wurden 1:1 als Evidence akzeptiert. Ein GPT-generierter
|
||||||
|
Text "Die Organisation hat ein ISMS implementiert" wurde als Nachweis fuer ISO 27001
|
||||||
|
Kontrolle A.5 eingesetzt — ohne dass ein ISMS existierte.
|
||||||
|
|
||||||
|
2. **Controls ohne Evidence** standen auf "pass". Das System erlaubte es, jeden Control-Status
|
||||||
|
manuell auf "pass" zu setzen, ohne dass ein einziger Nachweis hochgeladen wurde.
|
||||||
|
|
||||||
|
3. **100%-Compliance-Dashboards** ohne Validierung. Das Management sah eine gruene 100%-Anzeige
|
||||||
|
und glaubte, das Audit sei bestanden — bis der externe Auditor nach Dokumenten fragte.
|
||||||
|
|
||||||
|
### Haftungsrisiko
|
||||||
|
|
||||||
|
Wenn eine Compliance-Plattform falsche Compliance suggeriert, haftet:
|
||||||
|
|
||||||
|
- **Die Firma** — Aufsichtsbehoerden (z.B. BfDI, BaFin) akzeptieren "die Software hat gesagt
|
||||||
|
wir sind compliant" nicht als Entschuldigung
|
||||||
|
- **Die Geschaeftsfuehrung** — persoenliche Haftung bei DSGVO/NIS2-Verstoessen
|
||||||
|
- **Der Plattform-Anbieter** — wenn die Software den Eindruck erweckt, Compliance sei
|
||||||
|
nachgewiesen, obwohl sie nur Platzhalter anzeigt
|
||||||
|
|
||||||
|
### Die 6 Guardrails
|
||||||
|
|
||||||
|
Die Anti-Fake-Evidence Architektur implementiert 6 Schutzmechanismen:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
G1["1. Evidence Confidence Levels<br/>(E0-E4)"]
|
||||||
|
G2["2. Truth-Status Lifecycle<br/>(generated → validated)"]
|
||||||
|
G3["3. Control Status Machine<br/>(pass erfordert Evidence ≥ E2)"]
|
||||||
|
G4["4. Four-Eyes-Prinzip<br/>(GOV/PRIV: 2 unabhaengige Reviewer)"]
|
||||||
|
G5["5. LLM Truth Labels<br/>(may_be_used_as_evidence = false)"]
|
||||||
|
G6["6. Verbotene Formulierungen<br/>(keine Claims ohne Nachweis)"]
|
||||||
|
|
||||||
|
G1 --> G3
|
||||||
|
G2 --> G3
|
||||||
|
G4 --> G3
|
||||||
|
G5 --> G1
|
||||||
|
G3 --> G6
|
||||||
|
|
||||||
|
style G1 fill:#fee2e2,stroke:#dc2626
|
||||||
|
style G2 fill:#fef3c7,stroke:#d97706
|
||||||
|
style G3 fill:#dbeafe,stroke:#2563eb
|
||||||
|
style G4 fill:#d1fae5,stroke:#059669
|
||||||
|
style G5 fill:#e0e7ff,stroke:#4f46e5
|
||||||
|
style G6 fill:#fce7f3,stroke:#db2777
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zusammenspiel:** Ein Control kann nur auf "pass" stehen, wenn mindestens ein Evidence
|
||||||
|
mit Confidence >= E2 vorliegt, dessen Truth-Status validiert ist. Bei GOV/PRIV-Controls
|
||||||
|
muessen zwei verschiedene Personen den Evidence reviewt haben (Four-Eyes). LLM-generierte
|
||||||
|
Inhalte koennen nie als Evidence zaehlen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusammenspiel mit evidence_type
|
||||||
|
|
||||||
|
Das [evidence_type Feld](evidence-type.md) (code/process/hybrid) bestimmt,
|
||||||
|
**wie** Evidence gesammelt wird. Das Anti-Fake-Evidence System bestimmt,
|
||||||
|
**ob** die gesammelte Evidence valide ist:
|
||||||
|
|
||||||
|
| evidence_type | Typische Evidence-Quelle | Confidence | Automatisierbar? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `code` | SAST-Report, CI/CD-Pipeline, IaC-Scan | E3 (observed) | Ja — System-beobachtet |
|
||||||
|
| `process` | Policy-Dokument, Schulungsnachweis, Vertrag | E1 (uploaded) → E2 (reviewed) | Nein — Review noetig |
|
||||||
|
| `hybrid` | Code-Scan + Prozess-Doku | E1-E3 gemischt | Teilweise |
|
||||||
|
|
||||||
|
!!! warning "Code Controls sind nicht automatisch valide"
|
||||||
|
Auch ein SAST-Report (E3) muss einen validen Truth-Status haben.
|
||||||
|
Ein veralteter Scan (> 90 Tage) wird als "stale" markiert und reduziert
|
||||||
|
die Evidence Freshness im Dashboard-Score.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -458,3 +528,286 @@ Neue Seite unter `/sdk/assertions` mit 3 Tabs:
|
|||||||
| Methode | Pfad | Beschreibung |
|
| Methode | Pfad | Beschreibung |
|
||||||
|---------|------|--------------|
|
|---------|------|--------------|
|
||||||
| GET | `/dashboard/evidence-distribution` | Evidence-Verteilung nach Confidence + Four-Eyes-Status |
|
| GET | `/dashboard/evidence-distribution` | Evidence-Verteilung nach Confidence + Four-Eyes-Status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitsarchitektur im Detail
|
||||||
|
|
||||||
|
### Warum E0-E4 und nicht einfach "valide/invalide"?
|
||||||
|
|
||||||
|
Ein binaeres System ("valide" vs. "invalide") haette ein fundamentales Problem:
|
||||||
|
Wo zieht man die Grenze? Ein manuell hochgeladenes PDF ist "besser" als ein
|
||||||
|
LLM-Entwurf, aber "schlechter" als ein automatischer SAST-Report.
|
||||||
|
|
||||||
|
Die 5-stufige Skala erlaubt:
|
||||||
|
|
||||||
|
1. **Graduelle Verbesserung**: Ein Kunde kann mit E1-Evidence starten und
|
||||||
|
schrittweise auf E3/E4 aufruesten
|
||||||
|
2. **Risiko-basierte Entscheidungen**: Ein Auditor sieht sofort, welche
|
||||||
|
Controls nur auf schwacher Evidence basieren
|
||||||
|
3. **Automatische Schwellwerte**: Das System blockiert "pass" unterhalb E2,
|
||||||
|
aber zeigt auch E1-Evidence (mit Warnung)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
E0["E0<br/>Generated<br/>❌ Kein Nachweis"]
|
||||||
|
E1["E1<br/>Uploaded<br/>⚠️ Ungeprüft"]
|
||||||
|
E2["E2<br/>Reviewed<br/>✓ Intern geprüft"]
|
||||||
|
E3["E3<br/>Observed<br/>✓✓ System-beobachtet"]
|
||||||
|
E4["E4<br/>Auditor<br/>✓✓✓ Extern validiert"]
|
||||||
|
|
||||||
|
E0 --> E1 --> E2 --> E3 --> E4
|
||||||
|
|
||||||
|
style E0 fill:#fee2e2,stroke:#dc2626
|
||||||
|
style E1 fill:#fef3c7,stroke:#d97706
|
||||||
|
style E2 fill:#dbeafe,stroke:#2563eb
|
||||||
|
style E3 fill:#d1fae5,stroke:#059669
|
||||||
|
style E4 fill:#d1fae5,stroke:#047857
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confidence-Gewichtung im Score
|
||||||
|
|
||||||
|
| Level | Gewicht | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| E0 | 0.00 | Zaehlt nicht als Evidence |
|
||||||
|
| E1 | 0.25 | Minimal — ungepruefte Uploads |
|
||||||
|
| E2 | 0.50 | Schwelle fuer "pass"-Status |
|
||||||
|
| E3 | 0.75 | Stark — system-verifiziert |
|
||||||
|
| E4 | 1.00 | Perfekt — extern validiert |
|
||||||
|
|
||||||
|
### Warum Four-Eyes nur fuer GOV und PRIV?
|
||||||
|
|
||||||
|
Das Four-Eyes-Prinzip erhoert den Aufwand erheblich (jedes Evidence braucht
|
||||||
|
zwei verschiedene Reviewer). Deshalb wird es gezielt nur fuer Domains eingesetzt,
|
||||||
|
bei denen Manipulation besonders gefaehrlich ist:
|
||||||
|
|
||||||
|
| Domain | Four-Eyes | Begruendung |
|
||||||
|
|---|---|---|
|
||||||
|
| **GOV** (Governance) | Ja | Governance-Controls definieren das Compliance-Framework selbst. Wenn hier manipuliert wird, ist alles darunter wertlos. |
|
||||||
|
| **PRIV** (Datenschutz) | Ja | DSGVO-Nachweise sind rechtlich bindend. Falsche Nachweise koennen zu Bussgeldern fuehren. |
|
||||||
|
| SEC, AUTH, NET, ... | Nein | Technische Controls sind oft automatisch pruefbar (E3). Der Aufwand von Four-Eyes waere unverhältnismäßig. |
|
||||||
|
|
||||||
|
### Same-Person-Schutz
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Der zweite Reviewer MUSS eine andere Person sein:
|
||||||
|
if review.reviewed_by == evidence.first_reviewer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Four-Eyes: second reviewer must be different from first reviewer"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dies verhindert, dass eine einzelne Person ein Evidence "durchwinkt". Der Schutz
|
||||||
|
ist auf DB-Ebene durchgesetzt — nicht nur im Frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard Blocks — Audit-Sperren
|
||||||
|
|
||||||
|
Hard Blocks sind **absolute Sperren**, die eine Audit-Readiness verhindern.
|
||||||
|
Sie werden im Dashboard prominent rot angezeigt.
|
||||||
|
|
||||||
|
### Block 1: Controls auf "pass" ohne Evidence
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Controls die "pass" oder "partial" sind, aber keine Evidence haben
|
||||||
|
SELECT control_id FROM compliance_controls
|
||||||
|
WHERE status IN ('pass', 'partial')
|
||||||
|
AND id NOT IN (SELECT control_id FROM compliance_evidence)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum kritisch:** Ein Control auf "pass" ohne jeden Nachweis ist die Definition
|
||||||
|
von Compliance-Theater. Der Auditor wird sofort fragen: "Wo ist der Nachweis?"
|
||||||
|
|
||||||
|
### Block 2: Controls auf "pass" mit nur E0/E1-Evidence
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Controls auf "pass" deren beste Evidence nur E0 oder E1 ist
|
||||||
|
SELECT c.control_id FROM compliance_controls c
|
||||||
|
JOIN compliance_evidence e ON e.control_id = c.id
|
||||||
|
WHERE c.status = 'pass'
|
||||||
|
GROUP BY c.control_id
|
||||||
|
HAVING MAX(CASE
|
||||||
|
WHEN e.confidence_level = 'E4' THEN 4
|
||||||
|
WHEN e.confidence_level = 'E3' THEN 3
|
||||||
|
WHEN e.confidence_level = 'E2' THEN 2
|
||||||
|
WHEN e.confidence_level = 'E1' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END) < 2 -- Nur E0 oder E1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum kritisch:** Ein LLM-generierter Text (E0) oder ein ungeprüeftes Upload (E1)
|
||||||
|
reicht nicht aus, um Compliance zu behaupten. Mindestens ein intern geprüeftes
|
||||||
|
Dokument (E2) ist erforderlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datenbank-Schema (Komplett)
|
||||||
|
|
||||||
|
### ENUM-Typen
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TYPE evidence_confidence_level AS ENUM (
|
||||||
|
'E0', -- Generated / kein echter Nachweis
|
||||||
|
'E1', -- Hochgeladen, ungeprüeft
|
||||||
|
'E2', -- Intern geprüeft, Hash verifiziert
|
||||||
|
'E3', -- System-beobachtet (CI/CD, API mit Hash)
|
||||||
|
'E4' -- Extern validiert (Auditor)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE evidence_truth_status AS ENUM (
|
||||||
|
'generated', -- LLM/System-generiert
|
||||||
|
'uploaded', -- Manuell hochgeladen
|
||||||
|
'observed', -- Automatisch beobachtet
|
||||||
|
'validated_internal', -- Intern geprüeft + bestätigt
|
||||||
|
'rejected', -- Abgelehnt
|
||||||
|
'provided_to_auditor', -- An Auditor üebergeben
|
||||||
|
'accepted_by_auditor' -- Auditor hat akzeptiert
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### compliance_evidence — Erweiterte Spalten
|
||||||
|
|
||||||
|
| Spalte | Typ | Default | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| confidence_level | ENUM | E1 | Vertrauensstufe (E0-E4) |
|
||||||
|
| truth_status | ENUM | uploaded | Wahrheitsstatus |
|
||||||
|
| generation_mode | VARCHAR(100) | NULL | 'draft_assistance', 'auto_generation' |
|
||||||
|
| may_be_used_as_evidence | BOOLEAN | TRUE | FALSE fuer LLM-Output |
|
||||||
|
| reviewed_by | VARCHAR(200) | NULL | Reviewer E-Mail |
|
||||||
|
| reviewed_at | TIMESTAMPTZ | NULL | Zeitpunkt des Reviews |
|
||||||
|
| approval_status | VARCHAR(30) | 'none' | Four-Eyes Status |
|
||||||
|
| first_reviewer | VARCHAR(200) | NULL | Erster Reviewer |
|
||||||
|
| first_reviewed_at | TIMESTAMPTZ | NULL | Zeitpunkt erster Review |
|
||||||
|
| second_reviewer | VARCHAR(200) | NULL | Zweiter Reviewer (muss anders sein!) |
|
||||||
|
| second_reviewed_at | TIMESTAMPTZ | NULL | Zeitpunkt zweiter Review |
|
||||||
|
| requires_four_eyes | BOOLEAN | FALSE | Ob Four-Eyes-Pflicht besteht |
|
||||||
|
|
||||||
|
### compliance_llm_generation_audit
|
||||||
|
|
||||||
|
Jeder LLM-generierte Inhalt wird in dieser Tabelle protokolliert:
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| id | VARCHAR(36) | PK |
|
||||||
|
| tenant_id | VARCHAR(36) | Mandant |
|
||||||
|
| entity_type | VARCHAR(50) | 'evidence', 'control', 'document' |
|
||||||
|
| entity_id | VARCHAR(36) | FK zur generierten Entitaet |
|
||||||
|
| generation_mode | VARCHAR(100) | 'draft_assistance', 'auto_generation' |
|
||||||
|
| truth_status | ENUM | Default: 'generated' |
|
||||||
|
| may_be_used_as_evidence | BOOLEAN | Default: FALSE |
|
||||||
|
| llm_model | VARCHAR(100) | z.B. 'qwen3:30b-a3b' |
|
||||||
|
| llm_provider | VARCHAR(50) | 'ollama', 'anthropic' |
|
||||||
|
| prompt_hash | VARCHAR(64) | SHA-256 des Prompts |
|
||||||
|
| input_summary | TEXT | Zusammenfassung des Inputs |
|
||||||
|
| output_summary | TEXT | Zusammenfassung des Outputs |
|
||||||
|
|
||||||
|
### compliance_assertions
|
||||||
|
|
||||||
|
Die Assertion Engine trennt Behauptungen von Fakten:
|
||||||
|
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| id | VARCHAR(36) | PK |
|
||||||
|
| tenant_id | VARCHAR(36) | Mandant |
|
||||||
|
| entity_type | VARCHAR(50) | 'control', 'evidence', 'document', 'obligation' |
|
||||||
|
| entity_id | VARCHAR(36) | FK zur Entitaet |
|
||||||
|
| sentence_text | TEXT | Originalsatz |
|
||||||
|
| sentence_index | INTEGER | Position im Text |
|
||||||
|
| assertion_type | VARCHAR(20) | 'assertion', 'fact', 'rationale' |
|
||||||
|
| evidence_ids | JSONB | Verlinkte Evidence-IDs |
|
||||||
|
| confidence | FLOAT | 0.0-1.0 Konfidenz |
|
||||||
|
| normative_tier | VARCHAR(20) | 'pflicht', 'empfehlung', 'kann' |
|
||||||
|
| verified_by | VARCHAR(200) | Verifizierer |
|
||||||
|
| verified_at | TIMESTAMPTZ | Zeitpunkt der Verifikation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vollstaendige API-Referenz
|
||||||
|
|
||||||
|
### Evidence-Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung | Auth |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/evidence` | Evidence auflisten (mit Filtern) | Tenant |
|
||||||
|
| POST | `/evidence` | Neues Evidence erstellen | Tenant |
|
||||||
|
| DELETE | `/evidence/{id}` | Evidence loeschen | Tenant |
|
||||||
|
| POST | `/evidence/upload` | Evidence-Datei hochladen | Tenant |
|
||||||
|
| POST | `/evidence/collect` | CI/CD Evidence sammeln (automatisch) | API-Key |
|
||||||
|
| GET | `/evidence/ci-status` | CI/CD Evidence Statusuebersicht | Tenant |
|
||||||
|
| **PATCH** | **`/evidence/{id}/review`** | **Evidence reviewen (Four-Eyes)** | Tenant |
|
||||||
|
| **PATCH** | **`/evidence/{id}/reject`** | **Evidence ablehnen** | Tenant |
|
||||||
|
|
||||||
|
### Audit-Trail-Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/audit-trail` | Audit-Trail abfragen (entity_type, entity_id, action) |
|
||||||
|
|
||||||
|
### Assertion-Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/assertions` | Assertion manuell erstellen |
|
||||||
|
| GET | `/assertions` | Assertions auflisten |
|
||||||
|
| GET | `/assertions/{id}` | Assertion Detail |
|
||||||
|
| PUT | `/assertions/{id}` | Assertion aktualisieren |
|
||||||
|
| POST | `/assertions/{id}/verify` | Als Fakt markieren |
|
||||||
|
| POST | `/assertions/extract` | Automatische Extraktion aus Freitext |
|
||||||
|
| GET | `/assertions/summary` | Stats (total, facts, rationale, unverified) |
|
||||||
|
|
||||||
|
### LLM-Audit-Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/llm-audit` | LLM-Generierungs-Audit erstellen |
|
||||||
|
| GET | `/llm-audit` | LLM-Audit-Eintraege auflisten |
|
||||||
|
|
||||||
|
### Dashboard-Endpoints
|
||||||
|
|
||||||
|
| Methode | Pfad | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/dashboard/evidence-distribution` | Confidence-Verteilung + Four-Eyes-Status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Haeufige Fragen
|
||||||
|
|
||||||
|
### Kann ich E0-Evidence loeschen?
|
||||||
|
|
||||||
|
Ja, aber es bleibt ein Eintrag in `compliance_llm_generation_audit`. Das stellt sicher,
|
||||||
|
dass die Generierung nachvollziehbar ist, auch wenn der LLM-Output geloescht wurde.
|
||||||
|
|
||||||
|
### Was passiert, wenn der erste Reviewer nicht verfuegbar ist?
|
||||||
|
|
||||||
|
Das Four-Eyes-System hat keinen Timeout. Der erste Review bleibt als `first_approved`
|
||||||
|
gespeichert, bis ein zweiter Reviewer die Evidence prueft. Das ist bewusst so —
|
||||||
|
Compliance-Nachweise sollten nicht durch Zeitdruck kompromittiert werden.
|
||||||
|
|
||||||
|
### Kann ein Admin das Four-Eyes-Prinzip umgehen?
|
||||||
|
|
||||||
|
Nein. Der Same-Person-Check ist auf Backend-Ebene implementiert (HTTP 400 bei
|
||||||
|
gleichem Reviewer). Es gibt keine Admin-Bypass-Option. Dies ist ein bewusstes
|
||||||
|
Design-Prinzip: Wenn selbst der Admin das System umgehen koennte, waere die
|
||||||
|
gesamte Integritaet gefaehrdet.
|
||||||
|
|
||||||
|
### Wie funktioniert die Assertion-Extraktion?
|
||||||
|
|
||||||
|
Der Endpoint `POST /assertions/extract` nimmt einen Freitext und:
|
||||||
|
|
||||||
|
1. **Satz-Splitting**: Teilt den Text an `.!?` gefolgt von Grossbuchstaben
|
||||||
|
2. **Klassifikation**: Prueft jeden Satz auf normative Signal-Woerter
|
||||||
|
3. **Normative Tiers**: "muss/shall/must" → pflicht, "soll/should" → empfehlung, "kann/may" → kann
|
||||||
|
4. **Evidence-Keywords**: Woerter wie "liegt vor", "wurde geprueft" → als tentative Fakten markiert
|
||||||
|
5. **Ergebnis**: Liste von Assertions mit Typ und Normative-Tier
|
||||||
|
|
||||||
|
### Was ist der Unterschied zwischen Truth-Status und Confidence?
|
||||||
|
|
||||||
|
- **Confidence** (E0-E4) sagt: Wie vertrauenswuerdig ist die **Quelle**?
|
||||||
|
(LLM < Upload < Review < CI/CD < Auditor)
|
||||||
|
- **Truth-Status** sagt: In welchem **Lifecycle-Zustand** ist der Nachweis?
|
||||||
|
(generated → uploaded → validated → accepted_by_auditor)
|
||||||
|
|
||||||
|
Ein E3-Evidence (CI/CD) kann Truth-Status "observed" haben und ist damit sofort
|
||||||
|
verwendbar. Ein E1-Evidence (Upload) muss erst "validated_internal" werden.
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ erDiagram
|
|||||||
| `POST` | `/v1/canonical/generate/backfill-domain` | Domain/Category/Target-Audience nachpflegen (Anthropic) |
|
| `POST` | `/v1/canonical/generate/backfill-domain` | Domain/Category/Target-Audience nachpflegen (Anthropic) |
|
||||||
| `GET` | `/v1/canonical/blocked-sources` | Gesperrte Quellen (Rule 3) |
|
| `GET` | `/v1/canonical/blocked-sources` | Gesperrte Quellen (Rule 3) |
|
||||||
| `POST` | `/v1/canonical/blocked-sources/cleanup` | Cleanup-Workflow starten |
|
| `POST` | `/v1/canonical/blocked-sources/cleanup` | Cleanup-Workflow starten |
|
||||||
|
| `POST` | `/v1/canonical/obligations/dedup` | Obligation-Duplikate markieren (dry_run, batch_size, offset) |
|
||||||
|
| `GET` | `/v1/canonical/obligations/dedup-stats` | Dedup-Statistik (total, by_state, pending) |
|
||||||
|
|
||||||
### Beispiel: Control abrufen
|
### Beispiel: Control abrufen
|
||||||
|
|
||||||
@@ -984,6 +986,37 @@ vom Parent-Obligation uebernommen.
|
|||||||
**Datei:** `compliance/services/decomposition_pass.py`
|
**Datei:** `compliance/services/decomposition_pass.py`
|
||||||
**Test-Script:** `scripts/qa/test_pass0a.py` (standalone, speichert JSON)
|
**Test-Script:** `scripts/qa/test_pass0a.py` (standalone, speichert JSON)
|
||||||
|
|
||||||
|
#### Obligation Deduplizierung
|
||||||
|
|
||||||
|
Die Decomposition-Pipeline erzeugt pro Rich Control mehrere Obligation Candidates.
|
||||||
|
Durch Wiederholungen in der Pipeline koennen identische `candidate_id`-Eintraege
|
||||||
|
mehrfach existieren (z.B. 5x `OC-AUTH-839-01` mit leicht unterschiedlichem Text).
|
||||||
|
|
||||||
|
**Dedup-Strategie:** Pro `candidate_id` wird der aelteste Eintrag (`MIN(created_at)`)
|
||||||
|
behalten. Alle anderen erhalten:
|
||||||
|
|
||||||
|
- `release_state = 'duplicate'`
|
||||||
|
- `merged_into_id` → UUID des behaltenen Eintrags
|
||||||
|
- `quality_flags.dedup_reason` → z.B. `"duplicate of OC-AUTH-839-01"`
|
||||||
|
|
||||||
|
**Endpunkte:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry Run — zaehlt betroffene Duplikat-Gruppen
|
||||||
|
curl -X POST "https://macmini:8002/api/compliance/v1/canonical/obligations/dedup?dry_run=true"
|
||||||
|
|
||||||
|
# Ausfuehren — markiert alle Duplikate
|
||||||
|
curl -X POST "https://macmini:8002/api/compliance/v1/canonical/obligations/dedup?dry_run=false"
|
||||||
|
|
||||||
|
# Statistiken
|
||||||
|
curl "https://macmini:8002/api/compliance/v1/canonical/obligations/dedup-stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stand (2026-03-26):** 76.046 Obligations gesamt, davon 34.617 als `duplicate` markiert.
|
||||||
|
41.043 aktive Obligations verbleiben (composed + validated).
|
||||||
|
|
||||||
|
**Migration:** `081_obligation_dedup_state.sql` — Fuegt `'duplicate'` zum `release_state` Constraint hinzu.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Migration Passes (1-5)
|
### Migration Passes (1-5)
|
||||||
@@ -1033,6 +1066,9 @@ Die Crosswalk-Matrix bildet diese N:M-Beziehung ab.
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `obligation_candidates` | Extrahierte atomare Pflichten aus Rich Controls |
|
| `obligation_candidates` | Extrahierte atomare Pflichten aus Rich Controls |
|
||||||
| `obligation_candidates.obligation_type` | `pflicht` / `empfehlung` / `kann` (3-Tier-Klassifizierung) |
|
| `obligation_candidates.obligation_type` | `pflicht` / `empfehlung` / `kann` (3-Tier-Klassifizierung) |
|
||||||
|
| `obligation_candidates.release_state` | `extracted` / `validated` / `rejected` / `composed` / `merged` / `duplicate` |
|
||||||
|
| `obligation_candidates.merged_into_id` | UUID des behaltenen Eintrags (bei `duplicate`/`merged`) |
|
||||||
|
| `obligation_candidates.quality_flags` | JSONB mit Metadaten (u.a. `dedup_reason`, `dedup_kept_id`) |
|
||||||
| `canonical_controls.parent_control_uuid` | Self-Referenz zum Rich Control (neues Feld) |
|
| `canonical_controls.parent_control_uuid` | Self-Referenz zum Rich Control (neues Feld) |
|
||||||
| `canonical_controls.decomposition_method` | Zerlegungsmethode (neues Feld) |
|
| `canonical_controls.decomposition_method` | Zerlegungsmethode (neues Feld) |
|
||||||
| `canonical_controls.obligation_type` | Uebernommen von Obligation: pflicht/empfehlung/kann |
|
| `canonical_controls.obligation_type` | Uebernommen von Obligation: pflicht/empfehlung/kann |
|
||||||
|
|||||||
132
docs-src/services/sdk-modules/evidence-type.md
Normal file
132
docs-src/services/sdk-modules/evidence-type.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Evidence Type — Code vs. Prozess Controls
|
||||||
|
|
||||||
|
## Uebersicht
|
||||||
|
|
||||||
|
Nicht jedes Control kann gleich nachgewiesen werden. Ein Verschluesselungs-Control
|
||||||
|
ist im Quellcode pruefbar, ein Risikomanagement-Control erfordert Dokumente und
|
||||||
|
Nachweise. Diese Unterscheidung bestimmt, **wie** die Compliance-Plattform den
|
||||||
|
Nachweis automatisiert oder den Nutzer unterstuetzt.
|
||||||
|
|
||||||
|
Das Feld `evidence_type` auf `canonical_controls` klassifiziert jeden Control
|
||||||
|
in eine von drei Kategorien.
|
||||||
|
|
||||||
|
## Die drei Typen
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "code"
|
||||||
|
C1["Source Code"]
|
||||||
|
C2["IaC / Terraform"]
|
||||||
|
C3["CI/CD Pipeline"]
|
||||||
|
C4["Cloud Config"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "process"
|
||||||
|
P1["Policies"]
|
||||||
|
P2["Schulungsnachweise"]
|
||||||
|
P3["Vertraege"]
|
||||||
|
P4["Screenshots / Audits"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "hybrid"
|
||||||
|
H1["Code + Doku"]
|
||||||
|
H2["Config + Schulung"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Controls
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **evidence_type** | `code` |
|
||||||
|
| **Nachweis** | Automatisiert pruefbar: Source Code, IaC, CI/CD, Cloud-Konfiguration |
|
||||||
|
| **Automatisierung** | Hoch — SAST, Dependency Scan, Config Audit |
|
||||||
|
| **Beispiel-Domains** | SEC, AUTH, CRYPT, NET, LOG, ACC, API |
|
||||||
|
| **Beispiel** | "AES-256 Verschluesselung at rest" → pruefbar via Code Review / IaC Scan |
|
||||||
|
|
||||||
|
### Prozess Controls
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **evidence_type** | `process` |
|
||||||
|
| **Nachweis** | Dokumente, Policies, Schulungsnachweise, Vertraege, Screenshots |
|
||||||
|
| **Automatisierung** | Gering — erfordert manuelle Uploads oder MCP-Dokumenten-Scan |
|
||||||
|
| **Beispiel-Domains** | GOV, ORG, COMP, LEGAL, HR, FIN, RISK, AUDIT, ENV |
|
||||||
|
| **Beispiel** | "Reallabor-Zugang fuer KMUs bereitstellen" → Nachweis ueber Programm-Dokumentation |
|
||||||
|
|
||||||
|
!!! info "Governance & Regulatorische Controls"
|
||||||
|
Controls wie "Behoerde muss KMUs Zugang zu Reallaboren geben" sind Prozess-Controls.
|
||||||
|
Der Nachweis erfolgt ueber Dokumente — nicht im Source Code.
|
||||||
|
Auch regulatorische Umsetzungspflichten (GOV-Domain) fallen hierunter.
|
||||||
|
|
||||||
|
### Hybrid Controls
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **evidence_type** | `hybrid` |
|
||||||
|
| **Nachweis** | Sowohl Code als auch Dokumente erforderlich |
|
||||||
|
| **Automatisierung** | Teilweise — Code-Teil automatisiert, Prozess-Teil manuell |
|
||||||
|
| **Beispiel-Domains** | DATA, AI, INC, IAM |
|
||||||
|
| **Beispiel** | "MFA implementieren" → Config pruefbar (code) + Nutzer-Schulung noetig (process) |
|
||||||
|
|
||||||
|
## Backfill-Heuristik
|
||||||
|
|
||||||
|
Der Backfill klassifiziert Controls automatisch anhand des Domain-Prefix:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/compliance/v1/canonical/controls/backfill-evidence-type?dry_run=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Algorithmus:**
|
||||||
|
|
||||||
|
1. Domain-Prefix extrahieren (z.B. `SEC` aus `SEC-042`)
|
||||||
|
2. Gegen vordefinierte Domain-Sets pruefen (code / process / hybrid)
|
||||||
|
3. Falls Domain unbekannt: Category als Fallback nutzen
|
||||||
|
4. Falls auch keine Category: `process` (konservativ)
|
||||||
|
|
||||||
|
### Domain-Zuordnung
|
||||||
|
|
||||||
|
| Typ | Domains |
|
||||||
|
|---|---|
|
||||||
|
| **code** | SEC, AUTH, CRYPT, CRYP, NET, LOG, ACC, APP, SYS, API, WEB, DEV, SDL, PKI, HSM, TEE, TPM, VUL, ... |
|
||||||
|
| **process** | GOV, ORG, COMP, LEGAL, HR, FIN, RISK, AUDIT, ENV, HLT, TRD, LAB, PHYS, PRIV, DPO, ... |
|
||||||
|
| **hybrid** | DATA, AI, INC, IAM, OPS, MNT, INT, ... |
|
||||||
|
|
||||||
|
## Frontend-Anzeige
|
||||||
|
|
||||||
|
In der Control-Library werden Controls mit farbcodierten Badges angezeigt:
|
||||||
|
|
||||||
|
| evidence_type | Badge | Farbe | Bedeutung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `code` | **Code** | Blau (sky) | Technisch, im Source Code pruefbar |
|
||||||
|
| `process` | **Prozess** | Amber/Orange | Organisatorisch, Dokument-basiert |
|
||||||
|
| `hybrid` | **Hybrid** | Violett | Beides erforderlich |
|
||||||
|
|
||||||
|
Zusaetzlich steht ein Dropdown-Filter "Nachweisart" zur Verfuegung.
|
||||||
|
|
||||||
|
## Zusammenspiel mit anderen Feldern
|
||||||
|
|
||||||
|
| Feld | Zweck | Beispiel |
|
||||||
|
|---|---|---|
|
||||||
|
| `evidence_type` | **WAS** wird nachgewiesen (Code oder Prozess) | `code` |
|
||||||
|
| `verification_method` | **WIE** wird verifiziert | `code_review`, `document`, `tool`, `hybrid` |
|
||||||
|
| `evidence_confidence` | **WIE SICHER** ist der Nachweis (0.0 - 1.0) | `0.92` |
|
||||||
|
| `normative_strength` | **WIE VERBINDLICH** ist das Control | `must`, `should`, `may` |
|
||||||
|
|
||||||
|
!!! warning "evidence_type vs. verification_method"
|
||||||
|
`evidence_type` sagt, ob ein Control technisch oder organisatorisch ist.
|
||||||
|
`verification_method` sagt, mit welcher Methode es geprueft wird.
|
||||||
|
Ein `process`-Control kann trotzdem `verification_method = tool` haben
|
||||||
|
(z.B. wenn ein MCP-Scan Dokumente automatisch prueft).
|
||||||
|
|
||||||
|
## Kuenftige Automatisierung
|
||||||
|
|
||||||
|
### Code Controls
|
||||||
|
- **Git-Repository-Scan**: SAST, Secret Detection, Dependency Check
|
||||||
|
- **IaC-Analyse**: Terraform/Pulumi/CloudFormation Policies
|
||||||
|
- **CI/CD-Integration**: Pipeline-Ergebnisse als Evidence sammeln
|
||||||
|
|
||||||
|
### Prozess Controls
|
||||||
|
- **MCP-Dokumenten-Scan**: Kunden-Laufwerk anbinden, Dokumente automatisch pruefen
|
||||||
|
- **Screenshot-Analyse**: OCR + LLM-Validierung von Screenshots
|
||||||
|
- **Interview-Protokolle**: Strukturierte Audit-Checklisten
|
||||||
201
docs-src/services/sdk-modules/normative-verbindlichkeit.md
Normal file
201
docs-src/services/sdk-modules/normative-verbindlichkeit.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Normative Verbindlichkeit — Dreistufenmodell
|
||||||
|
|
||||||
|
## Uebersicht
|
||||||
|
|
||||||
|
Nicht jede Quelle, aus der Controls abgeleitet werden, hat die gleiche rechtliche
|
||||||
|
Verbindlichkeit. Ein Control, das aus einem EU-Gesetz stammt, hat ein anderes
|
||||||
|
Gewicht als eines aus einem freiwilligen Framework.
|
||||||
|
|
||||||
|
Das Dreistufenmodell klassifiziert jede Quell-Regulierung und leitet daraus die
|
||||||
|
**effektive normative Staerke** der daraus erzeugten Obligations ab.
|
||||||
|
|
||||||
|
## Die drei Stufen
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Stufe 1 — GESETZ (law)"
|
||||||
|
direction LR
|
||||||
|
A1["DSGVO, NIS2, AI Act, CRA..."]
|
||||||
|
A2["Rechtlich bindend"]
|
||||||
|
A3["Bussgeld bei Verstoss"]
|
||||||
|
A4["normative_strength: must/should/may"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Stufe 2 — LEITLINIE (guideline)"
|
||||||
|
direction LR
|
||||||
|
B1["EDPB-Leitlinien, BSI-TR, WP29"]
|
||||||
|
B2["Offizielle Auslegungshilfe"]
|
||||||
|
B3["Beweislastumkehr"]
|
||||||
|
B4["max normative_strength: should"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Stufe 3 — FRAMEWORK (framework)"
|
||||||
|
direction LR
|
||||||
|
C1["ENISA, NIST, OWASP, OECD"]
|
||||||
|
C2["Freiwillige Best Practice"]
|
||||||
|
C3["Stand der Technik"]
|
||||||
|
C4["max normative_strength: can"]
|
||||||
|
end
|
||||||
|
|
||||||
|
A1 --> A2 --> A3 --> A4
|
||||||
|
B1 --> B2 --> B3 --> B4
|
||||||
|
C1 --> C2 --> C3 --> C4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stufe 1: Gesetz (law)
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **Verbindlichkeit** | Rechtlich bindend, Bussgeld bei Verstoss |
|
||||||
|
| **normative_strength** | Bleibt wie im Gesetzestext: `must`, `should` oder `may` |
|
||||||
|
| **Beispiele** | DSGVO (EU) 2016/679, NIS2-Richtlinie, KI-Verordnung, CRA, BDSG |
|
||||||
|
| **Warum relevant** | "Sie MUESSEN angemessene technische Massnahmen ergreifen" (Art. 32 DSGVO) |
|
||||||
|
|
||||||
|
!!! warning "Wichtig"
|
||||||
|
Gesetze formulieren Pflichten **abstrakt**. Art. 32 DSGVO sagt:
|
||||||
|
"dem Stand der Technik entsprechende Massnahmen" — aber NICHT
|
||||||
|
"verwende AES-256". Das WAS ist Pflicht, das WIE bleibt offen.
|
||||||
|
|
||||||
|
### Stufe 2: Leitlinie (guideline)
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **Verbindlichkeit** | Nicht direkt bindend, aber Beweislastumkehr |
|
||||||
|
| **normative_strength** | Maximal `should` — auch wenn die Leitlinie intern "must" schreibt |
|
||||||
|
| **Beispiele** | EDPB-Leitlinien, BSI Technische Richtlinien, WP29-Dokumente |
|
||||||
|
| **Warum relevant** | "Daten at rest muessen verschluesselt werden" (BSI-TR) → `should` |
|
||||||
|
|
||||||
|
!!! info "Beweislastumkehr"
|
||||||
|
Wenn eine Aufsichtsbehoerde fragt "Warum verschluesselt ihr nicht?",
|
||||||
|
muss die Firma begruenden, warum sie von der Leitlinie abweicht.
|
||||||
|
Die Firma muss aber nicht genau so verschluesseln wie die BSI vorschlaegt.
|
||||||
|
|
||||||
|
### Stufe 3: Framework (framework)
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| **Verbindlichkeit** | Freiwillig, nicht rechtsverbindlich |
|
||||||
|
| **normative_strength** | Maximal `can` — unabhaengig von interner Sprache |
|
||||||
|
| **Beispiele** | ENISA CCM, NIST CSF, OWASP Top 10, OECD KI-Empfehlung |
|
||||||
|
| **Warum relevant** | "Organizations SHALL implement..." (ENISA) → `can` fuer den Anwender |
|
||||||
|
|
||||||
|
!!! tip "Stand der Technik"
|
||||||
|
NIS2 Art. 21 verweist auf ENISA-Leitlinien als Referenz fuer den
|
||||||
|
"Stand der Technik". Das hebt ENISA-Controls faktisch auf Stufe 2 (`should`)
|
||||||
|
— aber nur im Kontext von NIS2-pflichtigen Unternehmen, nicht generell.
|
||||||
|
|
||||||
|
## Ableitungskette
|
||||||
|
|
||||||
|
Die vollstaendige Kette von der Rechtsquelle zum atomaren Control:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
R["Regulierung<br/>(DSGVO Art. 32)"] -->|"MUSS"| O["Obligation<br/>(Daten schuetzen)"]
|
||||||
|
O -->|decomposition| RC["Rich Control<br/>(Verschluesselung)"]
|
||||||
|
RC -->|pass0b| AC["Atomares Control<br/>(AES-256 at rest)"]
|
||||||
|
|
||||||
|
R2["Framework<br/>(ENISA CCM)"] -->|"KANN"| AC
|
||||||
|
|
||||||
|
style R fill:#fee2e2,stroke:#dc2626
|
||||||
|
style R2 fill:#dbeafe,stroke:#2563eb
|
||||||
|
style O fill:#fef3c7,stroke:#d97706
|
||||||
|
style RC fill:#e0e7ff,stroke:#4f46e5
|
||||||
|
style AC fill:#d1fae5,stroke:#059669
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel**: Das atomare Control "AES-256 Verschluesselung at rest"
|
||||||
|
|
||||||
|
- Aus DSGVO Art. 32 abgeleitet → Obligation "must secure data" → **MUSS** (die Pflicht zu schuetzen)
|
||||||
|
- Aus ENISA CCM konkretisiert → **KANN** (AES-256 ist *eine* moegliche Umsetzung)
|
||||||
|
- Resultat: Die Firma MUSS verschluesseln, KANN aber waehlen wie
|
||||||
|
|
||||||
|
## Multi-Parent-Links
|
||||||
|
|
||||||
|
Ein atomares Control kann aus mehreren Quellen stammen:
|
||||||
|
|
||||||
|
| Control | Parent 1 | Parent 2 | Parent 3 | Effektive Staerke |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SEC-042 (Encrypt at rest) | DSGVO Art. 32 (law) | NIS2 Art. 21 (law) | ENISA CCM (framework) | **must** (Gesetz uebertrumpft) |
|
||||||
|
| NET-015 (Zero Trust) | NIST SP 800-207 (framework) | CISA (framework) | — | **can** (nur Frameworks) |
|
||||||
|
| AUTH-003 (MFA) | DSGVO Art. 32 (law) | BSI-TR (guideline) | OWASP ASVS (framework) | **must** (Gesetz vorhanden) |
|
||||||
|
|
||||||
|
**Regel**: Der hoechste source_type bestimmt, ob die normative_strength begrenzt wird.
|
||||||
|
Wenn mindestens ein Parent-Link ein Gesetz ist, bleibt die Staerke wie extrahiert.
|
||||||
|
|
||||||
|
## Technische Umsetzung
|
||||||
|
|
||||||
|
### Klassifikations-Map
|
||||||
|
|
||||||
|
Datei: `backend-compliance/compliance/data/source_type_classification.py`
|
||||||
|
|
||||||
|
Jeder `source_regulation`-Wert aus `control_parent_links` wird klassifiziert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SOURCE_REGULATION_CLASSIFICATION = {
|
||||||
|
"DSGVO (EU) 2016/679": "law",
|
||||||
|
"EDPB Leitlinien 01/2020 (Datentransfers)": "guideline",
|
||||||
|
"NIST Cybersecurity Framework 2.0": "framework",
|
||||||
|
# ... 55+ Eintraege
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backfill-Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/compliance/v1/canonical/controls/backfill-normative-strength?dry_run=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
|
||||||
|
1. Alle aktiven `obligation_candidates` laden
|
||||||
|
2. Fuer jede Obligation den Parent-Control finden
|
||||||
|
3. Ueber `control_parent_links` die source_regulations ermitteln
|
||||||
|
4. Hoechsten source_type bestimmen
|
||||||
|
5. `normative_strength` begrenzen falls noetig
|
||||||
|
6. Bei `dry_run=false`: Aenderungen in die DB schreiben
|
||||||
|
|
||||||
|
### Cap-Funktion
|
||||||
|
|
||||||
|
```python
|
||||||
|
def cap_normative_strength(original: str, source_type: str) -> str:
|
||||||
|
"""
|
||||||
|
cap_normative_strength("must", "framework") → "may"
|
||||||
|
cap_normative_strength("should", "law") → "should"
|
||||||
|
cap_normative_strength("must", "guideline") → "should"
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend-Anzeige
|
||||||
|
|
||||||
|
In der Control-Detail-Ansicht werden Obligations mit farbcodierten Badges angezeigt:
|
||||||
|
|
||||||
|
| normative_strength | Badge | Farbe | Bedeutung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `must` | **MUSS** | Rot | Gesetzliche Pflicht |
|
||||||
|
| `should` | **SOLL** | Gelb/Amber | Empfohlen, Begruendungspflicht bei Abweichung |
|
||||||
|
| `may` | **KANN** | Gruen | Freiwillige Best Practice |
|
||||||
|
|
||||||
|
## Haeufige Fragen
|
||||||
|
|
||||||
|
### Warum steht bei einem ENISA-Control "MUSS"?
|
||||||
|
|
||||||
|
**Vor dem Backfill**: Das System uebernahm die Sprache des Quelldokuments 1:1.
|
||||||
|
ENISA schreibt intern "shall/must" weil es innerhalb seines Frameworks
|
||||||
|
verbindlich formuliert. Fuer den Anwender ist das ENISA-Dokument aber nicht
|
||||||
|
rechtsverbindlich.
|
||||||
|
|
||||||
|
**Nach dem Backfill**: ENISA-Controls zeigen maximal "KANN", es sei denn
|
||||||
|
ein Gesetz (z.B. NIS2) referenziert dasselbe Control — dann gilt die
|
||||||
|
gesetzliche Verbindlichkeit.
|
||||||
|
|
||||||
|
### Was bedeutet "Stand der Technik"?
|
||||||
|
|
||||||
|
NIS2 und DSGVO verweisen auf den "Stand der Technik", ohne ihn zu definieren.
|
||||||
|
In der Praxis werden ENISA- und BSI-Dokumente als Referenz herangezogen.
|
||||||
|
Das macht ihre Empfehlungen relevant ("SOLL"), aber nicht zu Gesetzen ("MUSS").
|
||||||
|
|
||||||
|
### Wie gehe ich mit unbekannten Quellen um?
|
||||||
|
|
||||||
|
Neue Regulierungen muessen in der `SOURCE_REGULATION_CLASSIFICATION` Map
|
||||||
|
eingetragen werden. Der Fallback fuer unbekannte Quellen ist `framework`
|
||||||
|
(konservativstes Ergebnis — geringste Verbindlichkeit zugewiesen).
|
||||||
@@ -14,7 +14,7 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application
|
# Copy application
|
||||||
COPY main.py .
|
COPY . .
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV IPFS_API_URL=http://dsms-node:5001
|
ENV IPFS_API_URL=http://dsms-node:5001
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ nav:
|
|||||||
- Control Generator Pipeline: services/sdk-modules/control-generator-pipeline.md
|
- Control Generator Pipeline: services/sdk-modules/control-generator-pipeline.md
|
||||||
- Deduplizierungs-Engine: services/sdk-modules/dedup-engine.md
|
- Deduplizierungs-Engine: services/sdk-modules/dedup-engine.md
|
||||||
- Control Provenance Wiki: services/sdk-modules/control-provenance.md
|
- Control Provenance Wiki: services/sdk-modules/control-provenance.md
|
||||||
|
- Normative Verbindlichkeit (Dreistufenmodell): services/sdk-modules/normative-verbindlichkeit.md
|
||||||
|
- Evidence Type (Code vs. Prozess): services/sdk-modules/evidence-type.md
|
||||||
- Anti-Fake-Evidence Architektur: services/sdk-modules/anti-fake-evidence.md
|
- Anti-Fake-Evidence Architektur: services/sdk-modules/anti-fake-evidence.md
|
||||||
- Strategie:
|
- Strategie:
|
||||||
- Wettbewerbsanalyse & Roadmap: strategy/wettbewerbsanalyse.md
|
- Wettbewerbsanalyse & Roadmap: strategy/wettbewerbsanalyse.md
|
||||||
|
|||||||
164
scripts/deploy.sh
Executable file
164
scripts/deploy.sh
Executable file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =========================================================
|
||||||
|
# BreakPilot Compliance — Deploy Script
|
||||||
|
# =========================================================
|
||||||
|
# Pushes to both remotes, rebuilds changed services on
|
||||||
|
# Mac Mini, and monitors Coolify production health.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/deploy.sh
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
PROJECT="breakpilot-compliance"
|
||||||
|
PROJECT_DIR="/Users/benjaminadmin/Projekte/${PROJECT}"
|
||||||
|
COMPOSE_FILE="${PROJECT_DIR}/docker-compose.yml"
|
||||||
|
DOCKER="/usr/local/bin/docker"
|
||||||
|
MAC_MINI="macmini"
|
||||||
|
|
||||||
|
# Coolify health endpoints
|
||||||
|
HEALTH_ENDPOINTS=(
|
||||||
|
"https://api-dev.breakpilot.ai/health"
|
||||||
|
"https://sdk-dev.breakpilot.ai/health"
|
||||||
|
)
|
||||||
|
HEALTH_INTERVAL=20
|
||||||
|
HEALTH_TIMEOUT=300 # 5 minutes
|
||||||
|
|
||||||
|
# Map top-level directory to docker-compose service name
|
||||||
|
dir_to_service() {
|
||||||
|
case "$1" in
|
||||||
|
admin-compliance) echo "admin-compliance" ;;
|
||||||
|
backend-compliance) echo "backend-compliance" ;;
|
||||||
|
ai-compliance-sdk) echo "ai-compliance-sdk" ;;
|
||||||
|
developer-portal) echo "developer-portal" ;;
|
||||||
|
compliance-tts-service) echo "compliance-tts-service" ;;
|
||||||
|
document-crawler) echo "document-crawler" ;;
|
||||||
|
dsms-node) echo "dsms-node" ;;
|
||||||
|
dsms-gateway) echo "dsms-gateway" ;;
|
||||||
|
docs-src) echo "docs" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_SERVICES="admin-compliance backend-compliance ai-compliance-sdk developer-portal compliance-tts-service document-crawler dsms-node dsms-gateway docs"
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
info() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
|
||||||
|
ok() { printf "\033[1;32m[OK]\033[0m %s\n" "$*"; }
|
||||||
|
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
|
||||||
|
fail() { printf "\033[1;31m[FAIL]\033[0m %s\n" "$*"; }
|
||||||
|
|
||||||
|
# --- Step 1: Push to both remotes ---
|
||||||
|
info "Pushing to origin (local Gitea)..."
|
||||||
|
git push origin main
|
||||||
|
ok "Pushed to origin."
|
||||||
|
|
||||||
|
info "Pushing to gitea (external)..."
|
||||||
|
git push gitea main
|
||||||
|
ok "Pushed to gitea."
|
||||||
|
|
||||||
|
# --- Step 2: Detect changed services ---
|
||||||
|
info "Detecting changed services since last deploy..."
|
||||||
|
|
||||||
|
# Get the commit before the push (what Mac Mini currently has)
|
||||||
|
REMOTE_HEAD=$(ssh "${MAC_MINI}" "git -C ${PROJECT_DIR} rev-parse HEAD" 2>/dev/null || echo "")
|
||||||
|
LOCAL_HEAD=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
CHANGED_SERVICES=""
|
||||||
|
|
||||||
|
if [ -z "${REMOTE_HEAD}" ] || [ "${REMOTE_HEAD}" = "${LOCAL_HEAD}" ]; then
|
||||||
|
info "Cannot determine remote HEAD or already equal. Checking last commit diff..."
|
||||||
|
CHANGED_DIRS=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | cut -d'/' -f1 | sort -u)
|
||||||
|
else
|
||||||
|
CHANGED_DIRS=$(git diff --name-only "${REMOTE_HEAD}" "${LOCAL_HEAD}" 2>/dev/null | cut -d'/' -f1 | sort -u)
|
||||||
|
fi
|
||||||
|
|
||||||
|
for dir in ${CHANGED_DIRS}; do
|
||||||
|
svc=$(dir_to_service "${dir}")
|
||||||
|
if [ -n "${svc}" ]; then
|
||||||
|
CHANGED_SERVICES="${CHANGED_SERVICES} ${svc}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Also check if docker-compose.yml itself changed
|
||||||
|
if echo "${CHANGED_DIRS}" | grep -q "^docker-compose"; then
|
||||||
|
info "docker-compose.yml changed — will rebuild all services."
|
||||||
|
CHANGED_SERVICES="${ALL_SERVICES}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deduplicate
|
||||||
|
CHANGED_SERVICES=$(echo "${CHANGED_SERVICES}" | tr ' ' '\n' | sort -u | tr '\n' ' ' | xargs)
|
||||||
|
|
||||||
|
if [ -z "${CHANGED_SERVICES}" ]; then
|
||||||
|
warn "No service directories changed. Nothing to rebuild on Mac Mini."
|
||||||
|
info "Coolify will still deploy from the gitea push."
|
||||||
|
else
|
||||||
|
info "Changed services: ${CHANGED_SERVICES}"
|
||||||
|
|
||||||
|
# --- Step 3: Pull code on Mac Mini ---
|
||||||
|
info "Pulling latest code on Mac Mini..."
|
||||||
|
ssh "${MAC_MINI}" "git -C ${PROJECT_DIR} pull --no-rebase origin main"
|
||||||
|
ok "Code pulled on Mac Mini."
|
||||||
|
|
||||||
|
# --- Step 4: Rebuild + restart changed services ---
|
||||||
|
info "Building changed services on Mac Mini: ${CHANGED_SERVICES}"
|
||||||
|
ssh "${MAC_MINI}" "${DOCKER} compose -f ${COMPOSE_FILE} build ${CHANGED_SERVICES}"
|
||||||
|
ok "Build complete."
|
||||||
|
|
||||||
|
info "Restarting changed services on Mac Mini: ${CHANGED_SERVICES}"
|
||||||
|
ssh "${MAC_MINI}" "${DOCKER} compose -f ${COMPOSE_FILE} up -d --no-deps ${CHANGED_SERVICES}"
|
||||||
|
ok "Services restarted on Mac Mini."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Step 5: Monitor Coolify health in background ---
|
||||||
|
info "Monitoring Coolify production health in background (every ${HEALTH_INTERVAL}s, max ${HEALTH_TIMEOUT}s)..."
|
||||||
|
|
||||||
|
(
|
||||||
|
elapsed=0
|
||||||
|
all_healthy=false
|
||||||
|
|
||||||
|
while [ ${elapsed} -lt ${HEALTH_TIMEOUT} ]; do
|
||||||
|
sleep ${HEALTH_INTERVAL}
|
||||||
|
elapsed=$((elapsed + HEALTH_INTERVAL))
|
||||||
|
|
||||||
|
healthy_count=0
|
||||||
|
for endpoint in "${HEALTH_ENDPOINTS[@]}"; do
|
||||||
|
if curl -sf --max-time 5 "${endpoint}" >/dev/null 2>&1; then
|
||||||
|
healthy_count=$((healthy_count + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${healthy_count} -eq ${#HEALTH_ENDPOINTS[@]} ]; then
|
||||||
|
all_healthy=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf "\033[1;34m[HEALTH]\033[0m %d/%d endpoints healthy (%ds elapsed)\n" \
|
||||||
|
${healthy_count} ${#HEALTH_ENDPOINTS[@]} ${elapsed}
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if ${all_healthy}; then
|
||||||
|
printf "\033[1;32m========================================\033[0m\n"
|
||||||
|
printf "\033[1;32m Coolify deploy complete! \033[0m\n"
|
||||||
|
printf "\033[1;32m All health endpoints are healthy. \033[0m\n"
|
||||||
|
printf "\033[1;32m Test at: https://admin-dev.breakpilot.ai\033[0m\n"
|
||||||
|
printf "\033[1;32m========================================\033[0m\n"
|
||||||
|
else
|
||||||
|
printf "\033[1;31m========================================\033[0m\n"
|
||||||
|
printf "\033[1;31m Coolify deploy may have failed! \033[0m\n"
|
||||||
|
printf "\033[1;31m Not all endpoints healthy after %ds. \033[0m\n" ${HEALTH_TIMEOUT}
|
||||||
|
printf "\033[1;31m Check Coolify logs. \033[0m\n"
|
||||||
|
printf "\033[1;31m========================================\033[0m\n"
|
||||||
|
fi
|
||||||
|
) &
|
||||||
|
|
||||||
|
HEALTH_PID=$!
|
||||||
|
|
||||||
|
# --- Step 6: Report ---
|
||||||
|
echo ""
|
||||||
|
ok "Local deploy to Mac Mini: done."
|
||||||
|
info "Coolify health monitor running in background (PID ${HEALTH_PID})."
|
||||||
|
info "You will see a status banner when Coolify is ready (or after ${HEALTH_TIMEOUT}s timeout)."
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user