Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
7.9 KiB
Go
283 lines
7.9 KiB
Go
package training
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// BlockGenerator orchestrates the Controls → Training Modules pipeline
|
|
type BlockGenerator struct {
|
|
store *Store
|
|
contentGenerator *ContentGenerator
|
|
}
|
|
|
|
// NewBlockGenerator creates a new block generator
|
|
func NewBlockGenerator(store *Store, contentGenerator *ContentGenerator) *BlockGenerator {
|
|
return &BlockGenerator{
|
|
store: store,
|
|
contentGenerator: contentGenerator,
|
|
}
|
|
}
|
|
|
|
// Preview performs a dry run: loads matching controls, computes module split and roles
|
|
func (bg *BlockGenerator) Preview(ctx context.Context, configID uuid.UUID) (*PreviewBlockResponse, error) {
|
|
config, err := bg.store.GetBlockConfig(ctx, configID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load block config: %w", err)
|
|
}
|
|
if config == nil {
|
|
return nil, fmt.Errorf("block config not found")
|
|
}
|
|
|
|
controls, err := bg.store.QueryCanonicalControls(ctx,
|
|
config.DomainFilter, config.CategoryFilter,
|
|
config.SeverityFilter, config.TargetAudienceFilter,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query controls: %w", err)
|
|
}
|
|
|
|
maxPerModule := config.MaxControlsPerModule
|
|
if maxPerModule <= 0 {
|
|
maxPerModule = 20
|
|
}
|
|
moduleCount := int(math.Ceil(float64(len(controls)) / float64(maxPerModule)))
|
|
if moduleCount == 0 && len(controls) > 0 {
|
|
moduleCount = 1
|
|
}
|
|
|
|
roles := bg.deriveRoles(controls, config.TargetAudienceFilter)
|
|
|
|
return &PreviewBlockResponse{
|
|
ControlCount: len(controls),
|
|
ModuleCount: moduleCount,
|
|
Controls: controls,
|
|
ProposedRoles: roles,
|
|
}, nil
|
|
}
|
|
|
|
// Generate executes the full pipeline: Controls → Modules → Links → CTM → Content
|
|
func (bg *BlockGenerator) Generate(ctx context.Context, configID uuid.UUID, req GenerateBlockRequest) (*GenerateBlockResponse, error) {
|
|
config, err := bg.store.GetBlockConfig(ctx, configID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load block config: %w", err)
|
|
}
|
|
if config == nil {
|
|
return nil, fmt.Errorf("block config not found")
|
|
}
|
|
|
|
// 1. Load matching controls
|
|
controls, err := bg.store.QueryCanonicalControls(ctx,
|
|
config.DomainFilter, config.CategoryFilter,
|
|
config.SeverityFilter, config.TargetAudienceFilter,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query controls: %w", err)
|
|
}
|
|
|
|
if len(controls) == 0 {
|
|
return &GenerateBlockResponse{}, nil
|
|
}
|
|
|
|
// 2. Chunk controls into module-sized groups
|
|
maxPerModule := config.MaxControlsPerModule
|
|
if maxPerModule <= 0 {
|
|
maxPerModule = 20
|
|
}
|
|
chunks := chunkControls(controls, maxPerModule)
|
|
|
|
// 3. Derive target roles for CTM
|
|
roles := bg.deriveRoles(controls, config.TargetAudienceFilter)
|
|
|
|
// 4. Count existing modules with this prefix for auto-numbering
|
|
existingCount, err := bg.store.CountModulesWithPrefix(ctx, config.TenantID, config.ModuleCodePrefix)
|
|
if err != nil {
|
|
existingCount = 0
|
|
}
|
|
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "de"
|
|
}
|
|
|
|
resp := &GenerateBlockResponse{}
|
|
|
|
for i, chunk := range chunks {
|
|
moduleNum := existingCount + i + 1
|
|
moduleCode := fmt.Sprintf("%s-%02d", config.ModuleCodePrefix, moduleNum)
|
|
|
|
// Build a descriptive title from the first few controls
|
|
title := bg.buildModuleTitle(config, chunk, i+1, len(chunks))
|
|
|
|
// a. Create TrainingModule
|
|
module := &TrainingModule{
|
|
TenantID: config.TenantID,
|
|
ModuleCode: moduleCode,
|
|
Title: title,
|
|
Description: config.Description,
|
|
RegulationArea: config.RegulationArea,
|
|
NIS2Relevant: config.RegulationArea == RegulationNIS2,
|
|
ISOControls: bg.extractControlIDs(chunk),
|
|
FrequencyType: config.FrequencyType,
|
|
ValidityDays: 365,
|
|
RiskWeight: 2.0,
|
|
ContentType: "text",
|
|
DurationMinutes: config.DurationMinutes,
|
|
PassThreshold: config.PassThreshold,
|
|
IsActive: true,
|
|
SortOrder: moduleNum,
|
|
}
|
|
|
|
if err := bg.store.CreateModule(ctx, module); err != nil {
|
|
resp.Errors = append(resp.Errors, fmt.Sprintf("create module %s: %v", moduleCode, err))
|
|
continue
|
|
}
|
|
resp.ModulesCreated++
|
|
|
|
// b. Create control links (traceability)
|
|
for j, ctrl := range chunk {
|
|
link := &TrainingBlockControlLink{
|
|
BlockConfigID: config.ID,
|
|
ModuleID: module.ID,
|
|
ControlID: ctrl.ControlID,
|
|
ControlTitle: ctrl.Title,
|
|
ControlObjective: ctrl.Objective,
|
|
ControlRequirements: ctrl.Requirements,
|
|
SortOrder: j,
|
|
}
|
|
if err := bg.store.CreateBlockControlLink(ctx, link); err != nil {
|
|
resp.Errors = append(resp.Errors, fmt.Sprintf("link %s→%s: %v", moduleCode, ctrl.ControlID, err))
|
|
continue
|
|
}
|
|
resp.ControlsLinked++
|
|
}
|
|
|
|
// c. Create CTM entries (target_audience → roles)
|
|
if req.AutoMatrix {
|
|
for _, role := range roles {
|
|
entry := &TrainingMatrixEntry{
|
|
TenantID: config.TenantID,
|
|
RoleCode: role,
|
|
ModuleID: module.ID,
|
|
IsMandatory: true,
|
|
Priority: 1,
|
|
}
|
|
if err := bg.store.SetMatrixEntry(ctx, entry); err != nil {
|
|
resp.Errors = append(resp.Errors, fmt.Sprintf("matrix %s→%s: %v", role, moduleCode, err))
|
|
continue
|
|
}
|
|
resp.MatrixEntriesCreated++
|
|
}
|
|
}
|
|
|
|
// d. Generate LLM content
|
|
_, err := bg.contentGenerator.GenerateBlockContent(ctx, *module, chunk, language)
|
|
if err != nil {
|
|
resp.Errors = append(resp.Errors, fmt.Sprintf("content %s: %v", moduleCode, err))
|
|
continue
|
|
}
|
|
resp.ContentGenerated++
|
|
}
|
|
|
|
// 5. Update last_generated_at
|
|
bg.store.UpdateBlockConfigLastGenerated(ctx, config.ID)
|
|
|
|
// 6. Audit log
|
|
bg.store.LogAction(ctx, &AuditLogEntry{
|
|
TenantID: config.TenantID,
|
|
Action: AuditAction("block_generated"),
|
|
EntityType: AuditEntityModule,
|
|
Details: map[string]interface{}{
|
|
"block_config_id": config.ID.String(),
|
|
"block_name": config.Name,
|
|
"modules_created": resp.ModulesCreated,
|
|
"controls_linked": resp.ControlsLinked,
|
|
"content_generated": resp.ContentGenerated,
|
|
},
|
|
})
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// deriveRoles computes which CTM roles should receive the generated modules
|
|
func (bg *BlockGenerator) deriveRoles(controls []CanonicalControlSummary, audienceFilter string) []string {
|
|
roleSet := map[string]bool{}
|
|
|
|
// If a specific audience filter is set, use the mapping
|
|
if audienceFilter != "" {
|
|
if roles, ok := TargetAudienceRoleMapping[audienceFilter]; ok {
|
|
for _, r := range roles {
|
|
roleSet[r] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Additionally derive roles from control categories
|
|
for _, ctrl := range controls {
|
|
if ctrl.Category != "" {
|
|
if roles, ok := CategoryRoleMapping[ctrl.Category]; ok {
|
|
for _, r := range roles {
|
|
roleSet[r] = true
|
|
}
|
|
}
|
|
}
|
|
// Also check per-control target_audience
|
|
if ctrl.TargetAudience != "" && audienceFilter == "" {
|
|
if roles, ok := TargetAudienceRoleMapping[ctrl.TargetAudience]; ok {
|
|
for _, r := range roles {
|
|
roleSet[r] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If nothing derived, default to R9 (Alle Mitarbeiter)
|
|
if len(roleSet) == 0 {
|
|
roleSet[RoleR9] = true
|
|
}
|
|
|
|
roles := make([]string, 0, len(roleSet))
|
|
for r := range roleSet {
|
|
roles = append(roles, r)
|
|
}
|
|
return roles
|
|
}
|
|
|
|
// buildModuleTitle creates a descriptive module title
|
|
func (bg *BlockGenerator) buildModuleTitle(config *TrainingBlockConfig, controls []CanonicalControlSummary, partNum, totalParts int) string {
|
|
base := config.Name
|
|
if totalParts > 1 {
|
|
base = fmt.Sprintf("%s (Teil %d/%d)", config.Name, partNum, totalParts)
|
|
}
|
|
return base
|
|
}
|
|
|
|
// extractControlIDs returns the control IDs from a slice of controls
|
|
func (bg *BlockGenerator) extractControlIDs(controls []CanonicalControlSummary) []string {
|
|
ids := make([]string, len(controls))
|
|
for i, c := range controls {
|
|
ids[i] = c.ControlID
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// chunkControls splits controls into groups of maxSize
|
|
func chunkControls(controls []CanonicalControlSummary, maxSize int) [][]CanonicalControlSummary {
|
|
if maxSize <= 0 {
|
|
maxSize = 20
|
|
}
|
|
|
|
var chunks [][]CanonicalControlSummary
|
|
for i := 0; i < len(controls); i += maxSize {
|
|
end := i + maxSize
|
|
if end > len(controls) {
|
|
end = len(controls)
|
|
}
|
|
chunks = append(chunks, controls[i:end])
|
|
}
|
|
return chunks
|
|
}
|