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:
@@ -235,6 +235,12 @@ func (s *Store) UpdateModule(ctx context.Context, module *TrainingModule) error
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteModule deletes a training module by ID
|
||||
func (s *Store) DeleteModule(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_modules WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAcademyCourseID links a training module to an academy course
|
||||
func (s *Store) SetAcademyCourseID(ctx context.Context, moduleID, courseID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
@@ -570,6 +576,18 @@ func (s *Store) UpdateAssignmentStatus(ctx context.Context, id uuid.UUID, status
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAssignmentDeadline updates the deadline of an assignment
|
||||
func (s *Store) UpdateAssignmentDeadline(ctx context.Context, id uuid.UUID, deadline time.Time) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_assignments SET
|
||||
deadline = $2,
|
||||
updated_at = $3
|
||||
WHERE id = $1
|
||||
`, id, deadline, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAssignmentQuizResult updates quiz-related fields on an assignment
|
||||
func (s *Store) UpdateAssignmentQuizResult(ctx context.Context, id uuid.UUID, score float64, passed bool, attempts int) error {
|
||||
now := time.Now().UTC()
|
||||
@@ -1252,6 +1270,80 @@ func (s *Store) GetPublishedAudio(ctx context.Context, moduleID uuid.UUID) (*Tra
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// SetCertificateID sets the certificate ID on an assignment
|
||||
func (s *Store) SetCertificateID(ctx context.Context, assignmentID, certID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_assignments SET certificate_id = $2, updated_at = NOW() WHERE id = $1
|
||||
`, assignmentID, certID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAssignmentByCertificateID finds an assignment by its certificate ID
|
||||
func (s *Store) GetAssignmentByCertificateID(ctx context.Context, certID uuid.UUID) (*TrainingAssignment, error) {
|
||||
var assignmentID uuid.UUID
|
||||
err := s.pool.QueryRow(ctx,
|
||||
"SELECT id FROM training_assignments WHERE certificate_id = $1",
|
||||
certID).Scan(&assignmentID)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAssignment(ctx, assignmentID)
|
||||
}
|
||||
|
||||
// ListCertificates lists assignments that have certificates for a tenant
|
||||
func (s *Store) ListCertificates(ctx context.Context, tenantID uuid.UUID) ([]TrainingAssignment, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
ta.id, ta.tenant_id, ta.module_id, ta.user_id, ta.user_name, ta.user_email,
|
||||
ta.role_code, ta.trigger_type, ta.trigger_event, ta.status, ta.progress_percent,
|
||||
ta.quiz_score, ta.quiz_passed, ta.quiz_attempts,
|
||||
ta.started_at, ta.completed_at, ta.deadline, ta.certificate_id,
|
||||
ta.escalation_level, ta.last_escalation_at, ta.enrollment_id,
|
||||
ta.created_at, ta.updated_at,
|
||||
m.module_code, m.title
|
||||
FROM training_assignments ta
|
||||
JOIN training_modules m ON m.id = ta.module_id
|
||||
WHERE ta.tenant_id = $1 AND ta.certificate_id IS NOT NULL
|
||||
ORDER BY ta.completed_at DESC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assignments []TrainingAssignment
|
||||
for rows.Next() {
|
||||
var a TrainingAssignment
|
||||
var status, triggerType string
|
||||
|
||||
err := rows.Scan(
|
||||
&a.ID, &a.TenantID, &a.ModuleID, &a.UserID, &a.UserName, &a.UserEmail,
|
||||
&a.RoleCode, &triggerType, &a.TriggerEvent, &status, &a.ProgressPercent,
|
||||
&a.QuizScore, &a.QuizPassed, &a.QuizAttempts,
|
||||
&a.StartedAt, &a.CompletedAt, &a.Deadline, &a.CertificateID,
|
||||
&a.EscalationLevel, &a.LastEscalationAt, &a.EnrollmentID,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
&a.ModuleCode, &a.ModuleTitle,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Status = AssignmentStatus(status)
|
||||
a.TriggerType = TriggerType(triggerType)
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
|
||||
if assignments == nil {
|
||||
assignments = []TrainingAssignment{}
|
||||
}
|
||||
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// GetPublishedVideo gets the published video for a module
|
||||
func (s *Store) GetPublishedVideo(ctx context.Context, moduleID uuid.UUID) (*TrainingMedia, error) {
|
||||
var media TrainingMedia
|
||||
@@ -1283,3 +1375,195 @@ func (s *Store) GetPublishedVideo(ctx context.Context, moduleID uuid.UUID) (*Tra
|
||||
media.Status = MediaStatus(status)
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkpoint Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateCheckpoint inserts a new checkpoint
|
||||
func (s *Store) CreateCheckpoint(ctx context.Context, cp *Checkpoint) error {
|
||||
cp.ID = uuid.New()
|
||||
cp.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_checkpoints (id, module_id, checkpoint_index, title, timestamp_seconds, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, cp.ID, cp.ModuleID, cp.CheckpointIndex, cp.Title, cp.TimestampSeconds, cp.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListCheckpoints returns all checkpoints for a module ordered by index
|
||||
func (s *Store) ListCheckpoints(ctx context.Context, moduleID uuid.UUID) ([]Checkpoint, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, module_id, checkpoint_index, title, timestamp_seconds, created_at
|
||||
FROM training_checkpoints
|
||||
WHERE module_id = $1
|
||||
ORDER BY checkpoint_index
|
||||
`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var checkpoints []Checkpoint
|
||||
for rows.Next() {
|
||||
var cp Checkpoint
|
||||
if err := rows.Scan(&cp.ID, &cp.ModuleID, &cp.CheckpointIndex, &cp.Title, &cp.TimestampSeconds, &cp.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checkpoints = append(checkpoints, cp)
|
||||
}
|
||||
|
||||
if checkpoints == nil {
|
||||
checkpoints = []Checkpoint{}
|
||||
}
|
||||
return checkpoints, nil
|
||||
}
|
||||
|
||||
// DeleteCheckpointsForModule removes all checkpoints for a module (used before regenerating)
|
||||
func (s *Store) DeleteCheckpointsForModule(ctx context.Context, moduleID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_checkpoints WHERE module_id = $1`, moduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCheckpointProgress retrieves progress for a specific checkpoint+assignment
|
||||
func (s *Store) GetCheckpointProgress(ctx context.Context, assignmentID, checkpointID uuid.UUID) (*CheckpointProgress, error) {
|
||||
var cp CheckpointProgress
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at
|
||||
FROM training_checkpoint_progress
|
||||
WHERE assignment_id = $1 AND checkpoint_id = $2
|
||||
`, assignmentID, checkpointID).Scan(
|
||||
&cp.ID, &cp.AssignmentID, &cp.CheckpointID, &cp.Passed, &cp.Attempts, &cp.LastAttemptAt, &cp.CreatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// UpsertCheckpointProgress creates or updates checkpoint progress
|
||||
func (s *Store) UpsertCheckpointProgress(ctx context.Context, progress *CheckpointProgress) error {
|
||||
progress.ID = uuid.New()
|
||||
now := time.Now().UTC()
|
||||
progress.LastAttemptAt = &now
|
||||
progress.CreatedAt = now
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_checkpoint_progress (id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (assignment_id, checkpoint_id) DO UPDATE SET
|
||||
passed = EXCLUDED.passed,
|
||||
attempts = training_checkpoint_progress.attempts + 1,
|
||||
last_attempt_at = EXCLUDED.last_attempt_at
|
||||
`, progress.ID, progress.AssignmentID, progress.CheckpointID, progress.Passed, progress.Attempts, progress.LastAttemptAt, progress.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCheckpointQuestions retrieves quiz questions for a specific checkpoint
|
||||
func (s *Store) GetCheckpointQuestions(ctx context.Context, checkpointID uuid.UUID) ([]QuizQuestion, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, module_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at
|
||||
FROM training_quiz_questions
|
||||
WHERE checkpoint_id = $1 AND is_active = true
|
||||
ORDER BY sort_order
|
||||
`, checkpointID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var questions []QuizQuestion
|
||||
for rows.Next() {
|
||||
var q QuizQuestion
|
||||
var options []byte
|
||||
var difficulty string
|
||||
if err := rows.Scan(&q.ID, &q.ModuleID, &q.Question, &options, &q.CorrectIndex, &q.Explanation, &difficulty, &q.IsActive, &q.SortOrder, &q.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(options, &q.Options)
|
||||
q.Difficulty = Difficulty(difficulty)
|
||||
questions = append(questions, q)
|
||||
}
|
||||
|
||||
if questions == nil {
|
||||
questions = []QuizQuestion{}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// CreateCheckpointQuizQuestion creates a quiz question linked to a checkpoint
|
||||
func (s *Store) CreateCheckpointQuizQuestion(ctx context.Context, q *QuizQuestion, checkpointID uuid.UUID) error {
|
||||
q.ID = uuid.New()
|
||||
q.CreatedAt = time.Now().UTC()
|
||||
q.IsActive = true
|
||||
|
||||
options, _ := json.Marshal(q.Options)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_quiz_questions (id, module_id, checkpoint_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, q.ID, q.ModuleID, checkpointID, q.Question, options, q.CorrectIndex, q.Explanation, string(q.Difficulty), q.IsActive, q.SortOrder, q.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// AreAllCheckpointsPassed checks if all checkpoints for a module are passed by an assignment
|
||||
func (s *Store) AreAllCheckpointsPassed(ctx context.Context, assignmentID, moduleID uuid.UUID) (bool, error) {
|
||||
var totalCheckpoints, passedCheckpoints int
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_checkpoints WHERE module_id = $1
|
||||
`, moduleID).Scan(&totalCheckpoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if totalCheckpoints == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_checkpoint_progress cp
|
||||
JOIN training_checkpoints c ON cp.checkpoint_id = c.id
|
||||
WHERE cp.assignment_id = $1 AND c.module_id = $2 AND cp.passed = true
|
||||
`, assignmentID, moduleID).Scan(&passedCheckpoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return passedCheckpoints >= totalCheckpoints, nil
|
||||
}
|
||||
|
||||
// ListCheckpointProgress returns all checkpoint progress for an assignment
|
||||
func (s *Store) ListCheckpointProgress(ctx context.Context, assignmentID uuid.UUID) ([]CheckpointProgress, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at
|
||||
FROM training_checkpoint_progress
|
||||
WHERE assignment_id = $1
|
||||
ORDER BY created_at
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var progress []CheckpointProgress
|
||||
for rows.Next() {
|
||||
var cp CheckpointProgress
|
||||
if err := rows.Scan(&cp.ID, &cp.AssignmentID, &cp.CheckpointID, &cp.Passed, &cp.Attempts, &cp.LastAttemptAt, &cp.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progress = append(progress, cp)
|
||||
}
|
||||
|
||||
if progress == nil {
|
||||
progress = []CheckpointProgress{}
|
||||
}
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user