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
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:
290
ai-compliance-sdk/internal/api/handlers/payment_handlers.go
Normal file
290
ai-compliance-sdk/internal/api/handlers/payment_handlers.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PaymentHandlers handles payment compliance endpoints
|
||||
type PaymentHandlers struct {
|
||||
pool *pgxpool.Pool
|
||||
controls *PaymentControlLibrary
|
||||
}
|
||||
|
||||
// PaymentControlLibrary holds the control catalog
|
||||
type PaymentControlLibrary struct {
|
||||
Domains []PaymentDomain `json:"domains"`
|
||||
Controls []PaymentControl `json:"controls"`
|
||||
}
|
||||
|
||||
type PaymentDomain struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type PaymentControl struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Domain string `json:"domain"`
|
||||
Title string `json:"title"`
|
||||
Objective string `json:"objective"`
|
||||
CheckTarget string `json:"check_target"`
|
||||
Evidence []string `json:"evidence"`
|
||||
Automation string `json:"automation"`
|
||||
}
|
||||
|
||||
type PaymentAssessment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
TenderReference string `json:"tender_reference,omitempty"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SystemType string `json:"system_type,omitempty"`
|
||||
PaymentMethods json.RawMessage `json:"payment_methods,omitempty"`
|
||||
Protocols json.RawMessage `json:"protocols,omitempty"`
|
||||
TotalControls int `json:"total_controls"`
|
||||
ControlsPassed int `json:"controls_passed"`
|
||||
ControlsFailed int `json:"controls_failed"`
|
||||
ControlsPartial int `json:"controls_partial"`
|
||||
ControlsNA int `json:"controls_not_applicable"`
|
||||
ControlsUnchecked int `json:"controls_not_checked"`
|
||||
ComplianceScore float64 `json:"compliance_score"`
|
||||
Status string `json:"status"`
|
||||
ControlResults json.RawMessage `json:"control_results,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// NewPaymentHandlers creates payment handlers with loaded control library
|
||||
func NewPaymentHandlers(pool *pgxpool.Pool) *PaymentHandlers {
|
||||
lib := loadControlLibrary()
|
||||
return &PaymentHandlers{pool: pool, controls: lib}
|
||||
}
|
||||
|
||||
func loadControlLibrary() *PaymentControlLibrary {
|
||||
// Try to load from policies directory
|
||||
paths := []string{
|
||||
"policies/payment_controls_v1.json",
|
||||
"/app/policies/payment_controls_v1.json",
|
||||
}
|
||||
for _, p := range paths {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
// Try relative to executable
|
||||
execDir, _ := os.Executable()
|
||||
altPath := filepath.Join(filepath.Dir(execDir), p)
|
||||
data, err = os.ReadFile(altPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
var lib PaymentControlLibrary
|
||||
if err := json.Unmarshal(data, &lib); err == nil {
|
||||
return &lib
|
||||
}
|
||||
}
|
||||
return &PaymentControlLibrary{}
|
||||
}
|
||||
|
||||
// GetControlLibrary returns the loaded control library (for tender matching)
|
||||
func (h *PaymentHandlers) GetControlLibrary() *PaymentControlLibrary {
|
||||
return h.controls
|
||||
}
|
||||
|
||||
// ListControls returns the control library
|
||||
func (h *PaymentHandlers) ListControls(c *gin.Context) {
|
||||
domain := c.Query("domain")
|
||||
automation := c.Query("automation")
|
||||
|
||||
controls := h.controls.Controls
|
||||
if domain != "" {
|
||||
var filtered []PaymentControl
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Domain == domain {
|
||||
filtered = append(filtered, ctrl)
|
||||
}
|
||||
}
|
||||
controls = filtered
|
||||
}
|
||||
if automation != "" {
|
||||
var filtered []PaymentControl
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Automation == automation {
|
||||
filtered = append(filtered, ctrl)
|
||||
}
|
||||
}
|
||||
controls = filtered
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": controls,
|
||||
"domains": h.controls.Domains,
|
||||
"total": len(controls),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateAssessment creates a new payment compliance assessment
|
||||
func (h *PaymentHandlers) CreateAssessment(c *gin.Context) {
|
||||
var req PaymentAssessment
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
req.ID = uuid.New()
|
||||
req.TenantID = tenantID
|
||||
req.Status = "draft"
|
||||
req.TotalControls = len(h.controls.Controls)
|
||||
req.ControlsUnchecked = req.TotalControls
|
||||
req.CreatedAt = time.Now()
|
||||
req.UpdatedAt = time.Now()
|
||||
|
||||
_, err := h.pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO payment_compliance_assessments (
|
||||
id, tenant_id, project_name, tender_reference, customer_name, description,
|
||||
system_type, payment_methods, protocols,
|
||||
total_controls, controls_not_checked, status, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
||||
req.ID, req.TenantID, req.ProjectName, req.TenderReference, req.CustomerName, req.Description,
|
||||
req.SystemType, req.PaymentMethods, req.Protocols,
|
||||
req.TotalControls, req.ControlsUnchecked, req.Status, req.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, req)
|
||||
}
|
||||
|
||||
// ListAssessments lists all payment assessments for a tenant
|
||||
func (h *PaymentHandlers) ListAssessments(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, tenant_id, project_name, tender_reference, customer_name,
|
||||
system_type, total_controls, controls_passed, controls_failed,
|
||||
controls_partial, controls_not_applicable, controls_not_checked,
|
||||
compliance_score, status, created_at, updated_at
|
||||
FROM payment_compliance_assessments
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assessments []PaymentAssessment
|
||||
for rows.Next() {
|
||||
var a PaymentAssessment
|
||||
rows.Scan(&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName,
|
||||
&a.SystemType, &a.TotalControls, &a.ControlsPassed, &a.ControlsFailed,
|
||||
&a.ControlsPartial, &a.ControlsNA, &a.ControlsUnchecked,
|
||||
&a.ComplianceScore, &a.Status, &a.CreatedAt, &a.UpdatedAt)
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
if assessments == nil {
|
||||
assessments = []PaymentAssessment{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"assessments": assessments, "total": len(assessments)})
|
||||
}
|
||||
|
||||
// GetAssessment returns a single assessment with control results
|
||||
func (h *PaymentHandlers) GetAssessment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var a PaymentAssessment
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT id, tenant_id, project_name, tender_reference, customer_name, description,
|
||||
system_type, payment_methods, protocols,
|
||||
total_controls, controls_passed, controls_failed, controls_partial,
|
||||
controls_not_applicable, controls_not_checked, compliance_score,
|
||||
status, control_results, created_at, updated_at, created_by
|
||||
FROM payment_compliance_assessments WHERE id = $1`, id).Scan(
|
||||
&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName, &a.Description,
|
||||
&a.SystemType, &a.PaymentMethods, &a.Protocols,
|
||||
&a.TotalControls, &a.ControlsPassed, &a.ControlsFailed, &a.ControlsPartial,
|
||||
&a.ControlsNA, &a.ControlsUnchecked, &a.ComplianceScore,
|
||||
&a.Status, &a.ControlResults, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// UpdateControlVerdict updates the verdict for a single control
|
||||
func (h *PaymentHandlers) UpdateControlVerdict(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Verdict string `json:"verdict"` // passed, failed, partial, na, unchecked
|
||||
Evidence string `json:"evidence,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update the control_results JSONB and recalculate scores
|
||||
_, err = h.pool.Exec(c.Request.Context(), `
|
||||
WITH updated AS (
|
||||
SELECT id,
|
||||
COALESCE(control_results, '[]'::jsonb) AS existing_results
|
||||
FROM payment_compliance_assessments WHERE id = $1
|
||||
)
|
||||
UPDATE payment_compliance_assessments SET
|
||||
control_results = (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'control_id' = $2 THEN
|
||||
jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5)
|
||||
ELSE elem END
|
||||
) FROM updated, jsonb_array_elements(
|
||||
CASE WHEN existing_results @> jsonb_build_array(jsonb_build_object('control_id', $2))
|
||||
THEN existing_results
|
||||
ELSE existing_results || jsonb_build_array(jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5))
|
||||
END
|
||||
) AS elem
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
id, body.ControlID, body.Verdict, body.Evidence, body.Notes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated", "control_id": body.ControlID, "verdict": body.Verdict})
|
||||
}
|
||||
220
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
220
ai-compliance-sdk/internal/api/handlers/registration_handlers.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RegistrationHandlers handles EU AI Database registration endpoints
|
||||
type RegistrationHandlers struct {
|
||||
store *ucca.RegistrationStore
|
||||
uccaStore *ucca.Store
|
||||
}
|
||||
|
||||
// NewRegistrationHandlers creates new registration handlers
|
||||
func NewRegistrationHandlers(store *ucca.RegistrationStore, uccaStore *ucca.Store) *RegistrationHandlers {
|
||||
return &RegistrationHandlers{store: store, uccaStore: uccaStore}
|
||||
}
|
||||
|
||||
// Create creates a new registration
|
||||
func (h *RegistrationHandlers) Create(c *gin.Context) {
|
||||
var reg ucca.AIRegistration
|
||||
if err := c.ShouldBindJSON(®); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
reg.TenantID = tenantID
|
||||
|
||||
if reg.SystemName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "system_name required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Create(c.Request.Context(), ®); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create registration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, reg)
|
||||
}
|
||||
|
||||
// List lists all registrations for the tenant
|
||||
func (h *RegistrationHandlers) List(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
registrations, err := h.store.List(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list registrations: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if registrations == nil {
|
||||
registrations = []ucca.AIRegistration{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"registrations": registrations, "total": len(registrations)})
|
||||
}
|
||||
|
||||
// Get returns a single registration
|
||||
func (h *RegistrationHandlers) Get(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := h.store.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, reg)
|
||||
}
|
||||
|
||||
// Update updates a registration
|
||||
func (h *RegistrationHandlers) Update(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.store.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var updates ucca.AIRegistration
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Merge updates into existing
|
||||
updates.ID = existing.ID
|
||||
updates.TenantID = existing.TenantID
|
||||
updates.CreatedAt = existing.CreatedAt
|
||||
|
||||
if err := h.store.Update(c.Request.Context(), &updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, updates)
|
||||
}
|
||||
|
||||
// UpdateStatus changes the registration status
|
||||
func (h *RegistrationHandlers) UpdateStatus(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
SubmittedBy string `json:"submitted_by"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
validStatuses := map[string]bool{
|
||||
"draft": true, "ready": true, "submitted": true,
|
||||
"registered": true, "update_required": true, "withdrawn": true,
|
||||
}
|
||||
if !validStatuses[body.Status] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status. Valid: draft, ready, submitted, registered, update_required, withdrawn"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateStatus(c.Request.Context(), id, body.Status, body.SubmittedBy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update status: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "status": body.Status})
|
||||
}
|
||||
|
||||
// Prefill creates a registration pre-filled from a UCCA assessment
|
||||
func (h *RegistrationHandlers) Prefill(c *gin.Context) {
|
||||
assessmentID, err := uuid.Parse(c.Param("assessment_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load UCCA assessment
|
||||
assessment, err := h.uccaStore.GetAssessment(c.Request.Context(), assessmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-fill registration from assessment intake
|
||||
intake := assessment.Intake
|
||||
|
||||
reg := ucca.AIRegistration{
|
||||
TenantID: tenantID,
|
||||
SystemName: intake.Title,
|
||||
SystemDescription: intake.UseCaseText,
|
||||
IntendedPurpose: intake.UseCaseText,
|
||||
RiskClassification: string(assessment.RiskLevel),
|
||||
GPAIClassification: "none",
|
||||
RegistrationStatus: "draft",
|
||||
UCCAAssessmentID: &assessmentID,
|
||||
}
|
||||
|
||||
// Map domain to readable text
|
||||
if intake.Domain != "" {
|
||||
reg.IntendedPurpose = string(intake.Domain) + ": " + intake.UseCaseText
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, reg)
|
||||
}
|
||||
|
||||
// Export generates the EU AI Database submission JSON
|
||||
func (h *RegistrationHandlers) Export(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
reg, err := h.store.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Registration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
exportJSON := h.store.BuildExportJSON(reg)
|
||||
|
||||
// Save export data to DB
|
||||
reg.ExportData = exportJSON
|
||||
h.store.Update(c.Request.Context(), reg)
|
||||
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.Header("Content-Disposition", "attachment; filename=eu_ai_registration_"+reg.SystemName+".json")
|
||||
c.Data(http.StatusOK, "application/json", exportJSON)
|
||||
}
|
||||
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
557
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TenderHandlers handles tender upload and requirement extraction
|
||||
type TenderHandlers struct {
|
||||
pool *pgxpool.Pool
|
||||
controls *PaymentControlLibrary
|
||||
}
|
||||
|
||||
// TenderAnalysis represents a tender document analysis
|
||||
type TenderAnalysis struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name,omitempty"`
|
||||
Status string `json:"status"` // uploaded, extracting, extracted, matched, completed
|
||||
Requirements []ExtractedReq `json:"requirements,omitempty"`
|
||||
MatchResults []MatchResult `json:"match_results,omitempty"`
|
||||
TotalRequirements int `json:"total_requirements"`
|
||||
MatchedCount int `json:"matched_count"`
|
||||
UnmatchedCount int `json:"unmatched_count"`
|
||||
PartialCount int `json:"partial_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ExtractedReq represents a single requirement extracted from a tender document
|
||||
type ExtractedReq struct {
|
||||
ReqID string `json:"req_id"`
|
||||
Text string `json:"text"`
|
||||
SourcePage int `json:"source_page,omitempty"`
|
||||
SourceSection string `json:"source_section,omitempty"`
|
||||
ObligationLevel string `json:"obligation_level"` // MUST, SHALL, SHOULD, MAY
|
||||
TechnicalDomain string `json:"technical_domain"` // crypto, logging, payment_flow, etc.
|
||||
CheckTarget string `json:"check_target"` // code, system, config, process, certificate
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
// MatchResult represents the matching of a requirement to controls
|
||||
type MatchResult struct {
|
||||
ReqID string `json:"req_id"`
|
||||
ReqText string `json:"req_text"`
|
||||
ObligationLevel string `json:"obligation_level"`
|
||||
MatchedControls []ControlMatch `json:"matched_controls"`
|
||||
Verdict string `json:"verdict"` // matched, partial, unmatched
|
||||
GapDescription string `json:"gap_description,omitempty"`
|
||||
}
|
||||
|
||||
// ControlMatch represents a single control match for a requirement
|
||||
type ControlMatch struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Title string `json:"title"`
|
||||
Relevance float64 `json:"relevance"` // 0-1
|
||||
CheckTarget string `json:"check_target"`
|
||||
}
|
||||
|
||||
// NewTenderHandlers creates tender handlers
|
||||
func NewTenderHandlers(pool *pgxpool.Pool, controls *PaymentControlLibrary) *TenderHandlers {
|
||||
return &TenderHandlers{pool: pool, controls: controls}
|
||||
}
|
||||
|
||||
// Upload handles tender document upload
|
||||
func (h *TenderHandlers) Upload(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
projectName := c.PostForm("project_name")
|
||||
if projectName == "" {
|
||||
projectName = header.Filename
|
||||
}
|
||||
customerName := c.PostForm("customer_name")
|
||||
|
||||
// Read file content
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store analysis record
|
||||
analysisID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
_, err = h.pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO tender_analyses (
|
||||
id, tenant_id, file_name, file_size, file_content,
|
||||
project_name, customer_name, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8, $9)`,
|
||||
analysisID, tenantID, header.Filename, header.Size, content,
|
||||
projectName, customerName, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": analysisID,
|
||||
"file_name": header.Filename,
|
||||
"file_size": header.Size,
|
||||
"project_name": projectName,
|
||||
"status": "uploaded",
|
||||
"message": "Dokument hochgeladen. Starte Analyse mit POST /extract.",
|
||||
})
|
||||
}
|
||||
|
||||
// Extract extracts requirements from an uploaded tender document using LLM
|
||||
func (h *TenderHandlers) Extract(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get file content
|
||||
var fileContent []byte
|
||||
var fileName string
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT file_content, file_name FROM tender_analyses WHERE id = $1`, id,
|
||||
).Scan(&fileContent, &fileName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET status = 'extracting', updated_at = NOW() WHERE id = $1`, id)
|
||||
|
||||
// Extract text (simple: treat as text for now, PDF extraction would use embedding-service)
|
||||
text := string(fileContent)
|
||||
|
||||
// Use LLM to extract requirements
|
||||
requirements := h.extractRequirementsWithLLM(c.Request.Context(), text)
|
||||
|
||||
// Store results
|
||||
reqJSON, _ := json.Marshal(requirements)
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET
|
||||
status = 'extracted',
|
||||
requirements = $2,
|
||||
total_requirements = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`, id, reqJSON, len(requirements))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"status": "extracted",
|
||||
"requirements": requirements,
|
||||
"total": len(requirements),
|
||||
})
|
||||
}
|
||||
|
||||
// Match matches extracted requirements against the control library
|
||||
func (h *TenderHandlers) Match(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get requirements
|
||||
var reqJSON json.RawMessage
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT requirements FROM tender_analyses WHERE id = $1`, id,
|
||||
).Scan(&reqJSON)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var requirements []ExtractedReq
|
||||
json.Unmarshal(reqJSON, &requirements)
|
||||
|
||||
// Match each requirement against controls
|
||||
var results []MatchResult
|
||||
matched, unmatched, partial := 0, 0, 0
|
||||
|
||||
for _, req := range requirements {
|
||||
matches := h.findMatchingControls(req)
|
||||
result := MatchResult{
|
||||
ReqID: req.ReqID,
|
||||
ReqText: req.Text,
|
||||
ObligationLevel: req.ObligationLevel,
|
||||
MatchedControls: matches,
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
result.Verdict = "unmatched"
|
||||
result.GapDescription = "Kein passender Control gefunden — manueller Review erforderlich"
|
||||
unmatched++
|
||||
} else if matches[0].Relevance >= 0.7 {
|
||||
result.Verdict = "matched"
|
||||
matched++
|
||||
} else {
|
||||
result.Verdict = "partial"
|
||||
result.GapDescription = "Teilweise Abdeckung — Control deckt Anforderung nicht vollstaendig ab"
|
||||
partial++
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Store results
|
||||
resultsJSON, _ := json.Marshal(results)
|
||||
h.pool.Exec(c.Request.Context(), `
|
||||
UPDATE tender_analyses SET
|
||||
status = 'matched',
|
||||
match_results = $2,
|
||||
matched_count = $3,
|
||||
unmatched_count = $4,
|
||||
partial_count = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`, id, resultsJSON, matched, unmatched, partial)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": id,
|
||||
"status": "matched",
|
||||
"results": results,
|
||||
"matched": matched,
|
||||
"unmatched": unmatched,
|
||||
"partial": partial,
|
||||
"total": len(requirements),
|
||||
})
|
||||
}
|
||||
|
||||
// ListAnalyses lists all tender analyses for a tenant
|
||||
func (h *TenderHandlers) ListAnalyses(c *gin.Context) {
|
||||
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if tenantID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, tenant_id, file_name, file_size, project_name, customer_name,
|
||||
status, total_requirements, matched_count, unmatched_count, partial_count,
|
||||
created_at, updated_at
|
||||
FROM tender_analyses
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var analyses []TenderAnalysis
|
||||
for rows.Next() {
|
||||
var a TenderAnalysis
|
||||
rows.Scan(&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName,
|
||||
&a.Status, &a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount,
|
||||
&a.CreatedAt, &a.UpdatedAt)
|
||||
analyses = append(analyses, a)
|
||||
}
|
||||
if analyses == nil {
|
||||
analyses = []TenderAnalysis{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"analyses": analyses, "total": len(analyses)})
|
||||
}
|
||||
|
||||
// GetAnalysis returns a single analysis with all details
|
||||
func (h *TenderHandlers) GetAnalysis(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var a TenderAnalysis
|
||||
var reqJSON, matchJSON json.RawMessage
|
||||
err = h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT id, tenant_id, file_name, file_size, project_name, customer_name,
|
||||
status, requirements, match_results,
|
||||
total_requirements, matched_count, unmatched_count, partial_count,
|
||||
created_at, updated_at
|
||||
FROM tender_analyses WHERE id = $1`, id).Scan(
|
||||
&a.ID, &a.TenantID, &a.FileName, &a.FileSize, &a.ProjectName, &a.CustomerName,
|
||||
&a.Status, &reqJSON, &matchJSON,
|
||||
&a.TotalRequirements, &a.MatchedCount, &a.UnmatchedCount, &a.PartialCount,
|
||||
&a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if reqJSON != nil {
|
||||
json.Unmarshal(reqJSON, &a.Requirements)
|
||||
}
|
||||
if matchJSON != nil {
|
||||
json.Unmarshal(matchJSON, &a.MatchResults)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
func (h *TenderHandlers) extractRequirementsWithLLM(ctx context.Context, text string) []ExtractedReq {
|
||||
// Try Anthropic API for requirement extraction
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
// Fallback: simple keyword-based extraction
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Analysiere das folgende Ausschreibungsdokument und extrahiere alle technischen Anforderungen.
|
||||
|
||||
Fuer jede Anforderung gib zurueck:
|
||||
- req_id: fortlaufende ID (REQ-001, REQ-002, ...)
|
||||
- text: die Anforderung als kurzer Satz
|
||||
- obligation_level: MUST, SHALL, SHOULD oder MAY
|
||||
- technical_domain: eines von: payment_flow, logging, crypto, api_security, terminal_comm, firmware, reporting, access_control, error_handling, build_deploy
|
||||
- check_target: eines von: code, system, config, process, certificate
|
||||
|
||||
Antworte NUR mit JSON Array. Keine Erklaerung.
|
||||
|
||||
Dokument:
|
||||
%s`, text[:min(len(text), 15000)])
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"max_tokens": 4096,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(body)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", strings.NewReader(string(bodyJSON)))
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if len(result.Content) == 0 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
// Parse LLM response
|
||||
responseText := result.Content[0].Text
|
||||
// Find JSON array in response
|
||||
start := strings.Index(responseText, "[")
|
||||
end := strings.LastIndex(responseText, "]")
|
||||
if start < 0 || end < 0 {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
var reqs []ExtractedReq
|
||||
if err := json.Unmarshal([]byte(responseText[start:end+1]), &reqs); err != nil {
|
||||
return h.extractRequirementsKeyword(text)
|
||||
}
|
||||
|
||||
// Set confidence for LLM-extracted requirements
|
||||
for i := range reqs {
|
||||
reqs[i].Confidence = 0.8
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (h *TenderHandlers) extractRequirementsKeyword(text string) []ExtractedReq {
|
||||
// Simple keyword-based extraction as fallback
|
||||
keywords := map[string]string{
|
||||
"muss": "MUST",
|
||||
"muessen": "MUST",
|
||||
"ist sicherzustellen": "MUST",
|
||||
"soll": "SHOULD",
|
||||
"sollte": "SHOULD",
|
||||
"kann": "MAY",
|
||||
"wird gefordert": "MUST",
|
||||
"nachzuweisen": "MUST",
|
||||
"zertifiziert": "MUST",
|
||||
}
|
||||
|
||||
var reqs []ExtractedReq
|
||||
lines := strings.Split(text, "\n")
|
||||
reqNum := 1
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 20 || len(line) > 500 {
|
||||
continue
|
||||
}
|
||||
|
||||
for keyword, level := range keywords {
|
||||
if strings.Contains(strings.ToLower(line), keyword) {
|
||||
reqs = append(reqs, ExtractedReq{
|
||||
ReqID: fmt.Sprintf("REQ-%03d", reqNum),
|
||||
Text: line,
|
||||
ObligationLevel: level,
|
||||
TechnicalDomain: inferDomain(line),
|
||||
CheckTarget: inferCheckTarget(line),
|
||||
Confidence: 0.5,
|
||||
})
|
||||
reqNum++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
func (h *TenderHandlers) findMatchingControls(req ExtractedReq) []ControlMatch {
|
||||
var matches []ControlMatch
|
||||
|
||||
reqLower := strings.ToLower(req.Text + " " + req.TechnicalDomain)
|
||||
|
||||
for _, ctrl := range h.controls.Controls {
|
||||
titleLower := strings.ToLower(ctrl.Title + " " + ctrl.Objective)
|
||||
relevance := calculateRelevance(reqLower, titleLower, req.TechnicalDomain, ctrl.Domain)
|
||||
|
||||
if relevance > 0.3 {
|
||||
matches = append(matches, ControlMatch{
|
||||
ControlID: ctrl.ControlID,
|
||||
Title: ctrl.Title,
|
||||
Relevance: relevance,
|
||||
CheckTarget: ctrl.CheckTarget,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance (simple bubble sort for small lists)
|
||||
for i := 0; i < len(matches); i++ {
|
||||
for j := i + 1; j < len(matches); j++ {
|
||||
if matches[j].Relevance > matches[i].Relevance {
|
||||
matches[i], matches[j] = matches[j], matches[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return top 5
|
||||
if len(matches) > 5 {
|
||||
matches = matches[:5]
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func calculateRelevance(reqText, ctrlText, reqDomain, ctrlDomain string) float64 {
|
||||
score := 0.0
|
||||
|
||||
// Domain match bonus
|
||||
domainMap := map[string]string{
|
||||
"payment_flow": "PAY",
|
||||
"logging": "LOG",
|
||||
"crypto": "CRYPTO",
|
||||
"api_security": "API",
|
||||
"terminal_comm": "TERM",
|
||||
"firmware": "FW",
|
||||
"reporting": "REP",
|
||||
"access_control": "ACC",
|
||||
"error_handling": "ERR",
|
||||
"build_deploy": "BLD",
|
||||
}
|
||||
|
||||
if mapped, ok := domainMap[reqDomain]; ok && mapped == ctrlDomain {
|
||||
score += 0.4
|
||||
}
|
||||
|
||||
// Keyword overlap
|
||||
reqWords := strings.Fields(reqText)
|
||||
for _, word := range reqWords {
|
||||
if len(word) > 3 && strings.Contains(ctrlText, word) {
|
||||
score += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
if score > 1.0 {
|
||||
score = 1.0
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func inferDomain(text string) string {
|
||||
textLower := strings.ToLower(text)
|
||||
domainKeywords := map[string][]string{
|
||||
"payment_flow": {"zahlung", "transaktion", "buchung", "payment", "betrag"},
|
||||
"logging": {"log", "protokoll", "audit", "nachvollzieh"},
|
||||
"crypto": {"verschlüssel", "schlüssel", "krypto", "tls", "ssl", "hsm", "pin"},
|
||||
"api_security": {"api", "schnittstelle", "authentifiz", "autorisier"},
|
||||
"terminal_comm": {"terminal", "zvt", "opi", "gerät", "kontaktlos", "nfc"},
|
||||
"firmware": {"firmware", "update", "signatur", "boot"},
|
||||
"reporting": {"bericht", "report", "abrechnung", "export", "abgleich"},
|
||||
"access_control": {"zugang", "benutzer", "passwort", "rolle", "berechtigung"},
|
||||
"error_handling": {"fehler", "ausfall", "recovery", "offline", "störung"},
|
||||
"build_deploy": {"build", "deploy", "release", "ci", "pipeline"},
|
||||
}
|
||||
|
||||
for domain, keywords := range domainKeywords {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(textLower, kw) {
|
||||
return domain
|
||||
}
|
||||
}
|
||||
}
|
||||
return "general"
|
||||
}
|
||||
|
||||
func inferCheckTarget(text string) string {
|
||||
textLower := strings.ToLower(text)
|
||||
if strings.Contains(textLower, "zertifik") || strings.Contains(textLower, "zulassung") {
|
||||
return "certificate"
|
||||
}
|
||||
if strings.Contains(textLower, "prozess") || strings.Contains(textLower, "verfahren") {
|
||||
return "process"
|
||||
}
|
||||
if strings.Contains(textLower, "konfigur") {
|
||||
return "config"
|
||||
}
|
||||
return "code"
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
305
ai-compliance-sdk/internal/ucca/betrvg_test.go
Normal file
305
ai-compliance-sdk/internal/ucca/betrvg_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG Conflict Score Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCalculateBetrvgConflictScore_NoEmployeeData(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "Chatbot fuer Kunden-FAQ",
|
||||
Domain: DomainUtilities,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: false,
|
||||
PublicData: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.BetrvgConflictScore != 0 {
|
||||
t.Errorf("Expected BetrvgConflictScore 0 for non-employee case, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=false for non-employee case")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_EmployeeMonitoring(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "Teams Analytics mit Nutzungsstatistiken pro Mitarbeiter",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// employee_data(+10) + employee_monitoring(+20) + not_consulted(+5) = 35
|
||||
if result.BetrvgConflictScore < 30 {
|
||||
t.Errorf("Expected BetrvgConflictScore >= 30 for employee monitoring, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if !result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=true for employee monitoring")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_HRDecisionSupport(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI-gestuetztes Bewerber-Screening",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
HRDecisionSupport: true,
|
||||
Automation: "fully_automated",
|
||||
Outputs: Outputs{
|
||||
Rankings: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// employee_data(+10) + monitoring(+20) + hr(+20) + rankings(+10) + fully_auto(+10) + not_consulted(+5) = 75
|
||||
if result.BetrvgConflictScore < 70 {
|
||||
t.Errorf("Expected BetrvgConflictScore >= 70 for HR+monitoring+automated, got %d", result.BetrvgConflictScore)
|
||||
}
|
||||
if !result.BetrvgConsultationRequired {
|
||||
t.Error("Expected BetrvgConsultationRequired=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBetrvgConflictScore_ConsultedReducesScore(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
// Same as above but works council consulted
|
||||
intakeNotConsulted := &UseCaseIntake{
|
||||
UseCaseText: "Teams mit Nutzungsstatistiken",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
WorksCouncilConsulted: false,
|
||||
}
|
||||
|
||||
intakeConsulted := &UseCaseIntake{
|
||||
UseCaseText: "Teams mit Nutzungsstatistiken",
|
||||
Domain: DomainIT,
|
||||
DataTypes: DataTypes{
|
||||
PersonalData: true,
|
||||
EmployeeData: true,
|
||||
},
|
||||
EmployeeMonitoring: true,
|
||||
WorksCouncilConsulted: true,
|
||||
}
|
||||
|
||||
resultNot := engine.Evaluate(intakeNotConsulted)
|
||||
resultYes := engine.Evaluate(intakeConsulted)
|
||||
|
||||
if resultYes.BetrvgConflictScore >= resultNot.BetrvgConflictScore {
|
||||
t.Errorf("Expected consulted score (%d) < not-consulted score (%d)",
|
||||
resultYes.BetrvgConflictScore, resultNot.BetrvgConflictScore)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG Escalation Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEscalation_BetrvgHighConflict_E3(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityCONDITIONAL,
|
||||
RiskLevel: RiskLevelMEDIUM,
|
||||
RiskScore: 45,
|
||||
BetrvgConflictScore: 80,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: false,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{
|
||||
{Code: "R-WARN-001", Severity: "WARN"},
|
||||
},
|
||||
}
|
||||
|
||||
level, reason := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
if level != EscalationLevelE3 {
|
||||
t.Errorf("Expected E3 for high BR conflict without consultation, got %s (reason: %s)", level, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalation_BetrvgMediumConflict_E2(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityCONDITIONAL,
|
||||
RiskLevel: RiskLevelLOW,
|
||||
RiskScore: 25,
|
||||
BetrvgConflictScore: 55,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: false,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{
|
||||
{Code: "R-WARN-001", Severity: "WARN"},
|
||||
},
|
||||
}
|
||||
|
||||
level, reason := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
if level != EscalationLevelE2 {
|
||||
t.Errorf("Expected E2 for medium BR conflict without consultation, got %s (reason: %s)", level, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalation_BetrvgConsulted_NoEscalation(t *testing.T) {
|
||||
trigger := DefaultEscalationTrigger()
|
||||
|
||||
result := &AssessmentResult{
|
||||
Feasibility: FeasibilityYES,
|
||||
RiskLevel: RiskLevelLOW,
|
||||
RiskScore: 15,
|
||||
BetrvgConflictScore: 55,
|
||||
BetrvgConsultationRequired: true,
|
||||
Intake: UseCaseIntake{
|
||||
WorksCouncilConsulted: true,
|
||||
},
|
||||
TriggeredRules: []TriggeredRule{},
|
||||
}
|
||||
|
||||
level, _ := trigger.DetermineEscalationLevel(result)
|
||||
|
||||
// With consultation done and low risk, should not escalate for BR reasons
|
||||
if level == EscalationLevelE3 {
|
||||
t.Error("Should not escalate to E3 when works council is consulted")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BetrVG V2 Obligations Loading Test
|
||||
// ============================================================================
|
||||
|
||||
func TestBetrvgV2_LoadsFromManifest(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
v2Dir := filepath.Join(root, "policies", "obligations", "v2")
|
||||
|
||||
// Check file exists
|
||||
betrvgPath := filepath.Join(v2Dir, "betrvg_v2.json")
|
||||
if _, err := os.Stat(betrvgPath); os.IsNotExist(err) {
|
||||
t.Fatal("betrvg_v2.json not found in policies/obligations/v2/")
|
||||
}
|
||||
|
||||
// Load all v2 regulations
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
betrvg, ok := regs["betrvg"]
|
||||
if !ok {
|
||||
t.Fatal("betrvg not found in loaded regulations")
|
||||
}
|
||||
|
||||
if betrvg.Regulation != "betrvg" {
|
||||
t.Errorf("Expected regulation 'betrvg', got '%s'", betrvg.Regulation)
|
||||
}
|
||||
|
||||
if len(betrvg.Obligations) < 10 {
|
||||
t.Errorf("Expected at least 10 BetrVG obligations, got %d", len(betrvg.Obligations))
|
||||
}
|
||||
|
||||
// Check first obligation has correct structure
|
||||
obl := betrvg.Obligations[0]
|
||||
if obl.ID != "BETRVG-OBL-001" {
|
||||
t.Errorf("Expected first obligation ID 'BETRVG-OBL-001', got '%s'", obl.ID)
|
||||
}
|
||||
if len(obl.LegalBasis) == 0 {
|
||||
t.Error("Expected legal basis for first obligation")
|
||||
}
|
||||
if obl.LegalBasis[0].Norm != "BetrVG" {
|
||||
t.Errorf("Expected norm 'BetrVG', got '%s'", obl.LegalBasis[0].Norm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetrvgApplicability_Germany(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
betrvgReg := regs["betrvg"]
|
||||
module := NewJSONRegulationModule(betrvgReg)
|
||||
|
||||
// German company with 50 employees — should be applicable
|
||||
factsDE := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "DE",
|
||||
EmployeeCount: 50,
|
||||
},
|
||||
}
|
||||
if !module.IsApplicable(factsDE) {
|
||||
t.Error("BetrVG should be applicable for German company with 50 employees")
|
||||
}
|
||||
|
||||
// US company — should NOT be applicable
|
||||
factsUS := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "US",
|
||||
EmployeeCount: 50,
|
||||
},
|
||||
}
|
||||
if module.IsApplicable(factsUS) {
|
||||
t.Error("BetrVG should NOT be applicable for US company")
|
||||
}
|
||||
|
||||
// German company with 3 employees — should NOT be applicable (threshold 5)
|
||||
factsSmall := &UnifiedFacts{
|
||||
Organization: OrganizationFacts{
|
||||
Country: "DE",
|
||||
EmployeeCount: 3,
|
||||
},
|
||||
}
|
||||
if module.IsApplicable(factsSmall) {
|
||||
t.Error("BetrVG should NOT be applicable for company with < 5 employees")
|
||||
}
|
||||
}
|
||||
325
ai-compliance-sdk/internal/ucca/decision_tree_engine.go
Normal file
325
ai-compliance-sdk/internal/ucca/decision_tree_engine.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package ucca
|
||||
|
||||
// ============================================================================
|
||||
// AI Act Decision Tree Engine
|
||||
// ============================================================================
|
||||
//
|
||||
// Two-axis classification:
|
||||
// Axis 1 (Q1–Q7): High-Risk classification based on Annex III
|
||||
// Axis 2 (Q8–Q12): GPAI classification based on Art. 51–56
|
||||
//
|
||||
// Deterministic evaluation — no LLM involved.
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
// Question IDs
|
||||
const (
|
||||
Q1 = "Q1" // Uses AI?
|
||||
Q2 = "Q2" // Biometric identification?
|
||||
Q3 = "Q3" // Critical infrastructure?
|
||||
Q4 = "Q4" // Education / employment / HR?
|
||||
Q5 = "Q5" // Essential services (credit, insurance)?
|
||||
Q6 = "Q6" // Law enforcement / migration / justice?
|
||||
Q7 = "Q7" // Autonomous decisions with legal effect?
|
||||
Q8 = "Q8" // Foundation Model / GPAI?
|
||||
Q9 = "Q9" // Generates content (text, image, code, audio)?
|
||||
Q10 = "Q10" // Trained with >10^25 FLOP?
|
||||
Q11 = "Q11" // Model provided as API/service for third parties?
|
||||
Q12 = "Q12" // Significant EU market penetration?
|
||||
)
|
||||
|
||||
// BuildDecisionTreeDefinition returns the full decision tree structure for the frontend
|
||||
func BuildDecisionTreeDefinition() *DecisionTreeDefinition {
|
||||
return &DecisionTreeDefinition{
|
||||
ID: "ai_act_two_axis",
|
||||
Name: "AI Act Zwei-Achsen-Klassifikation",
|
||||
Version: "1.0.0",
|
||||
Questions: []DecisionTreeQuestion{
|
||||
// === Axis 1: High-Risk (Annex III) ===
|
||||
{
|
||||
ID: Q1,
|
||||
Axis: "high_risk",
|
||||
Question: "Setzt Ihr System KI-Technologie ein?",
|
||||
Description: "KI im Sinne des AI Act umfasst maschinelles Lernen, logik- und wissensbasierte Ansätze sowie statistische Methoden, die für eine gegebene Reihe von Zielen Ergebnisse wie Inhalte, Vorhersagen, Empfehlungen oder Entscheidungen erzeugen.",
|
||||
ArticleRef: "Art. 3 Nr. 1",
|
||||
},
|
||||
{
|
||||
ID: Q2,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System für biometrische Identifikation oder Kategorisierung natürlicher Personen verwendet?",
|
||||
Description: "Dazu zählen Gesichtserkennung, Stimmerkennung, Fingerabdruck-Analyse, Gangerkennung oder andere biometrische Merkmale zur Identifikation oder Kategorisierung.",
|
||||
ArticleRef: "Anhang III Nr. 1",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q3,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System in kritischer Infrastruktur eingesetzt (Energie, Verkehr, Wasser, digitale Infrastruktur)?",
|
||||
Description: "Betrifft KI-Systeme als Sicherheitskomponenten in der Verwaltung und dem Betrieb kritischer digitaler Infrastruktur, des Straßenverkehrs oder der Wasser-, Gas-, Heizungs- oder Stromversorgung.",
|
||||
ArticleRef: "Anhang III Nr. 2",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q4,
|
||||
Axis: "high_risk",
|
||||
Question: "Betrifft das System Bildung, Beschäftigung oder Personalmanagement?",
|
||||
Description: "KI zur Festlegung des Zugangs zu Bildungseinrichtungen, Bewertung von Prüfungsleistungen, Bewerbungsauswahl, Beförderungsentscheidungen oder Überwachung von Arbeitnehmern.",
|
||||
ArticleRef: "Anhang III Nr. 3–4",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q5,
|
||||
Axis: "high_risk",
|
||||
Question: "Betrifft das System den Zugang zu wesentlichen Diensten (Kreditvergabe, Versicherung, öffentliche Leistungen)?",
|
||||
Description: "KI zur Bonitätsbewertung, Risikobewertung bei Versicherungen, Bewertung der Anspruchsberechtigung für öffentliche Unterstützungsleistungen oder Notdienste.",
|
||||
ArticleRef: "Anhang III Nr. 5",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q6,
|
||||
Axis: "high_risk",
|
||||
Question: "Wird das System in Strafverfolgung, Migration, Asyl oder Justiz eingesetzt?",
|
||||
Description: "KI für Lügendetektoren, Beweisbewertung, Rückfallprognose, Asylentscheidungen, Grenzkontrolle, Risikobewertung bei Migration oder Unterstützung der Rechtspflege.",
|
||||
ArticleRef: "Anhang III Nr. 6–8",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
{
|
||||
ID: Q7,
|
||||
Axis: "high_risk",
|
||||
Question: "Trifft das System autonome Entscheidungen mit rechtlicher Wirkung für natürliche Personen?",
|
||||
Description: "Entscheidungen, die Rechtsverhältnisse begründen, ändern oder aufheben, z.B. Kreditablehnungen, Kündigungen, Sozialleistungsentscheidungen — ohne menschliche Überprüfung im Einzelfall.",
|
||||
ArticleRef: "Art. 22 DSGVO / Art. 14 AI Act",
|
||||
SkipIf: Q1,
|
||||
},
|
||||
|
||||
// === Axis 2: GPAI (Art. 51–56) ===
|
||||
{
|
||||
ID: Q8,
|
||||
Axis: "gpai",
|
||||
Question: "Stellst du ein KI-Modell fuer Dritte bereit (API / Plattform / SDK), das fuer viele verschiedene Zwecke einsetzbar ist?",
|
||||
Description: "GPAI-Pflichten (Art. 51-56) gelten fuer den Modellanbieter, nicht den API-Nutzer. Wenn du nur eine API nutzt (z.B. OpenAI, Claude), bist du kein GPAI-Anbieter. GPAI-Anbieter ist, wer ein Modell trainiert/fine-tuned und Dritten zur Verfuegung stellt. Beispiele: GPT, Claude, LLaMA, Gemini, Stable Diffusion.",
|
||||
ArticleRef: "Art. 3 Nr. 63 / Art. 51",
|
||||
},
|
||||
{
|
||||
ID: Q9,
|
||||
Axis: "gpai",
|
||||
Question: "Kann das System Inhalte generieren (Text, Bild, Code, Audio, Video)?",
|
||||
Description: "Generative KI erzeugt neue Inhalte auf Basis von Eingaben — dazu zählen Chatbots, Bild-/Videogeneratoren, Code-Assistenten, Sprachsynthese und ähnliche Systeme.",
|
||||
ArticleRef: "Art. 50 / Art. 52",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q10,
|
||||
Axis: "gpai",
|
||||
Question: "Wurde das Modell mit mehr als 10²⁵ FLOP trainiert oder hat es gleichwertige Fähigkeiten?",
|
||||
Description: "GPAI-Modelle mit einem kumulativen Rechenaufwand von mehr als 10²⁵ Gleitkommaoperationen gelten als Modelle mit systemischem Risiko (Art. 51 Abs. 2).",
|
||||
ArticleRef: "Art. 51 Abs. 2",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q11,
|
||||
Axis: "gpai",
|
||||
Question: "Wird das Modell als API oder Service für Dritte bereitgestellt?",
|
||||
Description: "Stellen Sie das Modell anderen Unternehmen oder Entwicklern zur Nutzung bereit (API, SaaS, Plattform-Integration)?",
|
||||
ArticleRef: "Art. 53",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
{
|
||||
ID: Q12,
|
||||
Axis: "gpai",
|
||||
Question: "Hat das Modell eine signifikante Marktdurchdringung in der EU (>10.000 registrierte Geschäftsnutzer)?",
|
||||
Description: "Modelle mit hoher Marktdurchdringung können auch ohne 10²⁵ FLOP als systemisches Risiko eingestuft werden, wenn die EU-Kommission dies feststellt.",
|
||||
ArticleRef: "Art. 51 Abs. 3",
|
||||
SkipIf: Q8,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateDecisionTree evaluates the answers and returns the combined result
|
||||
func EvaluateDecisionTree(req *DecisionTreeEvalRequest) *DecisionTreeResult {
|
||||
result := &DecisionTreeResult{
|
||||
SystemName: req.SystemName,
|
||||
SystemDescription: req.SystemDescription,
|
||||
Answers: req.Answers,
|
||||
}
|
||||
|
||||
// Evaluate Axis 1: High-Risk
|
||||
result.HighRiskResult = evaluateHighRiskAxis(req.Answers)
|
||||
|
||||
// Evaluate Axis 2: GPAI
|
||||
result.GPAIResult = evaluateGPAIAxis(req.Answers)
|
||||
|
||||
// Combine obligations and articles
|
||||
result.CombinedObligations = combineObligations(result.HighRiskResult, result.GPAIResult)
|
||||
result.ApplicableArticles = combineArticles(result.HighRiskResult, result.GPAIResult)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// evaluateHighRiskAxis determines the AI Act risk level from Q1–Q7
|
||||
func evaluateHighRiskAxis(answers map[string]DecisionTreeAnswer) AIActRiskLevel {
|
||||
// Q1: Uses AI at all?
|
||||
if !answerIsYes(answers, Q1) {
|
||||
return AIActNotApplicable
|
||||
}
|
||||
|
||||
// Q2–Q6: Annex III high-risk categories
|
||||
if answerIsYes(answers, Q2) || answerIsYes(answers, Q3) ||
|
||||
answerIsYes(answers, Q4) || answerIsYes(answers, Q5) ||
|
||||
answerIsYes(answers, Q6) {
|
||||
return AIActHighRisk
|
||||
}
|
||||
|
||||
// Q7: Autonomous decisions with legal effect
|
||||
if answerIsYes(answers, Q7) {
|
||||
return AIActHighRisk
|
||||
}
|
||||
|
||||
// AI is used but no high-risk category triggered
|
||||
return AIActMinimalRisk
|
||||
}
|
||||
|
||||
// evaluateGPAIAxis determines the GPAI classification from Q8–Q12
|
||||
func evaluateGPAIAxis(answers map[string]DecisionTreeAnswer) GPAIClassification {
|
||||
gpai := GPAIClassification{
|
||||
Category: GPAICategoryNone,
|
||||
ApplicableArticles: []string{},
|
||||
Obligations: []string{},
|
||||
}
|
||||
|
||||
// Q8: Is GPAI?
|
||||
if !answerIsYes(answers, Q8) {
|
||||
return gpai
|
||||
}
|
||||
|
||||
gpai.IsGPAI = true
|
||||
gpai.Category = GPAICategoryStandard
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 51", "Art. 53")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Technische Dokumentation erstellen (Art. 53 Abs. 1a)",
|
||||
"Informationen für nachgelagerte Anbieter bereitstellen (Art. 53 Abs. 1b)",
|
||||
"Urheberrechtsrichtlinie einhalten (Art. 53 Abs. 1c)",
|
||||
"Trainingsdaten-Zusammenfassung veröffentlichen (Art. 53 Abs. 1d)",
|
||||
)
|
||||
|
||||
// Q9: Generative AI — adds transparency obligations
|
||||
if answerIsYes(answers, Q9) {
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 50")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"KI-generierte Inhalte kennzeichnen (Art. 50 Abs. 2)",
|
||||
"Maschinenlesbare Kennzeichnung synthetischer Inhalte (Art. 50 Abs. 2)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q10: Systemic risk threshold (>10^25 FLOP)
|
||||
if answerIsYes(answers, Q10) {
|
||||
gpai.IsSystemicRisk = true
|
||||
gpai.Category = GPAICategorySystemic
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 55")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Modellbewertung nach Stand der Technik durchführen (Art. 55 Abs. 1a)",
|
||||
"Systemische Risiken bewerten und mindern (Art. 55 Abs. 1b)",
|
||||
"Schwerwiegende Vorfälle melden (Art. 55 Abs. 1c)",
|
||||
"Angemessenes Cybersicherheitsniveau gewährleisten (Art. 55 Abs. 1d)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q11: API/Service provider — additional downstream obligations
|
||||
if answerIsYes(answers, Q11) {
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Downstream-Informationspflichten erfüllen (Art. 53 Abs. 1b)",
|
||||
)
|
||||
}
|
||||
|
||||
// Q12: Significant market penetration — potential systemic risk
|
||||
if answerIsYes(answers, Q12) && !gpai.IsSystemicRisk {
|
||||
// EU Commission can designate as systemic risk
|
||||
gpai.ApplicableArticles = append(gpai.ApplicableArticles, "Art. 51 Abs. 3")
|
||||
gpai.Obligations = append(gpai.Obligations,
|
||||
"Achtung: EU-Kommission kann GPAI mit hoher Marktdurchdringung als systemisches Risiko einstufen (Art. 51 Abs. 3)",
|
||||
)
|
||||
}
|
||||
|
||||
return gpai
|
||||
}
|
||||
|
||||
// combineObligations merges obligations from both axes
|
||||
func combineObligations(highRisk AIActRiskLevel, gpai GPAIClassification) []string {
|
||||
var obligations []string
|
||||
|
||||
// High-Risk obligations
|
||||
switch highRisk {
|
||||
case AIActHighRisk:
|
||||
obligations = append(obligations,
|
||||
"Risikomanagementsystem einrichten (Art. 9)",
|
||||
"Daten-Governance sicherstellen (Art. 10)",
|
||||
"Technische Dokumentation erstellen (Art. 11)",
|
||||
"Protokollierungsfunktion implementieren (Art. 12)",
|
||||
"Transparenz und Nutzerinformation (Art. 13)",
|
||||
"Menschliche Aufsicht ermöglichen (Art. 14)",
|
||||
"Genauigkeit, Robustheit und Cybersicherheit (Art. 15)",
|
||||
"EU-Datenbank-Registrierung (Art. 49)",
|
||||
)
|
||||
case AIActMinimalRisk:
|
||||
obligations = append(obligations,
|
||||
"Freiwillige Verhaltenskodizes empfohlen (Art. 95)",
|
||||
)
|
||||
case AIActNotApplicable:
|
||||
// No obligations
|
||||
}
|
||||
|
||||
// GPAI obligations
|
||||
obligations = append(obligations, gpai.Obligations...)
|
||||
|
||||
// Universal obligation for all AI users
|
||||
if highRisk != AIActNotApplicable {
|
||||
obligations = append(obligations,
|
||||
"KI-Kompetenz sicherstellen (Art. 4)",
|
||||
"Verbotene Praktiken vermeiden (Art. 5)",
|
||||
)
|
||||
}
|
||||
|
||||
return obligations
|
||||
}
|
||||
|
||||
// combineArticles merges applicable articles from both axes
|
||||
func combineArticles(highRisk AIActRiskLevel, gpai GPAIClassification) []string {
|
||||
articles := map[string]bool{}
|
||||
|
||||
// Universal
|
||||
if highRisk != AIActNotApplicable {
|
||||
articles["Art. 4"] = true
|
||||
articles["Art. 5"] = true
|
||||
}
|
||||
|
||||
// High-Risk
|
||||
switch highRisk {
|
||||
case AIActHighRisk:
|
||||
for _, a := range []string{"Art. 9", "Art. 10", "Art. 11", "Art. 12", "Art. 13", "Art. 14", "Art. 15", "Art. 26", "Art. 49"} {
|
||||
articles[a] = true
|
||||
}
|
||||
case AIActMinimalRisk:
|
||||
articles["Art. 95"] = true
|
||||
}
|
||||
|
||||
// GPAI
|
||||
for _, a := range gpai.ApplicableArticles {
|
||||
articles[a] = true
|
||||
}
|
||||
|
||||
var result []string
|
||||
for a := range articles {
|
||||
result = append(result, a)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// answerIsYes checks if a question was answered with "yes" (true)
|
||||
func answerIsYes(answers map[string]DecisionTreeAnswer, questionID string) bool {
|
||||
a, ok := answers[questionID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return a.Value
|
||||
}
|
||||
420
ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go
Normal file
420
ai-compliance-sdk/internal/ucca/decision_tree_engine_test.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDecisionTreeDefinition_ReturnsValidTree(t *testing.T) {
|
||||
tree := BuildDecisionTreeDefinition()
|
||||
|
||||
if tree == nil {
|
||||
t.Fatal("Expected non-nil tree definition")
|
||||
}
|
||||
if tree.ID != "ai_act_two_axis" {
|
||||
t.Errorf("Expected ID 'ai_act_two_axis', got '%s'", tree.ID)
|
||||
}
|
||||
if tree.Version != "1.0.0" {
|
||||
t.Errorf("Expected version '1.0.0', got '%s'", tree.Version)
|
||||
}
|
||||
if len(tree.Questions) != 12 {
|
||||
t.Errorf("Expected 12 questions, got %d", len(tree.Questions))
|
||||
}
|
||||
|
||||
// Check axis distribution
|
||||
hrCount := 0
|
||||
gpaiCount := 0
|
||||
for _, q := range tree.Questions {
|
||||
switch q.Axis {
|
||||
case "high_risk":
|
||||
hrCount++
|
||||
case "gpai":
|
||||
gpaiCount++
|
||||
default:
|
||||
t.Errorf("Unexpected axis '%s' for question %s", q.Axis, q.ID)
|
||||
}
|
||||
}
|
||||
if hrCount != 7 {
|
||||
t.Errorf("Expected 7 high_risk questions, got %d", hrCount)
|
||||
}
|
||||
if gpaiCount != 5 {
|
||||
t.Errorf("Expected 5 gpai questions, got %d", gpaiCount)
|
||||
}
|
||||
|
||||
// Check all questions have required fields
|
||||
for _, q := range tree.Questions {
|
||||
if q.ID == "" {
|
||||
t.Error("Question has empty ID")
|
||||
}
|
||||
if q.Question == "" {
|
||||
t.Errorf("Question %s has empty question text", q.ID)
|
||||
}
|
||||
if q.Description == "" {
|
||||
t.Errorf("Question %s has empty description", q.ID)
|
||||
}
|
||||
if q.ArticleRef == "" {
|
||||
t.Errorf("Question %s has empty article_ref", q.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_NotApplicable(t *testing.T) {
|
||||
// Q1=No → AI Act not applicable
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Test System",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActNotApplicable {
|
||||
t.Errorf("Expected not_applicable, got %s", result.HighRiskResult)
|
||||
}
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be false when Q8 is not answered")
|
||||
}
|
||||
if result.SystemName != "Test System" {
|
||||
t.Errorf("Expected system name 'Test System', got '%s'", result.SystemName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_MinimalRisk(t *testing.T) {
|
||||
// Q1=Yes, Q2-Q7=No → minimal risk
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Simple Tool",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
Q8: {QuestionID: Q8, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActMinimalRisk {
|
||||
t.Errorf("Expected minimal_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be false")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryNone {
|
||||
t.Errorf("Expected GPAI category 'none', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_Biometric(t *testing.T) {
|
||||
// Q1=Yes, Q2=Yes → high risk (biometric)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Face Recognition",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: true},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
|
||||
// Should have high-risk obligations
|
||||
if len(result.CombinedObligations) == 0 {
|
||||
t.Error("Expected non-empty obligations for high-risk system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_CriticalInfrastructure(t *testing.T) {
|
||||
// Q1=Yes, Q3=Yes → high risk (critical infrastructure)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Energy Grid AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: true},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_Education(t *testing.T) {
|
||||
// Q1=Yes, Q4=Yes → high risk (education/employment)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Exam Grading AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_HighRisk_AutonomousDecisions(t *testing.T) {
|
||||
// Q1=Yes, Q7=Yes → high risk (autonomous decisions)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Credit Scoring AI",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: false},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_Standard(t *testing.T) {
|
||||
// Q8=Yes, Q10=No → GPAI standard
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Custom LLM",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: false},
|
||||
Q12: {QuestionID: Q12, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected category 'standard', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if result.GPAIResult.IsSystemicRisk {
|
||||
t.Error("Expected IsSystemicRisk to be false")
|
||||
}
|
||||
|
||||
// Should have Art. 51, 53, 50 (generative)
|
||||
hasArt51 := false
|
||||
hasArt53 := false
|
||||
hasArt50 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 51" {
|
||||
hasArt51 = true
|
||||
}
|
||||
if a == "Art. 53" {
|
||||
hasArt53 = true
|
||||
}
|
||||
if a == "Art. 50" {
|
||||
hasArt50 = true
|
||||
}
|
||||
}
|
||||
if !hasArt51 {
|
||||
t.Error("Expected Art. 51 in applicable articles")
|
||||
}
|
||||
if !hasArt53 {
|
||||
t.Error("Expected Art. 53 in applicable articles")
|
||||
}
|
||||
if !hasArt50 {
|
||||
t.Error("Expected Art. 50 in applicable articles (generative AI)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_SystemicRisk(t *testing.T) {
|
||||
// Q8=Yes, Q10=Yes → GPAI systemic risk
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "GPT-5",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: true},
|
||||
Q11: {QuestionID: Q11, Value: true},
|
||||
Q12: {QuestionID: Q12, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategorySystemic {
|
||||
t.Errorf("Expected category 'systemic', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if !result.GPAIResult.IsSystemicRisk {
|
||||
t.Error("Expected IsSystemicRisk to be true")
|
||||
}
|
||||
|
||||
// Should have Art. 55
|
||||
hasArt55 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 55" {
|
||||
hasArt55 = true
|
||||
}
|
||||
}
|
||||
if !hasArt55 {
|
||||
t.Error("Expected Art. 55 in applicable articles (systemic risk)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_Combined_HighRiskAndGPAI(t *testing.T) {
|
||||
// Q1=Yes, Q4=Yes (high risk) + Q8=Yes, Q9=Yes (GPAI standard)
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "HR Screening with LLM",
|
||||
SystemDescription: "LLM-based applicant screening system",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q2: {QuestionID: Q2, Value: false},
|
||||
Q3: {QuestionID: Q3, Value: false},
|
||||
Q4: {QuestionID: Q4, Value: true},
|
||||
Q5: {QuestionID: Q5, Value: false},
|
||||
Q6: {QuestionID: Q6, Value: false},
|
||||
Q7: {QuestionID: Q7, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: false},
|
||||
Q12: {QuestionID: Q12, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
// Both axes should be triggered
|
||||
if result.HighRiskResult != AIActHighRisk {
|
||||
t.Errorf("Expected high_risk, got %s", result.HighRiskResult)
|
||||
}
|
||||
if !result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected GPAI to be true")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected GPAI category 'standard', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
|
||||
// Combined obligations should include both axes
|
||||
if len(result.CombinedObligations) < 5 {
|
||||
t.Errorf("Expected at least 5 combined obligations, got %d", len(result.CombinedObligations))
|
||||
}
|
||||
|
||||
// Should have articles from both axes
|
||||
if len(result.ApplicableArticles) < 3 {
|
||||
t.Errorf("Expected at least 3 applicable articles, got %d", len(result.ApplicableArticles))
|
||||
}
|
||||
|
||||
// Check system name preserved
|
||||
if result.SystemName != "HR Screening with LLM" {
|
||||
t.Errorf("Expected system name preserved, got '%s'", result.SystemName)
|
||||
}
|
||||
if result.SystemDescription != "LLM-based applicant screening system" {
|
||||
t.Errorf("Expected description preserved, got '%s'", result.SystemDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_GPAI_MarketPenetration(t *testing.T) {
|
||||
// Q8=Yes, Q10=No, Q12=Yes → GPAI standard with market penetration warning
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Popular Chatbot",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: true},
|
||||
Q9: {QuestionID: Q9, Value: true},
|
||||
Q10: {QuestionID: Q10, Value: false},
|
||||
Q11: {QuestionID: Q11, Value: true},
|
||||
Q12: {QuestionID: Q12, Value: true},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.GPAIResult.Category != GPAICategoryStandard {
|
||||
t.Errorf("Expected category 'standard' (not systemic because Q10=No), got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
|
||||
// Should have Art. 51 Abs. 3 warning
|
||||
hasArt51_3 := false
|
||||
for _, a := range result.GPAIResult.ApplicableArticles {
|
||||
if a == "Art. 51 Abs. 3" {
|
||||
hasArt51_3 = true
|
||||
}
|
||||
}
|
||||
if !hasArt51_3 {
|
||||
t.Error("Expected Art. 51 Abs. 3 in applicable articles for high market penetration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDecisionTree_NoGPAI(t *testing.T) {
|
||||
// Q8=No → No GPAI classification
|
||||
req := &DecisionTreeEvalRequest{
|
||||
SystemName: "Traditional ML",
|
||||
Answers: map[string]DecisionTreeAnswer{
|
||||
Q1: {QuestionID: Q1, Value: true},
|
||||
Q8: {QuestionID: Q8, Value: false},
|
||||
},
|
||||
}
|
||||
|
||||
result := EvaluateDecisionTree(req)
|
||||
|
||||
if result.GPAIResult.IsGPAI {
|
||||
t.Error("Expected IsGPAI to be false")
|
||||
}
|
||||
if result.GPAIResult.Category != GPAICategoryNone {
|
||||
t.Errorf("Expected category 'none', got '%s'", result.GPAIResult.Category)
|
||||
}
|
||||
if len(result.GPAIResult.Obligations) != 0 {
|
||||
t.Errorf("Expected 0 GPAI obligations, got %d", len(result.GPAIResult.Obligations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerIsYes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
answers map[string]DecisionTreeAnswer
|
||||
qID string
|
||||
expected bool
|
||||
}{
|
||||
{"yes answer", map[string]DecisionTreeAnswer{"Q1": {Value: true}}, "Q1", true},
|
||||
{"no answer", map[string]DecisionTreeAnswer{"Q1": {Value: false}}, "Q1", false},
|
||||
{"missing answer", map[string]DecisionTreeAnswer{}, "Q1", false},
|
||||
{"different question", map[string]DecisionTreeAnswer{"Q2": {Value: true}}, "Q1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := answerIsYes(tt.answers, tt.qID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
542
ai-compliance-sdk/internal/ucca/domain_context_test.go
Normal file
542
ai-compliance-sdk/internal/ucca/domain_context_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// HR Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestHRContext_AutomatedRejection_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert und versendet Absagen automatisch",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{
|
||||
AutomatedScreening: true,
|
||||
AutomatedRejection: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO feasibility for automated rejection, got %s", result.Feasibility)
|
||||
}
|
||||
if !result.Art22Risk {
|
||||
t.Error("Expected Art22Risk=true for automated rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHRContext_ScreeningWithHumanReview_OK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI sortiert Bewerber vor, Mensch prueft jeden Vorschlag",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{
|
||||
AutomatedScreening: true,
|
||||
AutomatedRejection: false,
|
||||
HumanReviewEnforced: true,
|
||||
BiasAuditsDone: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
// Should NOT block — human review is enforced
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when human review is enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHRContext_AGGVisible_RiskIncrease(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intakeWithAGG := &UseCaseIntake{
|
||||
UseCaseText: "CV-Screening mit Foto und Name sichtbar",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{AGGCategoriesVisible: true},
|
||||
}
|
||||
intakeWithout := &UseCaseIntake{
|
||||
UseCaseText: "CV-Screening anonymisiert",
|
||||
Domain: DomainHR,
|
||||
DataTypes: DataTypes{PersonalData: true, EmployeeData: true},
|
||||
HRContext: &HRContext{AGGCategoriesVisible: false},
|
||||
}
|
||||
|
||||
resultWith := engine.Evaluate(intakeWithAGG)
|
||||
resultWithout := engine.Evaluate(intakeWithout)
|
||||
|
||||
if resultWith.RiskScore <= resultWithout.RiskScore {
|
||||
t.Errorf("Expected higher risk with AGG visible (%d) vs without (%d)",
|
||||
resultWith.RiskScore, resultWithout.RiskScore)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Education Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestEducationContext_MinorsWithoutTeacher_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI bewertet Schuelerarbeiten ohne Lehrkraft-Pruefung",
|
||||
Domain: DomainEducation,
|
||||
DataTypes: DataTypes{PersonalData: true, MinorData: true},
|
||||
EducationContext: &EducationContext{
|
||||
GradeInfluence: true,
|
||||
MinorsInvolved: true,
|
||||
TeacherReviewRequired: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO feasibility for minors without teacher review, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEducationContext_WithTeacherReview_Allowed(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI schlaegt Noten vor, Lehrkraft prueft und entscheidet",
|
||||
Domain: DomainEducation,
|
||||
DataTypes: DataTypes{PersonalData: true, MinorData: true},
|
||||
EducationContext: &EducationContext{
|
||||
GradeInfluence: true,
|
||||
MinorsInvolved: true,
|
||||
TeacherReviewRequired: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when teacher review is required")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Healthcare Domain Context Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestHealthcareContext_MDRWithoutValidation_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI-Diagnosetool als Medizinprodukt ohne klinische Validierung",
|
||||
Domain: DomainHealthcare,
|
||||
DataTypes: DataTypes{PersonalData: true, Article9Data: true},
|
||||
HealthcareContext: &HealthcareContext{
|
||||
DiagnosisSupport: true,
|
||||
MedicalDevice: true,
|
||||
ClinicalValidation: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for medical device without clinical validation, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthcareContext_Triage_HighRisk(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI priorisiert Patienten in der Notaufnahme",
|
||||
Domain: DomainHealthcare,
|
||||
DataTypes: DataTypes{PersonalData: true, Article9Data: true},
|
||||
HealthcareContext: &HealthcareContext{
|
||||
TriageDecision: true,
|
||||
PatientDataProcessed: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.RiskScore < 40 {
|
||||
t.Errorf("Expected high risk score for triage, got %d", result.RiskScore)
|
||||
}
|
||||
if !result.DSFARecommended {
|
||||
t.Error("Expected DSFA recommended for triage")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Critical Infrastructure Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCriticalInfra_SafetyCriticalNoRedundancy_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI steuert Stromnetz ohne Fallback",
|
||||
Domain: DomainEnergy,
|
||||
CriticalInfraContext: &CriticalInfraContext{
|
||||
GridControl: true,
|
||||
SafetyCritical: true,
|
||||
RedundancyExists: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for safety-critical without redundancy, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Marketing — Deepfake BLOCK Test
|
||||
// ============================================================================
|
||||
|
||||
func TestMarketing_DeepfakeUnlabeled_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert Werbevideos ohne Kennzeichnung",
|
||||
Domain: DomainMarketing,
|
||||
MarketingContext: &MarketingContext{
|
||||
DeepfakeContent: true,
|
||||
AIContentLabeled: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for unlabeled deepfakes, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketing_DeepfakeLabeled_OK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert Werbevideos mit Kennzeichnung",
|
||||
Domain: DomainMarketing,
|
||||
MarketingContext: &MarketingContext{
|
||||
DeepfakeContent: true,
|
||||
AIContentLabeled: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility == FeasibilityNO {
|
||||
t.Error("Expected feasibility != NO when deepfakes are properly labeled")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Manufacturing — Safety BLOCK Test
|
||||
// ============================================================================
|
||||
|
||||
func TestManufacturing_SafetyUnvalidated_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI in Maschinensicherheit ohne Validierung",
|
||||
Domain: DomainMechanicalEngineering,
|
||||
ManufacturingContext: &ManufacturingContext{
|
||||
MachineSafety: true,
|
||||
SafetyValidated: false,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for unvalidated machine safety, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGG V2 Obligations Loading Test
|
||||
// ============================================================================
|
||||
|
||||
func TestAGGV2_LoadsFromManifest(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
agg, ok := regs["agg"]
|
||||
if !ok {
|
||||
t.Fatal("agg not found in loaded regulations")
|
||||
}
|
||||
|
||||
if len(agg.Obligations) < 8 {
|
||||
t.Errorf("Expected at least 8 AGG obligations, got %d", len(agg.Obligations))
|
||||
}
|
||||
|
||||
// Check first obligation
|
||||
if agg.Obligations[0].ID != "AGG-OBL-001" {
|
||||
t.Errorf("Expected first ID 'AGG-OBL-001', got '%s'", agg.Obligations[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGGApplicability_Germany(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
module := NewJSONRegulationModule(regs["agg"])
|
||||
|
||||
factsDE := &UnifiedFacts{Organization: OrganizationFacts{Country: "DE"}}
|
||||
if !module.IsApplicable(factsDE) {
|
||||
t.Error("AGG should be applicable for German company")
|
||||
}
|
||||
|
||||
factsUS := &UnifiedFacts{Organization: OrganizationFacts{Country: "US"}}
|
||||
if module.IsApplicable(factsUS) {
|
||||
t.Error("AGG should NOT be applicable for US company")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Act V2 Extended Obligations Test
|
||||
// ============================================================================
|
||||
|
||||
func TestAIActV2_ExtendedObligations(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
aiAct, ok := regs["ai_act"]
|
||||
if !ok {
|
||||
t.Fatal("ai_act not found in loaded regulations")
|
||||
}
|
||||
|
||||
if len(aiAct.Obligations) < 75 {
|
||||
t.Errorf("Expected at least 75 AI Act obligations (expanded), got %d", len(aiAct.Obligations))
|
||||
}
|
||||
|
||||
// Check GPAI obligations exist (Art. 51-56)
|
||||
hasGPAI := false
|
||||
for _, obl := range aiAct.Obligations {
|
||||
if obl.ID == "AIACT-OBL-078" { // GPAI classification
|
||||
hasGPAI = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasGPAI {
|
||||
t.Error("Expected GPAI obligation AIACT-OBL-078 in expanded AI Act")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Field Resolver Tests — Domain Contexts
|
||||
// ============================================================================
|
||||
|
||||
func TestFieldResolver_HRContext(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
HRContext: &HRContext{AutomatedScreening: true},
|
||||
}
|
||||
|
||||
val := engine.getFieldValue("hr_context.automated_screening", intake)
|
||||
if val != true {
|
||||
t.Errorf("Expected true for hr_context.automated_screening, got %v", val)
|
||||
}
|
||||
|
||||
val2 := engine.getFieldValue("hr_context.automated_rejection", intake)
|
||||
if val2 != false {
|
||||
t.Errorf("Expected false for hr_context.automated_rejection, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResolver_NilContext(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{} // No HR context
|
||||
|
||||
val := engine.getFieldValue("hr_context.automated_screening", intake)
|
||||
if val != nil {
|
||||
t.Errorf("Expected nil for nil HR context, got %v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResolver_HealthcareContext(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
HealthcareContext: &HealthcareContext{
|
||||
TriageDecision: true,
|
||||
MedicalDevice: false,
|
||||
},
|
||||
}
|
||||
|
||||
val := engine.getFieldValue("healthcare_context.triage_decision", intake)
|
||||
if val != true {
|
||||
t.Errorf("Expected true, got %v", val)
|
||||
}
|
||||
|
||||
val2 := engine.getFieldValue("healthcare_context.medical_device", intake)
|
||||
if val2 != false {
|
||||
t.Errorf("Expected false, got %v", val2)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hospitality — Review Manipulation BLOCK
|
||||
// ============================================================================
|
||||
|
||||
func TestHospitality_ReviewManipulation_BLOCK(t *testing.T) {
|
||||
root := getProjectRoot(t)
|
||||
policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml")
|
||||
engine, err := NewPolicyEngineFromPath(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create policy engine: %v", err)
|
||||
}
|
||||
|
||||
intake := &UseCaseIntake{
|
||||
UseCaseText: "KI generiert Fake-Bewertungen",
|
||||
Domain: DomainHospitality,
|
||||
HospitalityContext: &HospitalityContext{
|
||||
ReviewManipulation: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := engine.Evaluate(intake)
|
||||
|
||||
if result.Feasibility != FeasibilityNO {
|
||||
t.Errorf("Expected NO for review manipulation, got %s", result.Feasibility)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Total Obligations Count
|
||||
// ============================================================================
|
||||
|
||||
func TestTotalObligationsCount(t *testing.T) {
|
||||
regs, err := LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load v2 regulations: %v", err)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, reg := range regs {
|
||||
total += len(reg.Obligations)
|
||||
}
|
||||
|
||||
// We expect at least 350 obligations across all regulations
|
||||
if total < 350 {
|
||||
t.Errorf("Expected at least 350 total obligations, got %d", total)
|
||||
}
|
||||
|
||||
t.Logf("Total obligations across all regulations: %d", total)
|
||||
for id, reg := range regs {
|
||||
t.Logf(" %s: %d obligations", id, len(reg.Obligations))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Domain constant existence checks
|
||||
// ============================================================================
|
||||
|
||||
func TestDomainConstants_Exist(t *testing.T) {
|
||||
domains := []Domain{
|
||||
DomainHR, DomainEducation, DomainHealthcare,
|
||||
DomainFinance, DomainBanking, DomainInsurance,
|
||||
DomainEnergy, DomainUtilities,
|
||||
DomainAutomotive, DomainAerospace,
|
||||
DomainRetail, DomainEcommerce,
|
||||
DomainMarketing, DomainMedia,
|
||||
DomainLogistics, DomainConstruction,
|
||||
DomainPublicSector, DomainDefense,
|
||||
DomainMechanicalEngineering,
|
||||
}
|
||||
|
||||
for _, d := range domains {
|
||||
if d == "" {
|
||||
t.Error("Empty domain constant found")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -187,6 +188,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
||||
}
|
||||
}
|
||||
|
||||
// BetrVG E3: Very high conflict score without consultation
|
||||
if result.BetrvgConflictScore >= 75 && !result.Intake.WorksCouncilConsulted {
|
||||
reasons = append(reasons, "BetrVG-Konfliktpotenzial sehr hoch (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+") ohne BR-Konsultation")
|
||||
return EscalationLevelE3, joinReasons(reasons, "E3 erforderlich: ")
|
||||
}
|
||||
|
||||
if hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold {
|
||||
if result.DSFARecommended {
|
||||
reasons = append(reasons, "DSFA empfohlen")
|
||||
@@ -197,6 +204,12 @@ func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (
|
||||
return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ")
|
||||
}
|
||||
|
||||
// BetrVG E2: High conflict score
|
||||
if result.BetrvgConflictScore >= 50 && result.BetrvgConsultationRequired && !result.Intake.WorksCouncilConsulted {
|
||||
reasons = append(reasons, "BetrVG-Mitbestimmung erforderlich (Score "+fmt.Sprintf("%d", result.BetrvgConflictScore)+"), BR nicht konsultiert")
|
||||
return EscalationLevelE2, joinReasons(reasons, "BR-Konsultation erforderlich: ")
|
||||
}
|
||||
|
||||
// E1: Low priority checks
|
||||
// - WARN rules triggered
|
||||
// - Risk 20-40
|
||||
|
||||
@@ -56,6 +56,10 @@ func (m *JSONRegulationModule) defaultApplicability(facts *UnifiedFacts) bool {
|
||||
return facts.Organization.EUMember && facts.AIUsage.UsesAI
|
||||
case "dora":
|
||||
return facts.Financial.DORAApplies || facts.Financial.IsRegulated
|
||||
case "betrvg":
|
||||
return facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 5
|
||||
case "agg":
|
||||
return facts.Organization.Country == "DE"
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
274
ai-compliance-sdk/internal/ucca/registration_store.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AIRegistration represents an EU AI Database registration entry
|
||||
type AIRegistration struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
|
||||
// System
|
||||
SystemName string `json:"system_name"`
|
||||
SystemVersion string `json:"system_version,omitempty"`
|
||||
SystemDescription string `json:"system_description,omitempty"`
|
||||
IntendedPurpose string `json:"intended_purpose,omitempty"`
|
||||
|
||||
// Provider
|
||||
ProviderName string `json:"provider_name,omitempty"`
|
||||
ProviderLegalForm string `json:"provider_legal_form,omitempty"`
|
||||
ProviderAddress string `json:"provider_address,omitempty"`
|
||||
ProviderCountry string `json:"provider_country,omitempty"`
|
||||
EURepresentativeName string `json:"eu_representative_name,omitempty"`
|
||||
EURepresentativeContact string `json:"eu_representative_contact,omitempty"`
|
||||
|
||||
// Classification
|
||||
RiskClassification string `json:"risk_classification"`
|
||||
AnnexIIICategory string `json:"annex_iii_category,omitempty"`
|
||||
GPAIClassification string `json:"gpai_classification"`
|
||||
|
||||
// Conformity
|
||||
ConformityAssessmentType string `json:"conformity_assessment_type,omitempty"`
|
||||
NotifiedBodyName string `json:"notified_body_name,omitempty"`
|
||||
NotifiedBodyID string `json:"notified_body_id,omitempty"`
|
||||
CEMarking bool `json:"ce_marking"`
|
||||
|
||||
// Training data
|
||||
TrainingDataCategories json.RawMessage `json:"training_data_categories,omitempty"`
|
||||
TrainingDataSummary string `json:"training_data_summary,omitempty"`
|
||||
|
||||
// Status
|
||||
RegistrationStatus string `json:"registration_status"`
|
||||
EUDatabaseID string `json:"eu_database_id,omitempty"`
|
||||
RegistrationDate *time.Time `json:"registration_date,omitempty"`
|
||||
LastUpdateDate *time.Time `json:"last_update_date,omitempty"`
|
||||
|
||||
// Links
|
||||
UCCAAssessmentID *uuid.UUID `json:"ucca_assessment_id,omitempty"`
|
||||
DecisionTreeResultID *uuid.UUID `json:"decision_tree_result_id,omitempty"`
|
||||
|
||||
// Export
|
||||
ExportData json.RawMessage `json:"export_data,omitempty"`
|
||||
|
||||
// Audit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
SubmittedBy string `json:"submitted_by,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrationStore handles AI registration persistence
|
||||
type RegistrationStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRegistrationStore creates a new registration store
|
||||
func NewRegistrationStore(pool *pgxpool.Pool) *RegistrationStore {
|
||||
return &RegistrationStore{pool: pool}
|
||||
}
|
||||
|
||||
// Create creates a new registration
|
||||
func (s *RegistrationStore) Create(ctx context.Context, r *AIRegistration) error {
|
||||
r.ID = uuid.New()
|
||||
r.CreatedAt = time.Now()
|
||||
r.UpdatedAt = time.Now()
|
||||
if r.RegistrationStatus == "" {
|
||||
r.RegistrationStatus = "draft"
|
||||
}
|
||||
if r.RiskClassification == "" {
|
||||
r.RiskClassification = "not_classified"
|
||||
}
|
||||
if r.GPAIClassification == "" {
|
||||
r.GPAIClassification = "none"
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO ai_system_registrations (
|
||||
id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||
provider_name, provider_legal_form, provider_address, provider_country,
|
||||
eu_representative_name, eu_representative_contact,
|
||||
risk_classification, annex_iii_category, gpai_classification,
|
||||
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||
training_data_categories, training_data_summary,
|
||||
registration_status, ucca_assessment_id, decision_tree_result_id,
|
||||
created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25
|
||||
)`,
|
||||
r.ID, r.TenantID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||
r.EURepresentativeName, r.EURepresentativeContact,
|
||||
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||
r.RegistrationStatus, r.UCCAAssessmentID, r.DecisionTreeResultID,
|
||||
r.CreatedBy,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// List returns all registrations for a tenant
|
||||
func (s *RegistrationStore) List(ctx context.Context, tenantID uuid.UUID) ([]AIRegistration, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||
provider_name, provider_legal_form, provider_address, provider_country,
|
||||
eu_representative_name, eu_representative_contact,
|
||||
risk_classification, annex_iii_category, gpai_classification,
|
||||
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||
training_data_categories, training_data_summary,
|
||||
registration_status, eu_database_id, registration_date, last_update_date,
|
||||
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||
created_at, updated_at, created_by, submitted_by
|
||||
FROM ai_system_registrations
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var registrations []AIRegistration
|
||||
for rows.Next() {
|
||||
var r AIRegistration
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registrations = append(registrations, r)
|
||||
}
|
||||
return registrations, nil
|
||||
}
|
||||
|
||||
// GetByID returns a registration by ID
|
||||
func (s *RegistrationStore) GetByID(ctx context.Context, id uuid.UUID) (*AIRegistration, error) {
|
||||
var r AIRegistration
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, system_name, system_version, system_description, intended_purpose,
|
||||
provider_name, provider_legal_form, provider_address, provider_country,
|
||||
eu_representative_name, eu_representative_contact,
|
||||
risk_classification, annex_iii_category, gpai_classification,
|
||||
conformity_assessment_type, notified_body_name, notified_body_id, ce_marking,
|
||||
training_data_categories, training_data_summary,
|
||||
registration_status, eu_database_id, registration_date, last_update_date,
|
||||
ucca_assessment_id, decision_tree_result_id, export_data,
|
||||
created_at, updated_at, created_by, submitted_by
|
||||
FROM ai_system_registrations
|
||||
WHERE id = $1`,
|
||||
id,
|
||||
).Scan(
|
||||
&r.ID, &r.TenantID, &r.SystemName, &r.SystemVersion, &r.SystemDescription, &r.IntendedPurpose,
|
||||
&r.ProviderName, &r.ProviderLegalForm, &r.ProviderAddress, &r.ProviderCountry,
|
||||
&r.EURepresentativeName, &r.EURepresentativeContact,
|
||||
&r.RiskClassification, &r.AnnexIIICategory, &r.GPAIClassification,
|
||||
&r.ConformityAssessmentType, &r.NotifiedBodyName, &r.NotifiedBodyID, &r.CEMarking,
|
||||
&r.TrainingDataCategories, &r.TrainingDataSummary,
|
||||
&r.RegistrationStatus, &r.EUDatabaseID, &r.RegistrationDate, &r.LastUpdateDate,
|
||||
&r.UCCAAssessmentID, &r.DecisionTreeResultID, &r.ExportData,
|
||||
&r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, &r.SubmittedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// Update updates a registration
|
||||
func (s *RegistrationStore) Update(ctx context.Context, r *AIRegistration) error {
|
||||
r.UpdatedAt = time.Now()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE ai_system_registrations SET
|
||||
system_name = $2, system_version = $3, system_description = $4, intended_purpose = $5,
|
||||
provider_name = $6, provider_legal_form = $7, provider_address = $8, provider_country = $9,
|
||||
eu_representative_name = $10, eu_representative_contact = $11,
|
||||
risk_classification = $12, annex_iii_category = $13, gpai_classification = $14,
|
||||
conformity_assessment_type = $15, notified_body_name = $16, notified_body_id = $17, ce_marking = $18,
|
||||
training_data_categories = $19, training_data_summary = $20,
|
||||
registration_status = $21, eu_database_id = $22,
|
||||
export_data = $23, updated_at = $24, submitted_by = $25
|
||||
WHERE id = $1`,
|
||||
r.ID, r.SystemName, r.SystemVersion, r.SystemDescription, r.IntendedPurpose,
|
||||
r.ProviderName, r.ProviderLegalForm, r.ProviderAddress, r.ProviderCountry,
|
||||
r.EURepresentativeName, r.EURepresentativeContact,
|
||||
r.RiskClassification, r.AnnexIIICategory, r.GPAIClassification,
|
||||
r.ConformityAssessmentType, r.NotifiedBodyName, r.NotifiedBodyID, r.CEMarking,
|
||||
r.TrainingDataCategories, r.TrainingDataSummary,
|
||||
r.RegistrationStatus, r.EUDatabaseID,
|
||||
r.ExportData, r.UpdatedAt, r.SubmittedBy,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStatus changes only the registration status
|
||||
func (s *RegistrationStore) UpdateStatus(ctx context.Context, id uuid.UUID, status string, submittedBy string) error {
|
||||
now := time.Now()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE ai_system_registrations
|
||||
SET registration_status = $2, submitted_by = $3, updated_at = $4,
|
||||
registration_date = CASE WHEN $2 = 'submitted' THEN $4 ELSE registration_date END,
|
||||
last_update_date = $4
|
||||
WHERE id = $1`,
|
||||
id, status, submittedBy, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// BuildExportJSON creates the EU AI Database submission JSON
|
||||
func (s *RegistrationStore) BuildExportJSON(r *AIRegistration) json.RawMessage {
|
||||
export := map[string]interface{}{
|
||||
"schema_version": "1.0",
|
||||
"submission_type": "ai_system_registration",
|
||||
"regulation": "EU AI Act (EU) 2024/1689",
|
||||
"article": "Art. 49",
|
||||
"provider": map[string]interface{}{
|
||||
"name": r.ProviderName,
|
||||
"legal_form": r.ProviderLegalForm,
|
||||
"address": r.ProviderAddress,
|
||||
"country": r.ProviderCountry,
|
||||
"eu_representative": r.EURepresentativeName,
|
||||
"eu_rep_contact": r.EURepresentativeContact,
|
||||
},
|
||||
"system": map[string]interface{}{
|
||||
"name": r.SystemName,
|
||||
"version": r.SystemVersion,
|
||||
"description": r.SystemDescription,
|
||||
"purpose": r.IntendedPurpose,
|
||||
},
|
||||
"classification": map[string]interface{}{
|
||||
"risk_level": r.RiskClassification,
|
||||
"annex_iii_category": r.AnnexIIICategory,
|
||||
"gpai": r.GPAIClassification,
|
||||
},
|
||||
"conformity": map[string]interface{}{
|
||||
"assessment_type": r.ConformityAssessmentType,
|
||||
"notified_body": r.NotifiedBodyName,
|
||||
"notified_body_id": r.NotifiedBodyID,
|
||||
"ce_marking": r.CEMarking,
|
||||
},
|
||||
"training_data": map[string]interface{}{
|
||||
"categories": r.TrainingDataCategories,
|
||||
"summary": r.TrainingDataSummary,
|
||||
},
|
||||
"status": r.RegistrationStatus,
|
||||
}
|
||||
data, _ := json.Marshal(export)
|
||||
return data
|
||||
}
|
||||
@@ -358,6 +358,128 @@ type AssessmentFilters struct {
|
||||
Offset int // OFFSET for pagination
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user