merge: Feature-Module (Payment, BetrVG, FISA 702) in refakturierten main
Some checks failed
Build + Deploy / build-admin-compliance (push) Successful in 1m30s
Build + Deploy / build-backend-compliance (push) Successful in 13s
Build + Deploy / build-ai-sdk (push) Failing after 29s
Build + Deploy / build-developer-portal (push) Successful in 6s
Build + Deploy / build-tts (push) Successful in 6s
Build + Deploy / build-document-crawler (push) Successful in 6s
Build + Deploy / build-dsms-gateway (push) Successful in 6s
Build + Deploy / trigger-orca (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 12s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m18s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 29s
CI / test-python-backend (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
CI / validate-canonical-controls (push) Successful in 30s

Merged feature/fisa-702-drittland-risiko in den refakturierten main-Branch.
Konflikte in 8 Dateien aufgelöst — neue Features in die aufgesplittete
Modulstruktur integriert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-22 23:52:11 +02:00
58 changed files with 15705 additions and 445 deletions

View 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})
}

View 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(&reg); 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(), &reg); 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)
}

View 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
}

View 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")
}
}

View File

@@ -0,0 +1,325 @@
package ucca
// ============================================================================
// AI Act Decision Tree Engine
// ============================================================================
//
// Two-axis classification:
// Axis 1 (Q1Q7): High-Risk classification based on Annex III
// Axis 2 (Q8Q12): GPAI classification based on Art. 5156
//
// 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. 34",
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. 68",
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. 5156) ===
{
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 Q1Q7
func evaluateHighRiskAxis(answers map[string]DecisionTreeAnswer) AIActRiskLevel {
// Q1: Uses AI at all?
if !answerIsYes(answers, Q1) {
return AIActNotApplicable
}
// Q2Q6: 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 Q8Q12
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
}

View 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)
}
})
}
}

View 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")
}
}
}

View File

