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 48s
CI / test-python-backend-compliance (push) Successful in 35s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s
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 48s
CI / test-python-backend-compliance (push) Successful in 35s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s
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:
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user