feat(training): add Media Pipeline — TTS Audio, Presentation Video, Bulk Generation
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 21s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 21s
Phase A: 8 new IT-Security training modules (SEC-PWD, SEC-DESK, SEC-KIAI, SEC-BYOD, SEC-VIDEO, SEC-USB, SEC-INC, SEC-HOME) with CTM entries. Bulk content and quiz generation endpoints for all 28 modules. Phase B: Piper TTS service (Python/FastAPI) for local German speech synthesis. training_media table, TTSClient in Go backend, audio generation endpoints, AudioPlayer component in frontend. MinIO storage integration. Phase C: FFmpeg presentation video pipeline — LLM generates slide scripts, ImageMagick renders 1920x1080 slides, FFmpeg combines with audio to MP4. VideoPlayer and ScriptPreview components in frontend. New files: 15 created, 9 modified - compliance-tts-service/ (Dockerfile, main.py, tts_engine.py, storage.py, slide_renderer.py, video_generator.py) - migrations 014-016 (training engine, IT-security modules, media table) - training package (models, store, content_generator, media, handlers) - frontend (AudioPlayer, VideoPlayer, ScriptPreview, api, types, page) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/gci"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -129,6 +130,11 @@ func main() {
|
||||
gciEngine := gci.NewEngine()
|
||||
gciHandlers := handlers.NewGCIHandlers(gciEngine)
|
||||
|
||||
// Initialize Training Engine
|
||||
trainingStore := training.NewStore(pool)
|
||||
ttsClient := training.NewTTSClient(cfg.TTSServiceURL)
|
||||
contentGenerator := training.NewContentGenerator(providerRegistry, piiDetector, trainingStore, ttsClient)
|
||||
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator)
|
||||
// Initialize middleware
|
||||
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
|
||||
|
||||
@@ -680,6 +686,63 @@ func main() {
|
||||
gciRoutes.GET("/iso/mappings/:controlId", gciHandlers.GetISOMapping)
|
||||
}
|
||||
|
||||
|
||||
// Training Engine routes - Compliance Training Management
|
||||
trainingRoutes := v1.Group("/training")
|
||||
{
|
||||
// Modules
|
||||
trainingRoutes.GET("/modules", trainingHandlers.ListModules)
|
||||
trainingRoutes.GET("/modules/:id", trainingHandlers.GetModule)
|
||||
trainingRoutes.POST("/modules", trainingHandlers.CreateModule)
|
||||
trainingRoutes.PUT("/modules/:id", trainingHandlers.UpdateModule)
|
||||
|
||||
// Training Matrix (CTM)
|
||||
trainingRoutes.GET("/matrix", trainingHandlers.GetMatrix)
|
||||
trainingRoutes.GET("/matrix/:role", trainingHandlers.GetMatrixForRole)
|
||||
trainingRoutes.POST("/matrix", trainingHandlers.SetMatrixEntry)
|
||||
trainingRoutes.DELETE("/matrix/:role/:moduleId", trainingHandlers.DeleteMatrixEntry)
|
||||
|
||||
// Assignments
|
||||
trainingRoutes.POST("/assignments/compute", trainingHandlers.ComputeAssignments)
|
||||
trainingRoutes.GET("/assignments", trainingHandlers.ListAssignments)
|
||||
trainingRoutes.GET("/assignments/:id", trainingHandlers.GetAssignment)
|
||||
trainingRoutes.POST("/assignments/:id/start", trainingHandlers.StartAssignment)
|
||||
trainingRoutes.POST("/assignments/:id/progress", trainingHandlers.UpdateAssignmentProgress)
|
||||
trainingRoutes.POST("/assignments/:id/complete", trainingHandlers.CompleteAssignment)
|
||||
|
||||
// Quiz
|
||||
trainingRoutes.GET("/quiz/:moduleId", trainingHandlers.GetQuiz)
|
||||
trainingRoutes.POST("/quiz/:moduleId/submit", trainingHandlers.SubmitQuiz)
|
||||
trainingRoutes.GET("/quiz/attempts/:assignmentId", trainingHandlers.GetQuizAttempts)
|
||||
|
||||
// Content Generation
|
||||
trainingRoutes.POST("/content/generate", trainingHandlers.GenerateContent)
|
||||
trainingRoutes.POST("/content/generate-quiz", trainingHandlers.GenerateQuiz)
|
||||
trainingRoutes.POST("/content/generate-all", trainingHandlers.GenerateAllContent)
|
||||
trainingRoutes.POST("/content/generate-all-quiz", trainingHandlers.GenerateAllQuizzes)
|
||||
trainingRoutes.GET("/content/:moduleId", trainingHandlers.GetContent)
|
||||
trainingRoutes.POST("/content/:id/publish", trainingHandlers.PublishContent)
|
||||
|
||||
// Audio/Media
|
||||
trainingRoutes.POST("/content/:moduleId/generate-audio", trainingHandlers.GenerateAudio)
|
||||
trainingRoutes.GET("/media/:moduleId", trainingHandlers.GetModuleMedia)
|
||||
trainingRoutes.GET("/media/:id/url", trainingHandlers.GetMediaURL)
|
||||
trainingRoutes.POST("/media/:id/publish", trainingHandlers.PublishMedia)
|
||||
|
||||
// Video
|
||||
trainingRoutes.POST("/content/:moduleId/generate-video", trainingHandlers.GenerateVideo)
|
||||
trainingRoutes.POST("/content/:moduleId/preview-script", trainingHandlers.PreviewVideoScript)
|
||||
|
||||
// Deadlines and Escalation
|
||||
trainingRoutes.GET("/deadlines", trainingHandlers.GetDeadlines)
|
||||
trainingRoutes.GET("/deadlines/overdue", trainingHandlers.GetOverdueDeadlines)
|
||||
trainingRoutes.POST("/escalation/check", trainingHandlers.CheckEscalation)
|
||||
|
||||
// Audit and Stats
|
||||
trainingRoutes.GET("/audit-log", trainingHandlers.GetAuditLog)
|
||||
trainingRoutes.GET("/stats", trainingHandlers.GetStats)
|
||||
trainingRoutes.GET("/certificates/:id/verify", trainingHandlers.VerifyCertificate)
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
|
||||
1113
ai-compliance-sdk/internal/api/handlers/training_handlers.go
Normal file
1113
ai-compliance-sdk/internal/api/handlers/training_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,8 @@ type Config struct {
|
||||
|
||||
// Frontend URLs
|
||||
AdminFrontendURL string
|
||||
// TTS Service
|
||||
TTSServiceURL string
|
||||
}
|
||||
|
||||
// Load loads configuration from environment variables
|
||||
@@ -105,6 +107,7 @@ func Load() (*Config, error) {
|
||||
// Integration
|
||||
ConsentServiceURL: getEnv("CONSENT_SERVICE_URL", "http://localhost:8081"),
|
||||
AdminFrontendURL: getEnv("ADMIN_FRONTEND_URL", "http://localhost:3002"),
|
||||
TTSServiceURL: getEnv("TTS_SERVICE_URL", "http://compliance-tts-service:8095"),
|
||||
}
|
||||
|
||||
// Parse allowed origins
|
||||
|
||||
183
ai-compliance-sdk/internal/training/assignment.go
Normal file
183
ai-compliance-sdk/internal/training/assignment.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// timeNow is a package-level function for testing
|
||||
var timeNow = time.Now
|
||||
|
||||
// ComputeAssignments calculates all necessary assignments for a user based on
|
||||
// their roles, existing assignments, and deadlines. Returns new assignments
|
||||
// that need to be created.
|
||||
func ComputeAssignments(ctx context.Context, store *Store, tenantID uuid.UUID,
|
||||
userID uuid.UUID, userName, userEmail string, roleCodes []string, trigger string) ([]TrainingAssignment, error) {
|
||||
|
||||
if trigger == "" {
|
||||
trigger = string(TriggerManual)
|
||||
}
|
||||
|
||||
// Get all required modules for the user's roles
|
||||
requiredModules, err := ComputeRequiredModules(ctx, store, tenantID, roleCodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get existing active assignments for this user
|
||||
existingAssignments, _, err := store.ListAssignments(ctx, tenantID, &AssignmentFilters{
|
||||
UserID: &userID,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build a map of existing assignments by module_id for quick lookup
|
||||
existingByModule := make(map[uuid.UUID]*TrainingAssignment)
|
||||
for i := range existingAssignments {
|
||||
a := &existingAssignments[i]
|
||||
// Only consider non-expired, non-completed-and-expired assignments
|
||||
if a.Status != AssignmentStatusExpired {
|
||||
existingByModule[a.ModuleID] = a
|
||||
}
|
||||
}
|
||||
|
||||
var newAssignments []TrainingAssignment
|
||||
now := timeNow().UTC()
|
||||
|
||||
for _, module := range requiredModules {
|
||||
existing, hasExisting := existingByModule[module.ID]
|
||||
|
||||
// Skip if there's an active, valid assignment
|
||||
if hasExisting {
|
||||
switch existing.Status {
|
||||
case AssignmentStatusCompleted:
|
||||
// Check if the completed assignment is still valid
|
||||
if existing.CompletedAt != nil {
|
||||
validUntil := existing.CompletedAt.AddDate(0, 0, module.ValidityDays)
|
||||
if validUntil.After(now) {
|
||||
continue // Still valid, skip
|
||||
}
|
||||
}
|
||||
case AssignmentStatusPending, AssignmentStatusInProgress:
|
||||
continue // Assignment exists and is active
|
||||
case AssignmentStatusOverdue:
|
||||
continue // Already tracked as overdue
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the role code for this assignment
|
||||
roleCode := ""
|
||||
for _, role := range roleCodes {
|
||||
entries, err := store.GetMatrixForRole(ctx, tenantID, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.ModuleID == module.ID {
|
||||
roleCode = role
|
||||
break
|
||||
}
|
||||
}
|
||||
if roleCode != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate deadline based on frequency
|
||||
var deadline time.Time
|
||||
switch module.FrequencyType {
|
||||
case FrequencyOnboarding:
|
||||
deadline = now.AddDate(0, 0, 30) // 30 days for onboarding
|
||||
case FrequencyMicro:
|
||||
deadline = now.AddDate(0, 0, 14) // 14 days for micro
|
||||
default:
|
||||
deadline = now.AddDate(0, 0, 90) // 90 days default
|
||||
}
|
||||
|
||||
assignment := TrainingAssignment{
|
||||
TenantID: tenantID,
|
||||
ModuleID: module.ID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserEmail: userEmail,
|
||||
RoleCode: roleCode,
|
||||
TriggerType: TriggerType(trigger),
|
||||
Status: AssignmentStatusPending,
|
||||
Deadline: deadline,
|
||||
ModuleCode: module.ModuleCode,
|
||||
ModuleTitle: module.Title,
|
||||
}
|
||||
|
||||
// Create the assignment in the store
|
||||
if err := store.CreateAssignment(ctx, &assignment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log the assignment
|
||||
store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: tenantID,
|
||||
UserID: &userID,
|
||||
Action: AuditActionAssigned,
|
||||
EntityType: AuditEntityAssignment,
|
||||
EntityID: &assignment.ID,
|
||||
Details: map[string]interface{}{
|
||||
"module_code": module.ModuleCode,
|
||||
"trigger": trigger,
|
||||
"role_code": roleCode,
|
||||
"deadline": deadline.Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
|
||||
newAssignments = append(newAssignments, assignment)
|
||||
}
|
||||
|
||||
if newAssignments == nil {
|
||||
newAssignments = []TrainingAssignment{}
|
||||
}
|
||||
|
||||
return newAssignments, nil
|
||||
}
|
||||
|
||||
// BulkAssign assigns a module to all users with specific roles
|
||||
// Returns the number of assignments created
|
||||
func BulkAssign(ctx context.Context, store *Store, tenantID uuid.UUID,
|
||||
moduleID uuid.UUID, users []UserInfo, trigger string, deadline time.Time) (int, error) {
|
||||
|
||||
if trigger == "" {
|
||||
trigger = string(TriggerManual)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
assignment := TrainingAssignment{
|
||||
TenantID: tenantID,
|
||||
ModuleID: moduleID,
|
||||
UserID: user.UserID,
|
||||
UserName: user.UserName,
|
||||
UserEmail: user.UserEmail,
|
||||
RoleCode: user.RoleCode,
|
||||
TriggerType: TriggerType(trigger),
|
||||
Status: AssignmentStatusPending,
|
||||
Deadline: deadline,
|
||||
}
|
||||
|
||||
if err := store.CreateAssignment(ctx, &assignment); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// UserInfo contains basic user information for bulk operations
|
||||
type UserInfo struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
RoleCode string `json:"role_code"`
|
||||
}
|
||||
602
ai-compliance-sdk/internal/training/content_generator.go
Normal file
602
ai-compliance-sdk/internal/training/content_generator.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ContentGenerator generates training content and quiz questions via LLM
|
||||
type ContentGenerator struct {
|
||||
registry *llm.ProviderRegistry
|
||||
piiDetector *llm.PIIDetector
|
||||
store *Store
|
||||
ttsClient *TTSClient
|
||||
}
|
||||
|
||||
// NewContentGenerator creates a new content generator
|
||||
func NewContentGenerator(registry *llm.ProviderRegistry, piiDetector *llm.PIIDetector, store *Store, ttsClient *TTSClient) *ContentGenerator {
|
||||
return &ContentGenerator{
|
||||
registry: registry,
|
||||
piiDetector: piiDetector,
|
||||
store: store,
|
||||
ttsClient: ttsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateModuleContent generates training content for a module via LLM
|
||||
func (g *ContentGenerator) GenerateModuleContent(ctx context.Context, module TrainingModule, language string) (*ModuleContent, error) {
|
||||
if language == "" {
|
||||
language = "de"
|
||||
}
|
||||
|
||||
prompt := buildContentPrompt(module, language)
|
||||
|
||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: getContentSystemPrompt(language)},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.15,
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM content generation failed: %w", err)
|
||||
}
|
||||
|
||||
contentBody := resp.Message.Content
|
||||
|
||||
// PII check on generated content
|
||||
if g.piiDetector != nil && g.piiDetector.ContainsPII(contentBody) {
|
||||
findings := g.piiDetector.FindPII(contentBody)
|
||||
for _, f := range findings {
|
||||
contentBody = strings.ReplaceAll(contentBody, f.Match, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
// Create summary (first 200 chars)
|
||||
summary := contentBody
|
||||
if len(summary) > 200 {
|
||||
summary = summary[:200] + "..."
|
||||
}
|
||||
|
||||
content := &ModuleContent{
|
||||
ModuleID: module.ID,
|
||||
ContentFormat: ContentFormatMarkdown,
|
||||
ContentBody: contentBody,
|
||||
Summary: summary,
|
||||
GeneratedBy: "llm_" + resp.Provider,
|
||||
LLMModel: resp.Model,
|
||||
IsPublished: false,
|
||||
}
|
||||
|
||||
if err := g.store.CreateModuleContent(ctx, content); err != nil {
|
||||
return nil, fmt.Errorf("failed to save content: %w", err)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
g.store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: module.TenantID,
|
||||
Action: AuditActionContentGenerated,
|
||||
EntityType: AuditEntityModule,
|
||||
EntityID: &module.ID,
|
||||
Details: map[string]interface{}{
|
||||
"module_code": module.ModuleCode,
|
||||
"provider": resp.Provider,
|
||||
"model": resp.Model,
|
||||
"content_id": content.ID.String(),
|
||||
"version": content.Version,
|
||||
"tokens_used": resp.Usage.TotalTokens,
|
||||
},
|
||||
})
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// GenerateQuizQuestions generates quiz questions for a module based on its content
|
||||
func (g *ContentGenerator) GenerateQuizQuestions(ctx context.Context, module TrainingModule, count int) ([]QuizQuestion, error) {
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
|
||||
// Get the published content for context
|
||||
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentContext := ""
|
||||
if content != nil {
|
||||
contentContext = content.ContentBody
|
||||
}
|
||||
|
||||
prompt := buildQuizPrompt(module, contentContext, count)
|
||||
|
||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: getQuizSystemPrompt()},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.2,
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM quiz generation failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
questions, err := parseQuizResponse(resp.Message.Content, module.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse quiz response: %w", err)
|
||||
}
|
||||
|
||||
// Save questions to store
|
||||
for i := range questions {
|
||||
questions[i].SortOrder = i + 1
|
||||
if err := g.store.CreateQuizQuestion(ctx, &questions[i]); err != nil {
|
||||
return nil, fmt.Errorf("failed to save question %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Templates
|
||||
// ============================================================================
|
||||
|
||||
func getContentSystemPrompt(language string) string {
|
||||
if language == "en" {
|
||||
return "You are a compliance training content expert. Generate professional, accurate training material in Markdown format. Focus on practical relevance and legal accuracy. Do not include any personal data or fictional names."
|
||||
}
|
||||
return "Du bist ein Experte fuer Compliance-Schulungsinhalte. Erstelle professionelle, praezise Schulungsmaterialien im Markdown-Format. Fokussiere dich auf praktische Relevanz und rechtliche Genauigkeit. Verwende keine personenbezogenen Daten oder fiktiven Namen."
|
||||
}
|
||||
|
||||
func getQuizSystemPrompt() string {
|
||||
return `Du bist ein Experte fuer Compliance-Pruefungsfragen. Erstelle Multiple-Choice-Fragen als JSON-Array.
|
||||
Jede Frage hat genau 4 Antwortoptionen, davon genau eine richtige.
|
||||
Antworte NUR mit dem JSON-Array, ohne zusaetzlichen Text.
|
||||
|
||||
Format:
|
||||
[
|
||||
{
|
||||
"question": "Frage hier?",
|
||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||
"correct_index": 0,
|
||||
"explanation": "Erklaerung warum Option A richtig ist.",
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]`
|
||||
}
|
||||
|
||||
func buildContentPrompt(module TrainingModule, language string) string {
|
||||
regulationLabels := map[RegulationArea]string{
|
||||
RegulationDSGVO: "Datenschutz-Grundverordnung (DSGVO)",
|
||||
RegulationNIS2: "NIS-2-Richtlinie",
|
||||
RegulationISO27001: "ISO 27001 / ISMS",
|
||||
RegulationAIAct: "EU AI Act / KI-Verordnung",
|
||||
RegulationGeschGehG: "Geschaeftsgeheimnisgesetz (GeschGehG)",
|
||||
RegulationHinSchG: "Hinweisgeberschutzgesetz (HinSchG)",
|
||||
}
|
||||
|
||||
regulation := regulationLabels[module.RegulationArea]
|
||||
if regulation == "" {
|
||||
regulation = string(module.RegulationArea)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:
|
||||
|
||||
**Modulcode:** %s
|
||||
**Titel:** %s
|
||||
**Beschreibung:** %s
|
||||
**Regulierungsbereich:** %s
|
||||
**Dauer:** %d Minuten
|
||||
**NIS2-relevant:** %v
|
||||
|
||||
Das Material soll:
|
||||
1. Eine kurze Einfuehrung in das Thema geben
|
||||
2. Die wichtigsten rechtlichen Grundlagen erklaeren
|
||||
3. Praktische Handlungsanweisungen fuer den Arbeitsalltag enthalten
|
||||
4. Typische Fehler und Risiken aufzeigen
|
||||
5. Eine Zusammenfassung der Kernpunkte bieten
|
||||
|
||||
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
|
||||
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`,
|
||||
module.ModuleCode, module.Title, module.Description,
|
||||
regulation, module.DurationMinutes, module.NIS2Relevant)
|
||||
}
|
||||
|
||||
func buildQuizPrompt(module TrainingModule, contentContext string, count int) string {
|
||||
prompt := fmt.Sprintf(`Erstelle %d Multiple-Choice-Pruefungsfragen fuer das Compliance-Modul:
|
||||
|
||||
**Modulcode:** %s
|
||||
**Titel:** %s
|
||||
**Regulierungsbereich:** %s`, count, module.ModuleCode, module.Title, string(module.RegulationArea))
|
||||
|
||||
if contentContext != "" {
|
||||
// Truncate content to avoid token limit
|
||||
if len(contentContext) > 3000 {
|
||||
contentContext = contentContext[:3000] + "..."
|
||||
}
|
||||
prompt += fmt.Sprintf(`
|
||||
|
||||
**Schulungsinhalt als Kontext:**
|
||||
%s`, contentContext)
|
||||
}
|
||||
|
||||
prompt += fmt.Sprintf(`
|
||||
|
||||
Erstelle genau %d Fragen mit je 4 Antwortoptionen.
|
||||
Verteile die Schwierigkeitsgrade: easy, medium, hard.
|
||||
Antworte NUR mit dem JSON-Array.`, count)
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// parseQuizResponse parses LLM JSON response into QuizQuestion structs
|
||||
func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
|
||||
// Try to extract JSON from the response (LLM might add text around it)
|
||||
jsonStr := response
|
||||
start := strings.Index(response, "[")
|
||||
end := strings.LastIndex(response, "]")
|
||||
if start >= 0 && end > start {
|
||||
jsonStr = response[start : end+1]
|
||||
}
|
||||
|
||||
type rawQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
CorrectIndex int `json:"correct_index"`
|
||||
Explanation string `json:"explanation"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
}
|
||||
|
||||
var rawQuestions []rawQuestion
|
||||
if err := json.Unmarshal([]byte(jsonStr), &rawQuestions); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON from LLM: %w", err)
|
||||
}
|
||||
|
||||
var questions []QuizQuestion
|
||||
for _, rq := range rawQuestions {
|
||||
difficulty := Difficulty(rq.Difficulty)
|
||||
if difficulty != DifficultyEasy && difficulty != DifficultyMedium && difficulty != DifficultyHard {
|
||||
difficulty = DifficultyMedium
|
||||
}
|
||||
|
||||
q := QuizQuestion{
|
||||
ModuleID: moduleID,
|
||||
Question: rq.Question,
|
||||
Options: rq.Options,
|
||||
CorrectIndex: rq.CorrectIndex,
|
||||
Explanation: rq.Explanation,
|
||||
Difficulty: difficulty,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if len(q.Options) != 4 {
|
||||
continue // Skip malformed questions
|
||||
}
|
||||
if q.CorrectIndex < 0 || q.CorrectIndex >= len(q.Options) {
|
||||
continue
|
||||
}
|
||||
|
||||
questions = append(questions, q)
|
||||
}
|
||||
|
||||
if questions == nil {
|
||||
questions = []QuizQuestion{}
|
||||
}
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// GenerateAllModuleContent generates text content for all modules that don't have published content yet
|
||||
func (g *ContentGenerator) GenerateAllModuleContent(ctx context.Context, tenantID uuid.UUID, language string) (*BulkResult, error) {
|
||||
if language == "" {
|
||||
language = "de"
|
||||
}
|
||||
|
||||
modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list modules: %w", err)
|
||||
}
|
||||
|
||||
result := &BulkResult{}
|
||||
for _, module := range modules {
|
||||
// Check if module already has published content
|
||||
content, _ := g.store.GetPublishedContent(ctx, module.ID)
|
||||
if content != nil {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := g.GenerateModuleContent(ctx, module, language)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
|
||||
continue
|
||||
}
|
||||
result.Generated++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GenerateAllQuizQuestions generates quiz questions for all modules that don't have questions yet
|
||||
func (g *ContentGenerator) GenerateAllQuizQuestions(ctx context.Context, tenantID uuid.UUID, count int) (*BulkResult, error) {
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
|
||||
modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list modules: %w", err)
|
||||
}
|
||||
|
||||
result := &BulkResult{}
|
||||
for _, module := range modules {
|
||||
// Check if module already has quiz questions
|
||||
questions, _ := g.store.ListQuizQuestions(ctx, module.ID)
|
||||
if len(questions) > 0 {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := g.GenerateQuizQuestions(ctx, module, count)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
|
||||
continue
|
||||
}
|
||||
result.Generated++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GenerateAudio generates audio for a module using the TTS service
|
||||
func (g *ContentGenerator) GenerateAudio(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
||||
// Get published content
|
||||
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get content: %w", err)
|
||||
}
|
||||
if content == nil {
|
||||
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
|
||||
}
|
||||
|
||||
if g.ttsClient == nil {
|
||||
return nil, fmt.Errorf("TTS client not configured")
|
||||
}
|
||||
|
||||
// Create media record (processing)
|
||||
media := &TrainingMedia{
|
||||
ModuleID: module.ID,
|
||||
ContentID: &content.ID,
|
||||
MediaType: MediaTypeAudio,
|
||||
Status: MediaStatusProcessing,
|
||||
Bucket: "compliance-training-audio",
|
||||
ObjectKey: fmt.Sprintf("audio/%s/%s.mp3", module.ID.String(), content.ID.String()),
|
||||
MimeType: "audio/mpeg",
|
||||
VoiceModel: "de_DE-thorsten-high",
|
||||
Language: "de",
|
||||
GeneratedBy: "tts_piper",
|
||||
}
|
||||
|
||||
if err := g.store.CreateMedia(ctx, media); err != nil {
|
||||
return nil, fmt.Errorf("failed to create media record: %w", err)
|
||||
}
|
||||
|
||||
// Call TTS service
|
||||
ttsResp, err := g.ttsClient.Synthesize(ctx, &TTSSynthesizeRequest{
|
||||
Text: content.ContentBody,
|
||||
Language: "de",
|
||||
Voice: "thorsten-high",
|
||||
ModuleID: module.ID.String(),
|
||||
ContentID: content.ID.String(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
|
||||
return nil, fmt.Errorf("TTS synthesis failed: %w", err)
|
||||
}
|
||||
|
||||
// Update media record
|
||||
media.Status = MediaStatusCompleted
|
||||
media.FileSizeBytes = ttsResp.SizeBytes
|
||||
media.DurationSeconds = ttsResp.DurationSeconds
|
||||
media.ObjectKey = ttsResp.ObjectKey
|
||||
media.Bucket = ttsResp.Bucket
|
||||
|
||||
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, ttsResp.SizeBytes, ttsResp.DurationSeconds, "")
|
||||
|
||||
// Audit log
|
||||
g.store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: module.TenantID,
|
||||
Action: AuditAction("audio_generated"),
|
||||
EntityType: AuditEntityModule,
|
||||
EntityID: &module.ID,
|
||||
Details: map[string]interface{}{
|
||||
"module_code": module.ModuleCode,
|
||||
"media_id": media.ID.String(),
|
||||
"duration_seconds": ttsResp.DurationSeconds,
|
||||
"size_bytes": ttsResp.SizeBytes,
|
||||
},
|
||||
})
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// VideoScript represents a structured presentation script
|
||||
type VideoScript struct {
|
||||
Title string `json:"title"`
|
||||
Sections []VideoScriptSection `json:"sections"`
|
||||
}
|
||||
|
||||
// VideoScriptSection is one slide in the presentation
|
||||
type VideoScriptSection struct {
|
||||
Heading string `json:"heading"`
|
||||
Text string `json:"text"`
|
||||
BulletPoints []string `json:"bullet_points"`
|
||||
}
|
||||
|
||||
// GenerateVideoScript generates a structured video script from module content via LLM
|
||||
func (g *ContentGenerator) GenerateVideoScript(ctx context.Context, module TrainingModule) (*VideoScript, error) {
|
||||
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get content: %w", err)
|
||||
}
|
||||
if content == nil {
|
||||
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Erstelle ein strukturiertes Folien-Script fuer eine Praesentations-Video-Schulung.
|
||||
|
||||
**Modul:** %s — %s
|
||||
**Inhalt:**
|
||||
%s
|
||||
|
||||
Erstelle 5-8 Folien. Jede Folie hat:
|
||||
- heading: Kurze Ueberschrift (max 60 Zeichen)
|
||||
- text: Erklaerungstext (1-2 Saetze)
|
||||
- bullet_points: 2-4 Kernpunkte
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt in diesem Format:
|
||||
{
|
||||
"title": "Titel der Praesentation",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Folienueberschrift",
|
||||
"text": "Erklaerungstext fuer diese Folie.",
|
||||
"bullet_points": ["Punkt 1", "Punkt 2", "Punkt 3"]
|
||||
}
|
||||
]
|
||||
}`, module.ModuleCode, module.Title, truncateText(content.ContentBody, 3000))
|
||||
|
||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: "Du bist ein Experte fuer Compliance-Schulungspraesentationen. Erstelle strukturierte Folien-Scripts als JSON. Antworte NUR mit dem JSON-Objekt."},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.15,
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM video script generation failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var script VideoScript
|
||||
jsonStr := resp.Message.Content
|
||||
start := strings.Index(jsonStr, "{")
|
||||
end := strings.LastIndex(jsonStr, "}")
|
||||
if start >= 0 && end > start {
|
||||
jsonStr = jsonStr[start : end+1]
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse video script JSON: %w", err)
|
||||
}
|
||||
|
||||
if len(script.Sections) == 0 {
|
||||
return nil, fmt.Errorf("video script has no sections")
|
||||
}
|
||||
|
||||
return &script, nil
|
||||
}
|
||||
|
||||
// GenerateVideo generates a presentation video for a module
|
||||
func (g *ContentGenerator) GenerateVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
||||
if g.ttsClient == nil {
|
||||
return nil, fmt.Errorf("TTS client not configured")
|
||||
}
|
||||
|
||||
// Check for published audio, generate if missing
|
||||
audio, _ := g.store.GetPublishedAudio(ctx, module.ID)
|
||||
if audio == nil {
|
||||
// Try to generate audio first
|
||||
var err error
|
||||
audio, err = g.GenerateAudio(ctx, module)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audio generation required but failed: %w", err)
|
||||
}
|
||||
// Auto-publish the audio
|
||||
g.store.PublishMedia(ctx, audio.ID, true)
|
||||
}
|
||||
|
||||
// Generate video script via LLM
|
||||
script, err := g.GenerateVideoScript(ctx, module)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("video script generation failed: %w", err)
|
||||
}
|
||||
|
||||
// Create media record
|
||||
media := &TrainingMedia{
|
||||
ModuleID: module.ID,
|
||||
MediaType: MediaTypeVideo,
|
||||
Status: MediaStatusProcessing,
|
||||
Bucket: "compliance-training-video",
|
||||
ObjectKey: fmt.Sprintf("video/%s/presentation.mp4", module.ID.String()),
|
||||
MimeType: "video/mp4",
|
||||
Language: "de",
|
||||
GeneratedBy: "tts_ffmpeg",
|
||||
}
|
||||
|
||||
if err := g.store.CreateMedia(ctx, media); err != nil {
|
||||
return nil, fmt.Errorf("failed to create media record: %w", err)
|
||||
}
|
||||
|
||||
// Build script map for TTS service
|
||||
scriptMap := map[string]interface{}{
|
||||
"title": script.Title,
|
||||
"module_code": module.ModuleCode,
|
||||
"sections": script.Sections,
|
||||
}
|
||||
|
||||
// Call TTS service video generation
|
||||
videoResp, err := g.ttsClient.GenerateVideo(ctx, &TTSGenerateVideoRequest{
|
||||
Script: scriptMap,
|
||||
AudioObjectKey: audio.ObjectKey,
|
||||
ModuleID: module.ID.String(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
|
||||
return nil, fmt.Errorf("video generation failed: %w", err)
|
||||
}
|
||||
|
||||
// Update media record
|
||||
media.Status = MediaStatusCompleted
|
||||
media.FileSizeBytes = videoResp.SizeBytes
|
||||
media.DurationSeconds = videoResp.DurationSeconds
|
||||
media.ObjectKey = videoResp.ObjectKey
|
||||
media.Bucket = videoResp.Bucket
|
||||
|
||||
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
|
||||
|
||||
// Audit log
|
||||
g.store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: module.TenantID,
|
||||
Action: AuditAction("video_generated"),
|
||||
EntityType: AuditEntityModule,
|
||||
EntityID: &module.ID,
|
||||
Details: map[string]interface{}{
|
||||
"module_code": module.ModuleCode,
|
||||
"media_id": media.ID.String(),
|
||||
"duration_seconds": videoResp.DurationSeconds,
|
||||
"size_bytes": videoResp.SizeBytes,
|
||||
"slides": len(script.Sections),
|
||||
},
|
||||
})
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func truncateText(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
177
ai-compliance-sdk/internal/training/escalation.go
Normal file
177
ai-compliance-sdk/internal/training/escalation.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Escalation level thresholds (days overdue)
|
||||
const (
|
||||
EscalationThresholdL1 = 7 // Reminder to user
|
||||
EscalationThresholdL2 = 14 // Notify team lead
|
||||
EscalationThresholdL3 = 30 // Notify management
|
||||
EscalationThresholdL4 = 45 // Notify compliance officer
|
||||
)
|
||||
|
||||
// EscalationLabels maps levels to human-readable labels
|
||||
var EscalationLabels = map[int]string{
|
||||
0: "Keine Eskalation",
|
||||
1: "Erinnerung an Mitarbeiter",
|
||||
2: "Benachrichtigung Teamleitung",
|
||||
3: "Benachrichtigung Management",
|
||||
4: "Benachrichtigung Compliance Officer",
|
||||
}
|
||||
|
||||
// CheckEscalations checks all overdue assignments and escalates as needed
|
||||
func CheckEscalations(ctx context.Context, store *Store, tenantID uuid.UUID) ([]EscalationResult, error) {
|
||||
overdueAssignments, err := store.ListOverdueAssignments(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []EscalationResult
|
||||
now := timeNow().UTC()
|
||||
|
||||
for _, assignment := range overdueAssignments {
|
||||
daysOverdue := int(math.Floor(now.Sub(assignment.Deadline).Hours() / 24))
|
||||
if daysOverdue < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine new escalation level
|
||||
newLevel := 0
|
||||
if daysOverdue >= EscalationThresholdL4 {
|
||||
newLevel = 4
|
||||
} else if daysOverdue >= EscalationThresholdL3 {
|
||||
newLevel = 3
|
||||
} else if daysOverdue >= EscalationThresholdL2 {
|
||||
newLevel = 2
|
||||
} else if daysOverdue >= EscalationThresholdL1 {
|
||||
newLevel = 1
|
||||
}
|
||||
|
||||
// Only escalate if the level has increased
|
||||
if newLevel <= assignment.EscalationLevel {
|
||||
continue
|
||||
}
|
||||
|
||||
previousLevel := assignment.EscalationLevel
|
||||
|
||||
// Update the assignment
|
||||
nowTime := now
|
||||
_, err := store.pool.Exec(ctx, `
|
||||
UPDATE training_assignments SET
|
||||
escalation_level = $2,
|
||||
last_escalation_at = $3,
|
||||
status = 'overdue',
|
||||
updated_at = $3
|
||||
WHERE id = $1
|
||||
`, assignment.ID, newLevel, nowTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log the escalation
|
||||
assignmentID := assignment.ID
|
||||
store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: tenantID,
|
||||
UserID: &assignment.UserID,
|
||||
Action: AuditActionEscalated,
|
||||
EntityType: AuditEntityAssignment,
|
||||
EntityID: &assignmentID,
|
||||
Details: map[string]interface{}{
|
||||
"previous_level": previousLevel,
|
||||
"new_level": newLevel,
|
||||
"days_overdue": daysOverdue,
|
||||
"label": EscalationLabels[newLevel],
|
||||
},
|
||||
})
|
||||
|
||||
results = append(results, EscalationResult{
|
||||
AssignmentID: assignment.ID,
|
||||
UserID: assignment.UserID,
|
||||
UserName: assignment.UserName,
|
||||
UserEmail: assignment.UserEmail,
|
||||
ModuleTitle: assignment.ModuleTitle,
|
||||
PreviousLevel: previousLevel,
|
||||
NewLevel: newLevel,
|
||||
DaysOverdue: daysOverdue,
|
||||
EscalationLabel: EscalationLabels[newLevel],
|
||||
})
|
||||
}
|
||||
|
||||
if results == nil {
|
||||
results = []EscalationResult{}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetOverdueDeadlines returns all overdue assignments with deadline info
|
||||
func GetOverdueDeadlines(ctx context.Context, store *Store, tenantID uuid.UUID) ([]DeadlineInfo, error) {
|
||||
rows, err := store.pool.Query(ctx, `
|
||||
SELECT
|
||||
ta.id, m.module_code, m.title,
|
||||
ta.user_id, ta.user_name, ta.deadline, ta.status,
|
||||
EXTRACT(DAY FROM (NOW() - ta.deadline))::INT AS days_overdue
|
||||
FROM training_assignments ta
|
||||
JOIN training_modules m ON m.id = ta.module_id
|
||||
WHERE ta.tenant_id = $1
|
||||
AND ta.status IN ('pending', 'in_progress', 'overdue')
|
||||
AND ta.deadline < NOW()
|
||||
ORDER BY ta.deadline ASC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deadlines []DeadlineInfo
|
||||
for rows.Next() {
|
||||
var d DeadlineInfo
|
||||
var status string
|
||||
|
||||
err := rows.Scan(
|
||||
&d.AssignmentID, &d.ModuleCode, &d.ModuleTitle,
|
||||
&d.UserID, &d.UserName, &d.Deadline, &status,
|
||||
&d.DaysLeft,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.Status = AssignmentStatus(status)
|
||||
d.DaysLeft = -d.DaysLeft // Negative means overdue
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
if deadlines == nil {
|
||||
deadlines = []DeadlineInfo{}
|
||||
}
|
||||
|
||||
return deadlines, nil
|
||||
}
|
||||
|
||||
// VerifyCertificate verifies a certificate by checking the assignment status
|
||||
func VerifyCertificate(ctx context.Context, store *Store, certificateID uuid.UUID) (bool, *TrainingAssignment, error) {
|
||||
// Find assignment with this certificate
|
||||
var assignmentID uuid.UUID
|
||||
err := store.pool.QueryRow(ctx,
|
||||
"SELECT id FROM training_assignments WHERE certificate_id = $1",
|
||||
certificateID).Scan(&assignmentID)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
assignment, err := store.GetAssignment(ctx, assignmentID)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if assignment == nil {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
return assignment.Status == AssignmentStatusCompleted, assignment, nil
|
||||
}
|
||||
127
ai-compliance-sdk/internal/training/matrix.go
Normal file
127
ai-compliance-sdk/internal/training/matrix.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ComputeRequiredModules returns all required training modules for a user
|
||||
// based on their assigned roles. Deduplicates modules across roles.
|
||||
func ComputeRequiredModules(ctx context.Context, store *Store, tenantID uuid.UUID, roleCodes []string) ([]TrainingModule, error) {
|
||||
seen := make(map[uuid.UUID]bool)
|
||||
var modules []TrainingModule
|
||||
|
||||
for _, role := range roleCodes {
|
||||
entries, err := store.GetMatrixForRole(ctx, tenantID, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if seen[entry.ModuleID] {
|
||||
continue
|
||||
}
|
||||
seen[entry.ModuleID] = true
|
||||
|
||||
module, err := store.GetModule(ctx, entry.ModuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if module != nil && module.IsActive {
|
||||
modules = append(modules, *module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if modules == nil {
|
||||
modules = []TrainingModule{}
|
||||
}
|
||||
|
||||
return modules, nil
|
||||
}
|
||||
|
||||
// GetComplianceGaps finds modules that are required but not completed for a user
|
||||
func GetComplianceGaps(ctx context.Context, store *Store, tenantID uuid.UUID, userID uuid.UUID, roleCodes []string) ([]ComplianceGap, error) {
|
||||
var gaps []ComplianceGap
|
||||
|
||||
for _, role := range roleCodes {
|
||||
entries, err := store.GetMatrixForRole(ctx, tenantID, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
// Check if there's an active, completed assignment for this module
|
||||
assignments, _, err := store.ListAssignments(ctx, tenantID, &AssignmentFilters{
|
||||
ModuleID: &entry.ModuleID,
|
||||
UserID: &userID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gap := ComplianceGap{
|
||||
ModuleID: entry.ModuleID,
|
||||
ModuleCode: entry.ModuleCode,
|
||||
ModuleTitle: entry.ModuleTitle,
|
||||
RoleCode: role,
|
||||
IsMandatory: entry.IsMandatory,
|
||||
}
|
||||
|
||||
// Determine regulation area from module
|
||||
module, err := store.GetModule(ctx, entry.ModuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if module != nil {
|
||||
gap.RegulationArea = module.RegulationArea
|
||||
}
|
||||
|
||||
if len(assignments) == 0 {
|
||||
gap.Status = "missing"
|
||||
gaps = append(gaps, gap)
|
||||
} else {
|
||||
a := assignments[0]
|
||||
gap.AssignmentID = &a.ID
|
||||
gap.Deadline = &a.Deadline
|
||||
|
||||
switch a.Status {
|
||||
case AssignmentStatusCompleted:
|
||||
// No gap
|
||||
continue
|
||||
case AssignmentStatusOverdue, AssignmentStatusExpired:
|
||||
gap.Status = string(a.Status)
|
||||
gaps = append(gaps, gap)
|
||||
default:
|
||||
// Check if overdue
|
||||
if a.Deadline.Before(timeNow()) {
|
||||
gap.Status = "overdue"
|
||||
gaps = append(gaps, gap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gaps == nil {
|
||||
gaps = []ComplianceGap{}
|
||||
}
|
||||
|
||||
return gaps, nil
|
||||
}
|
||||
|
||||
// BuildMatrixResponse builds the full CTM response grouped by role
|
||||
func BuildMatrixResponse(entries []TrainingMatrixEntry) *MatrixResponse {
|
||||
resp := &MatrixResponse{
|
||||
Entries: make(map[string][]TrainingMatrixEntry),
|
||||
Roles: RoleLabels,
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
resp.Entries[entry.RoleCode] = append(resp.Entries[entry.RoleCode], entry)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
186
ai-compliance-sdk/internal/training/media.go
Normal file
186
ai-compliance-sdk/internal/training/media.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// MediaType represents audio or video
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
MediaTypeAudio MediaType = "audio"
|
||||
MediaTypeVideo MediaType = "video"
|
||||
)
|
||||
|
||||
// MediaStatus represents the processing status
|
||||
type MediaStatus string
|
||||
|
||||
const (
|
||||
MediaStatusProcessing MediaStatus = "processing"
|
||||
MediaStatusCompleted MediaStatus = "completed"
|
||||
MediaStatusFailed MediaStatus = "failed"
|
||||
)
|
||||
|
||||
// TrainingMedia represents a generated media file
|
||||
type TrainingMedia struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
ContentID *uuid.UUID `json:"content_id,omitempty"`
|
||||
MediaType MediaType `json:"media_type"`
|
||||
Status MediaStatus `json:"status"`
|
||||
Bucket string `json:"bucket"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
FileSizeBytes int64 `json:"file_size_bytes"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
MimeType string `json:"mime_type"`
|
||||
VoiceModel string `json:"voice_model"`
|
||||
Language string `json:"language"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
GeneratedBy string `json:"generated_by"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TTS Client
|
||||
// ============================================================================
|
||||
|
||||
// TTSClient communicates with the compliance-tts-service
|
||||
type TTSClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewTTSClient creates a new TTS service client
|
||||
func NewTTSClient(baseURL string) *TTSClient {
|
||||
return &TTSClient{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TTSSynthesizeRequest is the request to synthesize audio
|
||||
type TTSSynthesizeRequest struct {
|
||||
Text string `json:"text"`
|
||||
Language string `json:"language"`
|
||||
Voice string `json:"voice"`
|
||||
ModuleID string `json:"module_id"`
|
||||
ContentID string `json:"content_id,omitempty"`
|
||||
}
|
||||
|
||||
// TTSSynthesizeResponse is the response from audio synthesis
|
||||
type TTSSynthesizeResponse struct {
|
||||
AudioID string `json:"audio_id"`
|
||||
Bucket string `json:"bucket"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// TTSGenerateVideoRequest is the request to generate a video
|
||||
type TTSGenerateVideoRequest struct {
|
||||
Script map[string]interface{} `json:"script"`
|
||||
AudioObjectKey string `json:"audio_object_key"`
|
||||
ModuleID string `json:"module_id"`
|
||||
}
|
||||
|
||||
// TTSGenerateVideoResponse is the response from video generation
|
||||
type TTSGenerateVideoResponse struct {
|
||||
VideoID string `json:"video_id"`
|
||||
Bucket string `json:"bucket"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// Synthesize calls the TTS service to create audio
|
||||
func (c *TTSClient) Synthesize(ctx context.Context, req *TTSSynthesizeRequest) (*TTSSynthesizeResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/synthesize", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TTS service request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result TTSSynthesizeResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse TTS response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GenerateVideo calls the TTS service to create a presentation video
|
||||
func (c *TTSClient) GenerateVideo(ctx context.Context, req *TTSGenerateVideoRequest) (*TTSGenerateVideoResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/generate-video", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TTS service request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result TTSGenerateVideoResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse TTS response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// IsHealthy checks if the TTS service is responsive
|
||||
func (c *TTSClient) IsHealthy(ctx context.Context) bool {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
500
ai-compliance-sdk/internal/training/models.go
Normal file
500
ai-compliance-sdk/internal/training/models.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Constants / Enums
|
||||
// ============================================================================
|
||||
|
||||
// RegulationArea represents a compliance regulation area
|
||||
type RegulationArea string
|
||||
|
||||
const (
|
||||
RegulationDSGVO RegulationArea = "dsgvo"
|
||||
RegulationNIS2 RegulationArea = "nis2"
|
||||
RegulationISO27001 RegulationArea = "iso27001"
|
||||
RegulationAIAct RegulationArea = "ai_act"
|
||||
RegulationGeschGehG RegulationArea = "geschgehg"
|
||||
RegulationHinSchG RegulationArea = "hinschg"
|
||||
)
|
||||
|
||||
// FrequencyType represents the training frequency
|
||||
type FrequencyType string
|
||||
|
||||
const (
|
||||
FrequencyOnboarding FrequencyType = "onboarding"
|
||||
FrequencyAnnual FrequencyType = "annual"
|
||||
FrequencyEventTrigger FrequencyType = "event_trigger"
|
||||
FrequencyMicro FrequencyType = "micro"
|
||||
)
|
||||
|
||||
// AssignmentStatus represents the status of a training assignment
|
||||
type AssignmentStatus string
|
||||
|
||||
const (
|
||||
AssignmentStatusPending AssignmentStatus = "pending"
|
||||
AssignmentStatusInProgress AssignmentStatus = "in_progress"
|
||||
AssignmentStatusCompleted AssignmentStatus = "completed"
|
||||
AssignmentStatusOverdue AssignmentStatus = "overdue"
|
||||
AssignmentStatusExpired AssignmentStatus = "expired"
|
||||
)
|
||||
|
||||
// TriggerType represents how a training was assigned
|
||||
type TriggerType string
|
||||
|
||||
const (
|
||||
TriggerOnboarding TriggerType = "onboarding"
|
||||
TriggerAnnual TriggerType = "annual"
|
||||
TriggerEvent TriggerType = "event"
|
||||
TriggerManual TriggerType = "manual"
|
||||
)
|
||||
|
||||
// ContentFormat represents the format of module content
|
||||
type ContentFormat string
|
||||
|
||||
const (
|
||||
ContentFormatMarkdown ContentFormat = "markdown"
|
||||
ContentFormatHTML ContentFormat = "html"
|
||||
)
|
||||
|
||||
// Difficulty represents the difficulty level of a quiz question
|
||||
type Difficulty string
|
||||
|
||||
const (
|
||||
DifficultyEasy Difficulty = "easy"
|
||||
DifficultyMedium Difficulty = "medium"
|
||||
DifficultyHard Difficulty = "hard"
|
||||
)
|
||||
|
||||
// AuditAction represents an action in the audit trail
|
||||
type AuditAction string
|
||||
|
||||
const (
|
||||
AuditActionAssigned AuditAction = "assigned"
|
||||
AuditActionStarted AuditAction = "started"
|
||||
AuditActionCompleted AuditAction = "completed"
|
||||
AuditActionQuizSubmitted AuditAction = "quiz_submitted"
|
||||
AuditActionEscalated AuditAction = "escalated"
|
||||
AuditActionCertificateIssued AuditAction = "certificate_issued"
|
||||
AuditActionContentGenerated AuditAction = "content_generated"
|
||||
)
|
||||
|
||||
// AuditEntityType represents the type of entity in audit log
|
||||
type AuditEntityType string
|
||||
|
||||
const (
|
||||
AuditEntityAssignment AuditEntityType = "assignment"
|
||||
AuditEntityModule AuditEntityType = "module"
|
||||
AuditEntityQuiz AuditEntityType = "quiz"
|
||||
AuditEntityCertificate AuditEntityType = "certificate"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Role Constants
|
||||
// ============================================================================
|
||||
|
||||
const (
|
||||
RoleR1 = "R1" // Geschaeftsfuehrung
|
||||
RoleR2 = "R2" // IT-Leitung
|
||||
RoleR3 = "R3" // DSB
|
||||
RoleR4 = "R4" // ISB
|
||||
RoleR5 = "R5" // HR
|
||||
RoleR6 = "R6" // Einkauf
|
||||
RoleR7 = "R7" // Fachabteilung
|
||||
RoleR8 = "R8" // IT-Admin
|
||||
RoleR9 = "R9" // Alle Mitarbeiter
|
||||
)
|
||||
|
||||
// RoleLabels maps role codes to human-readable labels
|
||||
var RoleLabels = map[string]string{
|
||||
RoleR1: "Geschaeftsfuehrung",
|
||||
RoleR2: "IT-Leitung",
|
||||
RoleR3: "Datenschutzbeauftragter",
|
||||
RoleR4: "Informationssicherheitsbeauftragter",
|
||||
RoleR5: "HR / Personal",
|
||||
RoleR6: "Einkauf / Beschaffung",
|
||||
RoleR7: "Fachabteilung",
|
||||
RoleR8: "IT-Administration",
|
||||
RoleR9: "Alle Mitarbeiter",
|
||||
}
|
||||
|
||||
// NIS2RoleMapping maps internal roles to NIS2 levels
|
||||
var NIS2RoleMapping = map[string]string{
|
||||
RoleR1: "N1", // Geschaeftsfuehrung
|
||||
RoleR2: "N2", // IT-Leitung
|
||||
RoleR3: "N3", // DSB
|
||||
RoleR4: "N3", // ISB
|
||||
RoleR5: "N4", // HR
|
||||
RoleR6: "N4", // Einkauf
|
||||
RoleR7: "N5", // Fachabteilung
|
||||
RoleR8: "N2", // IT-Admin
|
||||
RoleR9: "N5", // Alle Mitarbeiter
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entities
|
||||
// ============================================================================
|
||||
|
||||
// TrainingModule represents a compliance training module
|
||||
type TrainingModule struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"`
|
||||
ModuleCode string `json:"module_code"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RegulationArea RegulationArea `json:"regulation_area"`
|
||||
NIS2Relevant bool `json:"nis2_relevant"`
|
||||
ISOControls []string `json:"iso_controls"` // JSONB
|
||||
FrequencyType FrequencyType `json:"frequency_type"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
RiskWeight float64 `json:"risk_weight"`
|
||||
ContentType string `json:"content_type"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
PassThreshold int `json:"pass_threshold"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TrainingMatrixEntry represents a role-to-module mapping in the CTM
|
||||
type TrainingMatrixEntry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
RoleCode string `json:"role_code"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
IsMandatory bool `json:"is_mandatory"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Joined fields (optional, populated in queries)
|
||||
ModuleCode string `json:"module_code,omitempty"`
|
||||
ModuleTitle string `json:"module_title,omitempty"`
|
||||
}
|
||||
|
||||
// TrainingAssignment represents a user's training assignment
|
||||
type TrainingAssignment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
RoleCode string `json:"role_code,omitempty"`
|
||||
TriggerType TriggerType `json:"trigger_type"`
|
||||
TriggerEvent string `json:"trigger_event,omitempty"`
|
||||
Status AssignmentStatus `json:"status"`
|
||||
ProgressPercent int `json:"progress_percent"`
|
||||
QuizScore *float64 `json:"quiz_score,omitempty"`
|
||||
QuizPassed *bool `json:"quiz_passed,omitempty"`
|
||||
QuizAttempts int `json:"quiz_attempts"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
CertificateID *uuid.UUID `json:"certificate_id,omitempty"`
|
||||
EscalationLevel int `json:"escalation_level"`
|
||||
LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"`
|
||||
EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Joined fields
|
||||
ModuleCode string `json:"module_code,omitempty"`
|
||||
ModuleTitle string `json:"module_title,omitempty"`
|
||||
}
|
||||
|
||||
// QuizQuestion represents a persistent quiz question for a module
|
||||
type QuizQuestion struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"` // JSONB
|
||||
CorrectIndex int `json:"correct_index"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Difficulty Difficulty `json:"difficulty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// QuizAttempt represents a single quiz attempt by a user
|
||||
type QuizAttempt struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Answers []QuizAnswer `json:"answers"` // JSONB
|
||||
Score float64 `json:"score"`
|
||||
Passed bool `json:"passed"`
|
||||
CorrectCount int `json:"correct_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
||||
AttemptedAt time.Time `json:"attempted_at"`
|
||||
}
|
||||
|
||||
// QuizAnswer represents a single answer within a quiz attempt
|
||||
type QuizAnswer struct {
|
||||
QuestionID uuid.UUID `json:"question_id"`
|
||||
SelectedIndex int `json:"selected_index"`
|
||||
Correct bool `json:"correct"`
|
||||
}
|
||||
|
||||
// AuditLogEntry represents an entry in the training audit trail
|
||||
type AuditLogEntry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
UserID *uuid.UUID `json:"user_id,omitempty"`
|
||||
Action AuditAction `json:"action"`
|
||||
EntityType AuditEntityType `json:"entity_type"`
|
||||
EntityID *uuid.UUID `json:"entity_id,omitempty"`
|
||||
Details map[string]interface{} `json:"details"` // JSONB
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ModuleContent represents LLM-generated or manual content for a module
|
||||
type ModuleContent struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
Version int `json:"version"`
|
||||
ContentFormat ContentFormat `json:"content_format"`
|
||||
ContentBody string `json:"content_body"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
GeneratedBy string `json:"generated_by,omitempty"`
|
||||
LLMModel string `json:"llm_model,omitempty"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TrainingStats contains aggregated training metrics
|
||||
type TrainingStats struct {
|
||||
TotalModules int `json:"total_modules"`
|
||||
TotalAssignments int `json:"total_assignments"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
OverdueCount int `json:"overdue_count"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
InProgressCount int `json:"in_progress_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
AvgQuizScore float64 `json:"avg_quiz_score"`
|
||||
AvgCompletionDays float64 `json:"avg_completion_days"`
|
||||
UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days
|
||||
}
|
||||
|
||||
// ComplianceGap represents a missing or overdue training requirement
|
||||
type ComplianceGap struct {
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
ModuleCode string `json:"module_code"`
|
||||
ModuleTitle string `json:"module_title"`
|
||||
RegulationArea RegulationArea `json:"regulation_area"`
|
||||
RoleCode string `json:"role_code"`
|
||||
IsMandatory bool `json:"is_mandatory"`
|
||||
AssignmentID *uuid.UUID `json:"assignment_id,omitempty"`
|
||||
Status string `json:"status"` // "missing", "overdue", "expired"
|
||||
Deadline *time.Time `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// EscalationResult represents the result of an escalation check
|
||||
type EscalationResult struct {
|
||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserEmail string `json:"user_email"`
|
||||
ModuleTitle string `json:"module_title"`
|
||||
PreviousLevel int `json:"previous_level"`
|
||||
NewLevel int `json:"new_level"`
|
||||
DaysOverdue int `json:"days_overdue"`
|
||||
EscalationLabel string `json:"escalation_label"`
|
||||
}
|
||||
|
||||
// DeadlineInfo represents upcoming deadline information
|
||||
type DeadlineInfo struct {
|
||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||
ModuleCode string `json:"module_code"`
|
||||
ModuleTitle string `json:"module_title"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
Status AssignmentStatus `json:"status"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter Types
|
||||
// ============================================================================
|
||||
|
||||
// ModuleFilters defines filters for listing modules
|
||||
type ModuleFilters struct {
|
||||
RegulationArea RegulationArea
|
||||
FrequencyType FrequencyType
|
||||
IsActive *bool
|
||||
NIS2Relevant *bool
|
||||
Search string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// AssignmentFilters defines filters for listing assignments
|
||||
type AssignmentFilters struct {
|
||||
ModuleID *uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
RoleCode string
|
||||
Status AssignmentStatus
|
||||
Overdue *bool
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// AuditLogFilters defines filters for listing audit log entries
|
||||
type AuditLogFilters struct {
|
||||
UserID *uuid.UUID
|
||||
Action AuditAction
|
||||
EntityType AuditEntityType
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
// CreateModuleRequest is the API request for creating a training module
|
||||
type CreateModuleRequest struct {
|
||||
ModuleCode string `json:"module_code" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
|
||||
NIS2Relevant bool `json:"nis2_relevant"`
|
||||
ISOControls []string `json:"iso_controls,omitempty"`
|
||||
FrequencyType FrequencyType `json:"frequency_type" binding:"required"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
RiskWeight float64 `json:"risk_weight"`
|
||||
ContentType string `json:"content_type"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
PassThreshold int `json:"pass_threshold"`
|
||||
}
|
||||
|
||||
// UpdateModuleRequest is the API request for updating a training module
|
||||
type UpdateModuleRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
NIS2Relevant *bool `json:"nis2_relevant,omitempty"`
|
||||
ISOControls []string `json:"iso_controls,omitempty"`
|
||||
ValidityDays *int `json:"validity_days,omitempty"`
|
||||
RiskWeight *float64 `json:"risk_weight,omitempty"`
|
||||
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
||||
PassThreshold *int `json:"pass_threshold,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
// SetMatrixEntryRequest is the API request for setting a CTM entry
|
||||
type SetMatrixEntryRequest struct {
|
||||
RoleCode string `json:"role_code" binding:"required"`
|
||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||
IsMandatory bool `json:"is_mandatory"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// ComputeAssignmentsRequest is the API request for computing assignments
|
||||
type ComputeAssignmentsRequest struct {
|
||||
UserID uuid.UUID `json:"user_id" binding:"required"`
|
||||
UserName string `json:"user_name" binding:"required"`
|
||||
UserEmail string `json:"user_email" binding:"required"`
|
||||
Roles []string `json:"roles" binding:"required"`
|
||||
Trigger string `json:"trigger"`
|
||||
}
|
||||
|
||||
// UpdateAssignmentProgressRequest updates progress on an assignment
|
||||
type UpdateAssignmentProgressRequest struct {
|
||||
Progress int `json:"progress" binding:"required"`
|
||||
}
|
||||
|
||||
// SubmitTrainingQuizRequest is the API request for submitting a quiz
|
||||
type SubmitTrainingQuizRequest struct {
|
||||
AssignmentID uuid.UUID `json:"assignment_id" binding:"required"`
|
||||
Answers []QuizAnswer `json:"answers" binding:"required"`
|
||||
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitTrainingQuizResponse is the API response for quiz submission
|
||||
type SubmitTrainingQuizResponse struct {
|
||||
AttemptID uuid.UUID `json:"attempt_id"`
|
||||
Score float64 `json:"score"`
|
||||
Passed bool `json:"passed"`
|
||||
CorrectCount int `json:"correct_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Threshold int `json:"threshold"`
|
||||
}
|
||||
|
||||
// GenerateContentRequest is the API request for LLM content generation
|
||||
type GenerateContentRequest struct {
|
||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// GenerateQuizRequest is the API request for LLM quiz generation
|
||||
type GenerateQuizRequest struct {
|
||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// PublishContentRequest is the API request for publishing content
|
||||
type PublishContentRequest struct {
|
||||
ReviewedBy uuid.UUID `json:"reviewed_by"`
|
||||
}
|
||||
|
||||
// BulkAssignRequest is the API request for bulk assigning a module
|
||||
type BulkAssignRequest struct {
|
||||
ModuleID uuid.UUID `json:"module_id" binding:"required"`
|
||||
RoleCodes []string `json:"role_codes" binding:"required"`
|
||||
Trigger string `json:"trigger"`
|
||||
Deadline time.Time `json:"deadline" binding:"required"`
|
||||
}
|
||||
|
||||
// ModuleListResponse is the API response for listing modules
|
||||
type ModuleListResponse struct {
|
||||
Modules []TrainingModule `json:"modules"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AssignmentListResponse is the API response for listing assignments
|
||||
type AssignmentListResponse struct {
|
||||
Assignments []TrainingAssignment `json:"assignments"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// MatrixResponse is the API response for the full training matrix
|
||||
type MatrixResponse struct {
|
||||
Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries
|
||||
Roles map[string]string `json:"roles"` // role_code -> label
|
||||
}
|
||||
|
||||
// AuditLogResponse is the API response for listing audit log entries
|
||||
type AuditLogResponse struct {
|
||||
Entries []AuditLogEntry `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// EscalationResponse is the API response for escalation check
|
||||
type EscalationResponse struct {
|
||||
Results []EscalationResult `json:"results"`
|
||||
TotalChecked int `json:"total_checked"`
|
||||
Escalated int `json:"escalated"`
|
||||
}
|
||||
|
||||
// DeadlineListResponse is the API response for listing deadlines
|
||||
type DeadlineListResponse struct {
|
||||
Deadlines []DeadlineInfo `json:"deadlines"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// BulkResult holds the result of a bulk generation operation
|
||||
type BulkResult struct {
|
||||
Generated int `json:"generated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
1277
ai-compliance-sdk/internal/training/store.go
Normal file
1277
ai-compliance-sdk/internal/training/store.go
Normal file
File diff suppressed because it is too large
Load Diff
268
ai-compliance-sdk/migrations/014_training_engine.sql
Normal file
268
ai-compliance-sdk/migrations/014_training_engine.sql
Normal file
@@ -0,0 +1,268 @@
|
||||
-- =========================================================
|
||||
-- Migration 014: Compliance Training Engine
|
||||
-- =========================================================
|
||||
-- Training Module Catalog, Compliance Training Matrix (CTM),
|
||||
-- Assignments, Quiz Engine, Content Pipeline, Audit Trail
|
||||
-- =========================================================
|
||||
|
||||
-- Training-Module-Katalog (erweiterte Kurs-Metadaten)
|
||||
CREATE TABLE IF NOT EXISTS training_modules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
|
||||
academy_course_id UUID REFERENCES academy_courses(id),
|
||||
module_code VARCHAR(20) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
regulation_area VARCHAR(20) NOT NULL,
|
||||
nis2_relevant BOOLEAN DEFAULT FALSE,
|
||||
iso_controls JSONB DEFAULT '[]',
|
||||
frequency_type VARCHAR(20) NOT NULL DEFAULT 'annual',
|
||||
validity_days INT DEFAULT 365,
|
||||
risk_weight FLOAT DEFAULT 2.0,
|
||||
content_type VARCHAR(20) DEFAULT 'text',
|
||||
duration_minutes INT DEFAULT 30,
|
||||
pass_threshold INT DEFAULT 70,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, module_code)
|
||||
);
|
||||
|
||||
-- Compliance Training Matrix: welche Rollen brauchen welche Module
|
||||
CREATE TABLE IF NOT EXISTS training_matrix (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
|
||||
role_code VARCHAR(10) NOT NULL,
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
is_mandatory BOOLEAN DEFAULT TRUE,
|
||||
priority INT DEFAULT 5,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, role_code, module_id)
|
||||
);
|
||||
|
||||
-- Training-Zuweisungen (automatisch oder manuell)
|
||||
CREATE TABLE IF NOT EXISTS training_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
user_name VARCHAR(255) NOT NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
role_code VARCHAR(10),
|
||||
trigger_type VARCHAR(20) NOT NULL,
|
||||
trigger_event VARCHAR(100),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
progress_percent INT DEFAULT 0,
|
||||
quiz_score FLOAT,
|
||||
quiz_passed BOOLEAN,
|
||||
quiz_attempts INT DEFAULT 0,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
deadline TIMESTAMPTZ NOT NULL,
|
||||
certificate_id UUID,
|
||||
escalation_level INT DEFAULT 0,
|
||||
last_escalation_at TIMESTAMPTZ,
|
||||
enrollment_id UUID REFERENCES academy_enrollments(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Quiz-Fragenbank (persistent, nicht nur JSONB)
|
||||
CREATE TABLE IF NOT EXISTS training_quiz_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
question TEXT NOT NULL,
|
||||
options JSONB NOT NULL,
|
||||
correct_index INT NOT NULL,
|
||||
explanation TEXT,
|
||||
difficulty VARCHAR(10) DEFAULT 'medium',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Quiz-Versuche (jeder Versuch einzeln getracked)
|
||||
CREATE TABLE IF NOT EXISTS training_quiz_attempts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assignment_id UUID NOT NULL REFERENCES training_assignments(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
answers JSONB NOT NULL,
|
||||
score FLOAT NOT NULL,
|
||||
passed BOOLEAN NOT NULL,
|
||||
correct_count INT NOT NULL,
|
||||
total_count INT NOT NULL,
|
||||
duration_seconds INT,
|
||||
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Audit Trail fuer Training-Aktionen
|
||||
CREATE TABLE IF NOT EXISTS training_audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(30) NOT NULL,
|
||||
entity_id UUID,
|
||||
details JSONB DEFAULT '{}',
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Module-Inhalte (LLM-generiert oder manuell)
|
||||
CREATE TABLE IF NOT EXISTS training_module_content (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
version INT DEFAULT 1,
|
||||
content_format VARCHAR(20) DEFAULT 'markdown',
|
||||
content_body TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
generated_by VARCHAR(50),
|
||||
llm_model VARCHAR(100),
|
||||
is_published BOOLEAN DEFAULT FALSE,
|
||||
reviewed_by UUID,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- =========================================================
|
||||
-- INDEXES
|
||||
-- =========================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_modules_tenant ON training_modules(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_modules_regulation ON training_modules(tenant_id, regulation_area);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_matrix_tenant_role ON training_matrix(tenant_id, role_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_matrix_module ON training_matrix(module_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_assignments_tenant ON training_assignments(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_assignments_user ON training_assignments(tenant_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_assignments_status ON training_assignments(tenant_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_assignments_deadline ON training_assignments(deadline)
|
||||
WHERE status NOT IN ('completed', 'expired');
|
||||
CREATE INDEX IF NOT EXISTS idx_training_assignments_overdue ON training_assignments(tenant_id, deadline, status)
|
||||
WHERE status IN ('pending', 'in_progress') AND deadline < NOW();
|
||||
CREATE INDEX IF NOT EXISTS idx_training_quiz_questions_module ON training_quiz_questions(module_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_quiz_attempts_assignment ON training_quiz_attempts(assignment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_audit_log_tenant ON training_audit_log(tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_module_content_module ON training_module_content(module_id, is_published);
|
||||
|
||||
-- =========================================================
|
||||
-- SEED DATA: Template Modules (tenant_id = 00000000-...)
|
||||
-- =========================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t_id UUID := '00000000-0000-0000-0000-000000000000';
|
||||
m_gdpr_bas UUID; m_gdpr_adv UUID; m_gdpr_art UUID; m_gdpr_dpia UUID;
|
||||
m_nis2_m1 UUID; m_nis2_m2 UUID; m_nis2_m3 UUID; m_nis2_m4 UUID; m_nis2_m5 UUID;
|
||||
m_isms_bas UUID; m_isms_int UUID; m_isms_aud UUID;
|
||||
m_ai_bas UUID; m_ai_adv UUID; m_ai_risk UUID;
|
||||
m_gesch_bas UUID; m_hin_bas UUID;
|
||||
m_mail_sec UUID; m_soc_eng UUID; m_phish UUID;
|
||||
BEGIN
|
||||
-- Skip if seed data already exists
|
||||
IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = t_id LIMIT 1) THEN
|
||||
RAISE NOTICE 'Seed data already exists, skipping';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Insert modules and capture IDs
|
||||
INSERT INTO training_modules (id, tenant_id, module_code, title, description, regulation_area, nis2_relevant, frequency_type, validity_days, risk_weight, duration_minutes, pass_threshold, sort_order)
|
||||
VALUES
|
||||
(gen_random_uuid(), t_id, 'GDPR-BAS', 'DSGVO Grundlagen', 'Grundlegende Datenschutzprinzipien, Rechtsgrundlagen, Verarbeitungsgrundsaetze', 'dsgvo', false, 'annual', 365, 2.0, 30, 70, 1),
|
||||
(gen_random_uuid(), t_id, 'GDPR-ADV', 'DSGVO Vertiefung', 'Auftragsverarbeitung, Drittlandtransfer, Datenschutz-Management', 'dsgvo', false, 'annual', 365, 2.5, 45, 70, 2),
|
||||
(gen_random_uuid(), t_id, 'GDPR-ART', 'Betroffenenrechte', 'Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenportabilitaet', 'dsgvo', false, 'annual', 365, 2.0, 30, 70, 3),
|
||||
(gen_random_uuid(), t_id, 'GDPR-DPIA', 'Datenschutz-Folgenabschaetzung', 'DSFA-Durchfuehrung, Schwellwertanalyse, Risikobewertung', 'dsgvo', false, 'event_trigger', 365, 3.0, 60, 80, 4),
|
||||
(gen_random_uuid(), t_id, 'NIS2-M1', 'NIS2 Geschaeftsleitungspflicht', 'Haftung, Schulungspflicht, Risikomanagement-Governance', 'nis2', true, 'annual', 365, 3.0, 45, 80, 5),
|
||||
(gen_random_uuid(), t_id, 'NIS2-M2', 'NIS2 Incident Response', 'Meldepflichten, 24h/72h-Fristen, Incident-Response-Plan', 'nis2', true, 'annual', 365, 3.0, 60, 80, 6),
|
||||
(gen_random_uuid(), t_id, 'NIS2-M3', 'NIS2 Lieferkettensicherheit', 'Supply-Chain-Risiken, Dienstleisterbewertung, Vertragsklauseln', 'nis2', true, 'annual', 365, 2.5, 45, 70, 7),
|
||||
(gen_random_uuid(), t_id, 'NIS2-M4', 'NIS2 BCM', 'Business Continuity, Notfallplanung, Wiederherstellung', 'nis2', true, 'annual', 365, 2.5, 45, 70, 8),
|
||||
(gen_random_uuid(), t_id, 'NIS2-M5', 'NIS2 Risikomanagement', 'Risikoanalyse, Massnahmenplanung, Wirksamkeitspruefung', 'nis2', true, 'annual', 365, 3.0, 60, 80, 9),
|
||||
(gen_random_uuid(), t_id, 'ISMS-BAS', 'ISMS Grundlagen', 'ISO 27001 Anforderungen, PDCA-Zyklus, Informationssicherheitspolitik', 'iso27001', false, 'annual', 365, 2.0, 30, 70, 10),
|
||||
(gen_random_uuid(), t_id, 'ISMS-INT', 'ISMS Interne Audits', 'Audit-Planung, Durchfuehrung, Berichterstattung, Massnahmenverfolgung', 'iso27001', false, 'annual', 365, 2.5, 45, 70, 11),
|
||||
(gen_random_uuid(), t_id, 'ISMS-AUD', 'ISMS Audit-Vorbereitung', 'Zertifizierungsaudit, Dokumentation, Nachweisfuehrung', 'iso27001', false, 'event_trigger', 365, 2.5, 60, 70, 12),
|
||||
(gen_random_uuid(), t_id, 'AI-BAS', 'KI-Kompetenz Grundlagen', 'EU AI Act Ueberblick, KI-Risikokategorien, Transparenzpflichten', 'ai_act', false, 'annual', 365, 2.0, 30, 70, 13),
|
||||
(gen_random_uuid(), t_id, 'AI-ADV', 'KI-Risikomanagement', 'Hochrisiko-KI, Konformitaetsbewertung, Dokumentationspflichten', 'ai_act', false, 'annual', 365, 2.5, 45, 70, 14),
|
||||
(gen_random_uuid(), t_id, 'AI-RISK', 'Hochrisiko-KI-Systeme', 'Risikomanagementsystem, Qualitaet der Trainingsdaten, Human Oversight', 'ai_act', false, 'event_trigger', 365, 3.0, 60, 80, 15),
|
||||
(gen_random_uuid(), t_id, 'GESCH-BAS', 'Geschaeftsgeheimnisschutz', 'GeschGehG, Schutzkonzept, NDAs, technische Massnahmen', 'geschgehg', false, 'annual', 365, 2.0, 30, 70, 16),
|
||||
(gen_random_uuid(), t_id, 'HIN-BAS', 'Hinweisgeberschutz', 'HinSchG, Meldekanal, Vertraulichkeit, Repressalienverbot', 'hinschg', false, 'annual', 365, 2.0, 30, 70, 17),
|
||||
(gen_random_uuid(), t_id, 'MAIL-SEC', 'E-Mail-Sicherheit', 'Phishing, Spam, Verschluesselung, Sichere Kommunikation', 'iso27001', false, 'micro', 180, 1.5, 15, 70, 18),
|
||||
(gen_random_uuid(), t_id, 'SOC-ENG', 'Social Engineering Abwehr', 'Manipulationstechniken, Pretexting, Baiting, Tailgating', 'iso27001', false, 'micro', 180, 1.5, 15, 70, 19),
|
||||
(gen_random_uuid(), t_id, 'PHISH', 'Phishing-Erkennung', 'Phishing-Merkmale, Pruefschritte, Meldeprozess', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 20);
|
||||
|
||||
-- Get module IDs for CTM
|
||||
SELECT id INTO m_gdpr_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-BAS';
|
||||
SELECT id INTO m_gdpr_adv FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-ADV';
|
||||
SELECT id INTO m_gdpr_art FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-ART';
|
||||
SELECT id INTO m_gdpr_dpia FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-DPIA';
|
||||
SELECT id INTO m_nis2_m1 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M1';
|
||||
SELECT id INTO m_nis2_m2 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M2';
|
||||
SELECT id INTO m_nis2_m3 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M3';
|
||||
SELECT id INTO m_nis2_m4 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M4';
|
||||
SELECT id INTO m_nis2_m5 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M5';
|
||||
SELECT id INTO m_isms_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-BAS';
|
||||
SELECT id INTO m_isms_int FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-INT';
|
||||
SELECT id INTO m_isms_aud FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-AUD';
|
||||
SELECT id INTO m_ai_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-BAS';
|
||||
SELECT id INTO m_ai_adv FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-ADV';
|
||||
SELECT id INTO m_ai_risk FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-RISK';
|
||||
SELECT id INTO m_gesch_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'GESCH-BAS';
|
||||
SELECT id INTO m_hin_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'HIN-BAS';
|
||||
SELECT id INTO m_mail_sec FROM training_modules WHERE tenant_id = t_id AND module_code = 'MAIL-SEC';
|
||||
SELECT id INTO m_soc_eng FROM training_modules WHERE tenant_id = t_id AND module_code = 'SOC-ENG';
|
||||
SELECT id INTO m_phish FROM training_modules WHERE tenant_id = t_id AND module_code = 'PHISH';
|
||||
|
||||
-- CTM: R1 Geschaeftsfuehrung
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R1', m_gdpr_bas, true, 1), (t_id, 'R1', m_nis2_m1, true, 1),
|
||||
(t_id, 'R1', m_nis2_m5, true, 2), (t_id, 'R1', m_isms_bas, true, 2),
|
||||
(t_id, 'R1', m_ai_bas, true, 3), (t_id, 'R1', m_gesch_bas, true, 3);
|
||||
|
||||
-- CTM: R2 IT-Leitung
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R2', m_nis2_m2, true, 1), (t_id, 'R2', m_nis2_m5, true, 1),
|
||||
(t_id, 'R2', m_isms_bas, true, 2), (t_id, 'R2', m_isms_int, true, 2),
|
||||
(t_id, 'R2', m_mail_sec, true, 3), (t_id, 'R2', m_soc_eng, true, 3),
|
||||
(t_id, 'R2', m_phish, true, 3);
|
||||
|
||||
-- CTM: R3 DSB
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R3', m_gdpr_bas, true, 1), (t_id, 'R3', m_gdpr_adv, true, 1),
|
||||
(t_id, 'R3', m_gdpr_art, true, 1), (t_id, 'R3', m_gdpr_dpia, true, 2),
|
||||
(t_id, 'R3', m_isms_bas, true, 3);
|
||||
|
||||
-- CTM: R4 ISB
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R4', m_nis2_m2, true, 1), (t_id, 'R4', m_nis2_m3, true, 1),
|
||||
(t_id, 'R4', m_nis2_m4, true, 1), (t_id, 'R4', m_nis2_m5, true, 1),
|
||||
(t_id, 'R4', m_isms_bas, true, 2), (t_id, 'R4', m_isms_int, true, 2),
|
||||
(t_id, 'R4', m_isms_aud, true, 2);
|
||||
|
||||
-- CTM: R5 HR
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R5', m_gdpr_bas, true, 1), (t_id, 'R5', m_gdpr_art, true, 1),
|
||||
(t_id, 'R5', m_hin_bas, true, 2);
|
||||
|
||||
-- CTM: R6 Einkauf
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R6', m_gdpr_bas, true, 1), (t_id, 'R6', m_nis2_m3, true, 1),
|
||||
(t_id, 'R6', m_gesch_bas, true, 2);
|
||||
|
||||
-- CTM: R7 Fachabteilung
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R7', m_gdpr_bas, true, 1), (t_id, 'R7', m_ai_bas, true, 2),
|
||||
(t_id, 'R7', m_mail_sec, true, 3), (t_id, 'R7', m_soc_eng, true, 3);
|
||||
|
||||
-- CTM: R8 IT-Admin
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R8', m_nis2_m2, true, 1), (t_id, 'R8', m_nis2_m5, true, 1),
|
||||
(t_id, 'R8', m_isms_bas, true, 2), (t_id, 'R8', m_mail_sec, true, 3),
|
||||
(t_id, 'R8', m_soc_eng, true, 3), (t_id, 'R8', m_phish, true, 3);
|
||||
|
||||
-- CTM: R9 Alle Mitarbeiter
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(t_id, 'R9', m_gdpr_bas, true, 1), (t_id, 'R9', m_mail_sec, true, 2),
|
||||
(t_id, 'R9', m_soc_eng, true, 2), (t_id, 'R9', m_phish, true, 2);
|
||||
|
||||
RAISE NOTICE 'Training Engine seed data inserted successfully';
|
||||
END $$;
|
||||
84
ai-compliance-sdk/migrations/015_it_security_modules.sql
Normal file
84
ai-compliance-sdk/migrations/015_it_security_modules.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
-- =========================================================
|
||||
-- Migration 015: IT-Security Training Modules
|
||||
-- =========================================================
|
||||
-- 8 neue IT-Security Micro-/Annual-Trainingsmodule
|
||||
-- fuer Breakpilot-Tenant
|
||||
-- =========================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
bp_id UUID := '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e';
|
||||
b_sec_pwd UUID;
|
||||
b_sec_desk UUID;
|
||||
b_sec_kiai UUID;
|
||||
b_sec_byod UUID;
|
||||
b_sec_video UUID;
|
||||
b_sec_usb UUID;
|
||||
b_sec_inc UUID;
|
||||
b_sec_home UUID;
|
||||
BEGIN
|
||||
-- Skip if already exists
|
||||
IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-PWD' LIMIT 1) THEN
|
||||
RAISE NOTICE 'IT-Security modules already exist for Breakpilot tenant, skipping';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Insert 8 IT-Security modules
|
||||
INSERT INTO training_modules (id, tenant_id, module_code, title, description, regulation_area, nis2_relevant, frequency_type, validity_days, risk_weight, duration_minutes, pass_threshold, sort_order)
|
||||
VALUES
|
||||
(gen_random_uuid(), bp_id, 'SEC-PWD', 'Passwortsicherheit & MFA', 'Sichere Passwoerter, Multi-Faktor-Authentifizierung, Passwort-Manager', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 21),
|
||||
(gen_random_uuid(), bp_id, 'SEC-DESK', 'Sichere Datenablage & Clean Desk', 'Clean-Desk-Policy, sichere Ablage, Bildschirmsperre, Dokumentenvernichtung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 22),
|
||||
(gen_random_uuid(), bp_id, 'SEC-KIAI', 'Personenbezogene Daten in KI-Tools', 'DSGVO-konforme Nutzung von KI, ChatGPT und Co., Datenweitergabe-Risiken', 'dsgvo', false, 'annual', 365, 2.5, 30, 70, 23),
|
||||
(gen_random_uuid(), bp_id, 'SEC-BYOD', 'BYOD & Mobile Security', 'Bring Your Own Device, Mobile Device Management, Geraetetrennung', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 24),
|
||||
(gen_random_uuid(), bp_id, 'SEC-VIDEO', 'Sichere Videokonferenzen', 'Datenschutz in Videokonferenzen, Screensharing-Risiken, Aufzeichnungsregeln', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 25),
|
||||
(gen_random_uuid(), bp_id, 'SEC-USB', 'USB & Externe Medien', 'Risiken externer Datentraeger, USB-Richtlinien, Verschluesselung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 26),
|
||||
(gen_random_uuid(), bp_id, 'SEC-INC', 'Sicherheitsvorfall melden', 'Erkennung von Sicherheitsvorfaellen, Meldewege, Sofortmassnahmen, Dokumentation', 'iso27001', true, 'micro', 180, 1.5, 10, 70, 27),
|
||||
(gen_random_uuid(), bp_id, 'SEC-HOME', 'Homeoffice-Sicherheit', 'Sicheres Arbeiten von zuhause, VPN, WLAN-Sicherheit, physische Sicherheit', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 28);
|
||||
|
||||
-- Lookup module IDs
|
||||
SELECT id INTO b_sec_pwd FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-PWD';
|
||||
SELECT id INTO b_sec_desk FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-DESK';
|
||||
SELECT id INTO b_sec_kiai FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-KIAI';
|
||||
SELECT id INTO b_sec_byod FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-BYOD';
|
||||
SELECT id INTO b_sec_video FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-VIDEO';
|
||||
SELECT id INTO b_sec_usb FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-USB';
|
||||
SELECT id INTO b_sec_inc FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-INC';
|
||||
SELECT id INTO b_sec_home FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-HOME';
|
||||
|
||||
-- CTM: R2 IT-Leitung
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(bp_id, 'R2', b_sec_byod, true, 3),
|
||||
(bp_id, 'R2', b_sec_usb, true, 3),
|
||||
(bp_id, 'R2', b_sec_inc, true, 2);
|
||||
|
||||
-- CTM: R3 DSB
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(bp_id, 'R3', b_sec_kiai, true, 2);
|
||||
|
||||
-- CTM: R7 Fachabteilung
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(bp_id, 'R7', b_sec_pwd, true, 3),
|
||||
(bp_id, 'R7', b_sec_kiai, true, 3),
|
||||
(bp_id, 'R7', b_sec_inc, true, 2);
|
||||
|
||||
-- CTM: R8 IT-Admin
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(bp_id, 'R8', b_sec_pwd, true, 3),
|
||||
(bp_id, 'R8', b_sec_byod, true, 3),
|
||||
(bp_id, 'R8', b_sec_usb, true, 3),
|
||||
(bp_id, 'R8', b_sec_inc, true, 2),
|
||||
(bp_id, 'R8', b_sec_home, true, 3);
|
||||
|
||||
-- CTM: R9 Alle Mitarbeiter
|
||||
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
|
||||
(bp_id, 'R9', b_sec_pwd, true, 3),
|
||||
(bp_id, 'R9', b_sec_desk, true, 3),
|
||||
(bp_id, 'R9', b_sec_kiai, true, 3),
|
||||
(bp_id, 'R9', b_sec_byod, true, 3),
|
||||
(bp_id, 'R9', b_sec_video, false, 5),
|
||||
(bp_id, 'R9', b_sec_usb, true, 3),
|
||||
(bp_id, 'R9', b_sec_inc, true, 2),
|
||||
(bp_id, 'R9', b_sec_home, true, 3);
|
||||
|
||||
RAISE NOTICE 'IT-Security modules inserted for Breakpilot tenant';
|
||||
END $$;
|
||||
28
ai-compliance-sdk/migrations/016_training_media.sql
Normal file
28
ai-compliance-sdk/migrations/016_training_media.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- =========================================================
|
||||
-- Migration 016: Training Media (Audio/Video)
|
||||
-- =========================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_media (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
content_id UUID REFERENCES training_module_content(id),
|
||||
media_type VARCHAR(10) NOT NULL CHECK (media_type IN ('audio', 'video')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'processing',
|
||||
bucket VARCHAR(100) NOT NULL,
|
||||
object_key VARCHAR(500) NOT NULL,
|
||||
file_size_bytes BIGINT,
|
||||
duration_seconds FLOAT,
|
||||
mime_type VARCHAR(50),
|
||||
voice_model VARCHAR(100),
|
||||
language VARCHAR(10) DEFAULT 'de',
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
error_message TEXT,
|
||||
generated_by VARCHAR(50),
|
||||
is_published BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_media_module ON training_media(module_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_media_type ON training_media(module_id, media_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_media_published ON training_media(module_id, media_type, is_published) WHERE is_published = true;
|
||||
Reference in New Issue
Block a user