@@ -1,6 +1,7 @@
package ucca
import (
"fmt"
"time"
"github.com/google/uuid"
@@ -187,6 +188,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
}
}
// BetrVG E3: Very high conflict score without consultation
if result.BetrvgConflictScore >= 75 && !result.Intake.WorksCouncilConsulted {
reasons = append(reasons, "BetrVG-Konfliktpotenzial sehr hoch (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+") ohne BR-Konsultation")
return EscalationLevelE3, joinReasons(reasons, "E3 erforderlich: ")
}
if hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold {
if result.DSFARecommended {
reasons = append(reasons, "DSFA empfohlen")
@@ -197,6 +204,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ")
}
// BetrVG E2: High conflict score
if result.BetrvgConflictScore >= 50 && result.BetrvgConsultationRequired && !result.Intake.WorksCouncilConsulted {
reasons = append(reasons, "BetrVG-Mitbestimmung erforderlich (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+"), BR nicht konsultiert")
return EscalationLevelE2, joinReasons(reasons, "BR-Konsultation erforderlich: ")
}
// E1: Low priority checks
// - WARN rules triggered
// - Risk 20-40

View File

@@ -56,6 +56,10 @@ func (m *JSONRegulationModule) defaultApplicability(facts *UnifiedFacts) bool {
return facts.Organization.EUMember && facts.AIUsage.UsesAI
case "dora":
return facts.Financial.DORAApplies || facts.Financial.IsRegulated
case "betrvg":
return facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 5
case "agg":
return facts.Organization.Country == "DE"
default:
return true
}

View File

@@ -178,3 +178,73 @@ const (
ExportFormatJSON ExportFormat = "json"
ExportFormatMarkdown ExportFormat = "md"
)
// ============================================================================
// AI Act Decision Tree Types
// ============================================================================
// GPAICategory represents the GPAI classification result
type GPAICategory string
const (
GPAICategoryNone GPAICategory = "none"
GPAICategoryStandard GPAICategory = "standard"
GPAICategorySystemic GPAICategory = "systemic"
)
// GPAIClassification represents the result of the GPAI axis evaluation
type GPAIClassification struct {
IsGPAI bool `json:"is_gpai"`
IsSystemicRisk bool `json:"is_systemic_risk"`
Category GPAICategory `json:"gpai_category"`
ApplicableArticles []string `json:"applicable_articles"`
Obligations []string `json:"obligations"`
}
// DecisionTreeAnswer represents a user's answer to a decision tree question
type DecisionTreeAnswer struct {
QuestionID string `json:"question_id"`
Value bool `json:"value"`
Note string `json:"note,omitempty"`
}
// DecisionTreeQuestion represents a single question in the decision tree
type DecisionTreeQuestion struct {
ID string `json:"id"`
Axis string `json:"axis"` // "high_risk" or "gpai"
Question string `json:"question"`
Description string `json:"description"` // Additional context
ArticleRef string `json:"article_ref"` // e.g., "Art. 5", "Anhang III"
SkipIf string `json:"skip_if,omitempty"` // Question ID — skip if that was answered "no"
}
// DecisionTreeDefinition represents the full decision tree structure for the frontend
type DecisionTreeDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Questions []DecisionTreeQuestion `json:"questions"`
}
// DecisionTreeEvalRequest is the API request for evaluating the decision tree
type DecisionTreeEvalRequest struct {
SystemName string `json:"system_name"`
SystemDescription string `json:"system_description,omitempty"`
Answers map[string]DecisionTreeAnswer `json:"answers"`
}
// DecisionTreeResult represents the combined evaluation result
type DecisionTreeResult struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SystemName string `json:"system_name"`
SystemDescription string `json:"system_description,omitempty"`
Answers map[string]DecisionTreeAnswer `json:"answers"`
HighRiskResult AIActRiskLevel `json:"high_risk_result"`
GPAIResult GPAIClassification `json:"gpai_result"`
CombinedObligations []string `json:"combined_obligations"`
ApplicableArticles []string `json:"applicable_articles"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View 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
}

View File

@@ -358,6 +358,128 @@ type AssessmentFilters struct {
Offset int // OFFSET for pagination
}
// ============================================================================
// Decision Tree Result CRUD
// ============================================================================
// CreateDecisionTreeResult stores a new decision tree result
func (s *Store) CreateDecisionTreeResult(ctx context.Context, r *DecisionTreeResult) error {
r.ID = uuid.New()
r.CreatedAt = time.Now().UTC()
r.UpdatedAt = r.CreatedAt
answers, _ := json.Marshal(r.Answers)
gpaiResult, _ := json.Marshal(r.GPAIResult)
obligations, _ := json.Marshal(r.CombinedObligations)
articles, _ := json.Marshal(r.ApplicableArticles)
_, err := s.pool.Exec(ctx, `
INSERT INTO ai_act_decision_tree_results (
id, tenant_id, project_id, system_name, system_description,
answers, high_risk_level, gpai_result,
combined_obligations, applicable_articles,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10,
$11, $12
)
`,
r.ID, r.TenantID, r.ProjectID, r.SystemName, r.SystemDescription,
answers, string(r.HighRiskResult), gpaiResult,
obligations, articles,
r.CreatedAt, r.UpdatedAt,
)
return err
}
// GetDecisionTreeResult retrieves a decision tree result by ID
func (s *Store) GetDecisionTreeResult(ctx context.Context, id uuid.UUID) (*DecisionTreeResult, error) {
var r DecisionTreeResult
var answersBytes, gpaiBytes, oblBytes, artBytes []byte
var highRiskLevel string
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, project_id, system_name, system_description,
answers, high_risk_level, gpai_result,
combined_obligations, applicable_articles,
created_at, updated_at
FROM ai_act_decision_tree_results WHERE id = $1
`, id).Scan(
&r.ID, &r.TenantID, &r.ProjectID, &r.SystemName, &r.SystemDescription,
&answersBytes, &highRiskLevel, &gpaiBytes,
&oblBytes, &artBytes,
&r.CreatedAt, &r.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(answersBytes, &r.Answers)
json.Unmarshal(gpaiBytes, &r.GPAIResult)
json.Unmarshal(oblBytes, &r.CombinedObligations)
json.Unmarshal(artBytes, &r.ApplicableArticles)
r.HighRiskResult = AIActRiskLevel(highRiskLevel)
return &r, nil
}
// ListDecisionTreeResults lists all decision tree results for a tenant
func (s *Store) ListDecisionTreeResults(ctx context.Context, tenantID uuid.UUID) ([]DecisionTreeResult, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, project_id, system_name, system_description,
answers, high_risk_level, gpai_result,
combined_obligations, applicable_articles,
created_at, updated_at
FROM ai_act_decision_tree_results
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 100
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []DecisionTreeResult
for rows.Next() {
var r DecisionTreeResult
var answersBytes, gpaiBytes, oblBytes, artBytes []byte
var highRiskLevel string
err := rows.Scan(
&r.ID, &r.TenantID, &r.ProjectID, &r.SystemName, &r.SystemDescription,
&answersBytes, &highRiskLevel, &gpaiBytes,
&oblBytes, &artBytes,
&r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
json.Unmarshal(answersBytes, &r.Answers)
json.Unmarshal(gpaiBytes, &r.GPAIResult)
json.Unmarshal(oblBytes, &r.CombinedObligations)
json.Unmarshal(artBytes, &r.ApplicableArticles)
r.HighRiskResult = AIActRiskLevel(highRiskLevel)
results = append(results, r)
}
return results, nil
}
// DeleteDecisionTreeResult deletes a decision tree result by ID
func (s *Store) DeleteDecisionTreeResult(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM ai_act_decision_tree_results WHERE id = $1", id)
return err
}
// ============================================================================
// Helpers
// ============================================================================