Files
breakpilot-compliance/ai-compliance-sdk/internal/training/block_store.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

485 lines
14 KiB
Go

package training
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ============================================================================
// Block Config CRUD
// ============================================================================
// CreateBlockConfig creates a new training block configuration
func (s *Store) CreateBlockConfig(ctx context.Context, config *TrainingBlockConfig) error {
config.ID = uuid.New()
config.CreatedAt = time.Now().UTC()
config.UpdatedAt = config.CreatedAt
if !config.IsActive {
config.IsActive = true
}
_, err := s.pool.Exec(ctx, `
INSERT INTO training_block_configs (
id, tenant_id, name, description,
domain_filter, category_filter, severity_filter, target_audience_filter,
regulation_area, module_code_prefix, frequency_type,
duration_minutes, pass_threshold, max_controls_per_module,
is_active, created_at, updated_at
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11,
$12, $13, $14,
$15, $16, $17
)
`,
config.ID, config.TenantID, config.Name, config.Description,
nilIfEmpty(config.DomainFilter), nilIfEmpty(config.CategoryFilter),
nilIfEmpty(config.SeverityFilter), nilIfEmpty(config.TargetAudienceFilter),
string(config.RegulationArea), config.ModuleCodePrefix, string(config.FrequencyType),
config.DurationMinutes, config.PassThreshold, config.MaxControlsPerModule,
config.IsActive, config.CreatedAt, config.UpdatedAt,
)
return err
}
// GetBlockConfig retrieves a block config by ID
func (s *Store) GetBlockConfig(ctx context.Context, id uuid.UUID) (*TrainingBlockConfig, error) {
var config TrainingBlockConfig
var regulationArea, frequencyType string
var domainFilter, categoryFilter, severityFilter, targetAudienceFilter *string
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, name, description,
domain_filter, category_filter, severity_filter, target_audience_filter,
regulation_area, module_code_prefix, frequency_type,
duration_minutes, pass_threshold, max_controls_per_module,
is_active, last_generated_at, created_at, updated_at
FROM training_block_configs WHERE id = $1
`, id).Scan(
&config.ID, &config.TenantID, &config.Name, &config.Description,
&domainFilter, &categoryFilter, &severityFilter, &targetAudienceFilter,
&regulationArea, &config.ModuleCodePrefix, &frequencyType,
&config.DurationMinutes, &config.PassThreshold, &config.MaxControlsPerModule,
&config.IsActive, &config.LastGeneratedAt, &config.CreatedAt, &config.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
config.RegulationArea = RegulationArea(regulationArea)
config.FrequencyType = FrequencyType(frequencyType)
if domainFilter != nil {
config.DomainFilter = *domainFilter
}
if categoryFilter != nil {
config.CategoryFilter = *categoryFilter
}
if severityFilter != nil {
config.SeverityFilter = *severityFilter
}
if targetAudienceFilter != nil {
config.TargetAudienceFilter = *targetAudienceFilter
}
return &config, nil
}
// ListBlockConfigs returns all block configs for a tenant
func (s *Store) ListBlockConfigs(ctx context.Context, tenantID uuid.UUID) ([]TrainingBlockConfig, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, tenant_id, name, description,
domain_filter, category_filter, severity_filter, target_audience_filter,
regulation_area, module_code_prefix, frequency_type,
duration_minutes, pass_threshold, max_controls_per_module,
is_active, last_generated_at, created_at, updated_at
FROM training_block_configs
WHERE tenant_id = $1
ORDER BY created_at DESC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []TrainingBlockConfig
for rows.Next() {
var config TrainingBlockConfig
var regulationArea, frequencyType string
var domainFilter, categoryFilter, severityFilter, targetAudienceFilter *string
if err := rows.Scan(
&config.ID, &config.TenantID, &config.Name, &config.Description,
&domainFilter, &categoryFilter, &severityFilter, &targetAudienceFilter,
&regulationArea, &config.ModuleCodePrefix, &frequencyType,
&config.DurationMinutes, &config.PassThreshold, &config.MaxControlsPerModule,
&config.IsActive, &config.LastGeneratedAt, &config.CreatedAt, &config.UpdatedAt,
); err != nil {
return nil, err
}
config.RegulationArea = RegulationArea(regulationArea)
config.FrequencyType = FrequencyType(frequencyType)
if domainFilter != nil {
config.DomainFilter = *domainFilter
}
if categoryFilter != nil {
config.CategoryFilter = *categoryFilter
}
if severityFilter != nil {
config.SeverityFilter = *severityFilter
}
if targetAudienceFilter != nil {
config.TargetAudienceFilter = *targetAudienceFilter
}
configs = append(configs, config)
}
if configs == nil {
configs = []TrainingBlockConfig{}
}
return configs, nil
}
// UpdateBlockConfig updates a block config
func (s *Store) UpdateBlockConfig(ctx context.Context, config *TrainingBlockConfig) error {
config.UpdatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE training_block_configs SET
name = $2, description = $3,
domain_filter = $4, category_filter = $5,
severity_filter = $6, target_audience_filter = $7,
max_controls_per_module = $8, duration_minutes = $9,
pass_threshold = $10, is_active = $11, updated_at = $12
WHERE id = $1
`,
config.ID, config.Name, config.Description,
nilIfEmpty(config.DomainFilter), nilIfEmpty(config.CategoryFilter),
nilIfEmpty(config.SeverityFilter), nilIfEmpty(config.TargetAudienceFilter),
config.MaxControlsPerModule, config.DurationMinutes,
config.PassThreshold, config.IsActive, config.UpdatedAt,
)
return err
}
// DeleteBlockConfig deletes a block config (cascades to control links)
func (s *Store) DeleteBlockConfig(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, `DELETE FROM training_block_configs WHERE id = $1`, id)
return err
}
// UpdateBlockConfigLastGenerated updates the last_generated_at timestamp
func (s *Store) UpdateBlockConfigLastGenerated(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE training_block_configs SET last_generated_at = $2, updated_at = $2 WHERE id = $1
`, id, now)
return err
}
// ============================================================================
// Block Control Links
// ============================================================================
// CreateBlockControlLink creates a link between a block config, a module, and a control
func (s *Store) CreateBlockControlLink(ctx context.Context, link *TrainingBlockControlLink) error {
link.ID = uuid.New()
link.CreatedAt = time.Now().UTC()
requirements, _ := json.Marshal(link.ControlRequirements)
_, err := s.pool.Exec(ctx, `
INSERT INTO training_block_control_links (
id, block_config_id, module_id, control_id,
control_title, control_objective, control_requirements,
sort_order, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`,
link.ID, link.BlockConfigID, link.ModuleID, link.ControlID,
link.ControlTitle, link.ControlObjective, requirements,
link.SortOrder, link.CreatedAt,
)
return err
}
// GetControlLinksForBlock returns all control links for a block config
func (s *Store) GetControlLinksForBlock(ctx context.Context, blockConfigID uuid.UUID) ([]TrainingBlockControlLink, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, block_config_id, module_id, control_id,
control_title, control_objective, control_requirements,
sort_order, created_at
FROM training_block_control_links
WHERE block_config_id = $1
ORDER BY sort_order
`, blockConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []TrainingBlockControlLink
for rows.Next() {
var link TrainingBlockControlLink
var requirements []byte
if err := rows.Scan(
&link.ID, &link.BlockConfigID, &link.ModuleID, &link.ControlID,
&link.ControlTitle, &link.ControlObjective, &requirements,
&link.SortOrder, &link.CreatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(requirements, &link.ControlRequirements)
if link.ControlRequirements == nil {
link.ControlRequirements = []string{}
}
links = append(links, link)
}
if links == nil {
links = []TrainingBlockControlLink{}
}
return links, nil
}
// GetControlLinksForModule returns all control links for a specific module
func (s *Store) GetControlLinksForModule(ctx context.Context, moduleID uuid.UUID) ([]TrainingBlockControlLink, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, block_config_id, module_id, control_id,
control_title, control_objective, control_requirements,
sort_order, created_at
FROM training_block_control_links
WHERE module_id = $1
ORDER BY sort_order
`, moduleID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []TrainingBlockControlLink
for rows.Next() {
var link TrainingBlockControlLink
var requirements []byte
if err := rows.Scan(
&link.ID, &link.BlockConfigID, &link.ModuleID, &link.ControlID,
&link.ControlTitle, &link.ControlObjective, &requirements,
&link.SortOrder, &link.CreatedAt,
); err != nil {
return nil, err
}
json.Unmarshal(requirements, &link.ControlRequirements)
if link.ControlRequirements == nil {
link.ControlRequirements = []string{}
}
links = append(links, link)
}
if links == nil {
links = []TrainingBlockControlLink{}
}
return links, nil
}
// ============================================================================
// Canonical Controls Query (reads from shared DB table)
// ============================================================================
// QueryCanonicalControls queries canonical_controls with dynamic filters.
// Domain is derived from the control_id prefix (e.g. "AUTH" from "AUTH-042").
func (s *Store) QueryCanonicalControls(ctx context.Context,
domain, category, severity, targetAudience string,
) ([]CanonicalControlSummary, error) {
query := `SELECT control_id, title, objective, rationale,
requirements, severity, COALESCE(category, ''), COALESCE(target_audience, ''), COALESCE(tags, '[]')
FROM canonical_controls
WHERE release_state NOT IN ('deprecated', 'draft')
AND customer_visible = true`
args := []interface{}{}
argIdx := 1
if domain != "" {
query += fmt.Sprintf(` AND LEFT(control_id, %d) = $%d`, len(domain), argIdx)
args = append(args, domain)
argIdx++
}
if category != "" {
query += fmt.Sprintf(` AND category = $%d`, argIdx)
args = append(args, category)
argIdx++
}
if severity != "" {
query += fmt.Sprintf(` AND severity = $%d`, argIdx)
args = append(args, severity)
argIdx++
}
if targetAudience != "" {
query += fmt.Sprintf(` AND (target_audience = $%d OR target_audience = 'all')`, argIdx)
args = append(args, targetAudience)
argIdx++
}
query += ` ORDER BY control_id`
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query canonical controls: %w", err)
}
defer rows.Close()
var controls []CanonicalControlSummary
for rows.Next() {
var c CanonicalControlSummary
var requirementsJSON, tagsJSON []byte
if err := rows.Scan(
&c.ControlID, &c.Title, &c.Objective, &c.Rationale,
&requirementsJSON, &c.Severity, &c.Category, &c.TargetAudience, &tagsJSON,
); err != nil {
return nil, err
}
json.Unmarshal(requirementsJSON, &c.Requirements)
if c.Requirements == nil {
c.Requirements = []string{}
}
json.Unmarshal(tagsJSON, &c.Tags)
if c.Tags == nil {
c.Tags = []string{}
}
controls = append(controls, c)
}
if controls == nil {
controls = []CanonicalControlSummary{}
}
return controls, nil
}
// GetCanonicalControlMeta returns aggregated metadata about canonical controls
func (s *Store) GetCanonicalControlMeta(ctx context.Context) (*CanonicalControlMeta, error) {
meta := &CanonicalControlMeta{}
// Total count
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM canonical_controls
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
`).Scan(&meta.Total)
if err != nil {
return nil, fmt.Errorf("count canonical controls: %w", err)
}
// Domains (derived from control_id prefix)
rows, err := s.pool.Query(ctx, `
SELECT LEFT(control_id, POSITION('-' IN control_id) - 1) AS domain, COUNT(*) AS cnt
FROM canonical_controls
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
AND POSITION('-' IN control_id) > 0
GROUP BY domain ORDER BY cnt DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var d DomainCount
if err := rows.Scan(&d.Domain, &d.Count); err != nil {
return nil, err
}
meta.Domains = append(meta.Domains, d)
}
if meta.Domains == nil {
meta.Domains = []DomainCount{}
}
// Categories
catRows, err := s.pool.Query(ctx, `
SELECT COALESCE(category, 'uncategorized') AS cat, COUNT(*) AS cnt
FROM canonical_controls
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
GROUP BY cat ORDER BY cnt DESC
`)
if err != nil {
return nil, err
}
defer catRows.Close()
for catRows.Next() {
var c CategoryCount
if err := catRows.Scan(&c.Category, &c.Count); err != nil {
return nil, err
}
meta.Categories = append(meta.Categories, c)
}
if meta.Categories == nil {
meta.Categories = []CategoryCount{}
}
// Target audiences
audRows, err := s.pool.Query(ctx, `
SELECT COALESCE(target_audience, 'unset') AS aud, COUNT(*) AS cnt
FROM canonical_controls
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
GROUP BY aud ORDER BY cnt DESC
`)
if err != nil {
return nil, err
}
defer audRows.Close()
for audRows.Next() {
var a AudienceCount
if err := audRows.Scan(&a.Audience, &a.Count); err != nil {
return nil, err
}
meta.Audiences = append(meta.Audiences, a)
}
if meta.Audiences == nil {
meta.Audiences = []AudienceCount{}
}
return meta, nil
}
// ============================================================================
// Helpers
// ============================================================================
// CountModulesWithPrefix counts existing modules with a given code prefix for auto-numbering
func (s *Store) CountModulesWithPrefix(ctx context.Context, tenantID uuid.UUID, prefix string) (int, error) {
var count int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM training_modules
WHERE tenant_id = $1 AND module_code LIKE $2
`, tenantID, prefix+"-%").Scan(&count)
return count, err
}
func nilIfEmpty(s string) *string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return &s
}