Files
breakpilot-compliance/ai-compliance-sdk/internal/training/block_generator.go
Benjamin Admin 4f6bc8f6f6
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
feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
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>
2026-03-16 21:41:48 +01:00

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
}