feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
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
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>
This commit is contained in:
282
ai-compliance-sdk/internal/training/block_generator.go
Normal file
282
ai-compliance-sdk/internal/training/block_generator.go
Normal file
@@ -0,0 +1,282 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user