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

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:
Benjamin Admin
2026-03-16 21:41:48 +01:00
parent d2133dbfa2
commit 4f6bc8f6f6
50 changed files with 17299 additions and 198 deletions

View File

@@ -3,7 +3,9 @@ package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/training"
"github.com/gin-gonic/gin"
@@ -14,13 +16,17 @@ import (
type TrainingHandlers struct {
store *training.Store
contentGenerator *training.ContentGenerator
blockGenerator *training.BlockGenerator
ttsClient *training.TTSClient
}
// NewTrainingHandlers creates new training handlers
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator) *TrainingHandlers {
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator, blockGenerator *training.BlockGenerator, ttsClient *training.TTSClient) *TrainingHandlers {
return &TrainingHandlers{
store: store,
contentGenerator: contentGenerator,
blockGenerator: blockGenerator,
ttsClient: ttsClient,
}
}
@@ -212,6 +218,33 @@ func (h *TrainingHandlers) UpdateModule(c *gin.Context) {
c.JSON(http.StatusOK, module)
}
// DeleteModule deletes a training module
// DELETE /sdk/v1/training/modules/:id
func (h *TrainingHandlers) DeleteModule(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
if err := h.store.DeleteModule(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ============================================================================
// Matrix Endpoints
// ============================================================================
@@ -459,6 +492,48 @@ func (h *TrainingHandlers) UpdateAssignmentProgress(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": string(status), "progress": req.Progress})
}
// UpdateAssignment updates assignment fields (e.g. deadline)
// PUT /sdk/v1/training/assignments/:id
func (h *TrainingHandlers) UpdateAssignment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
var req struct {
Deadline *string `json:"deadline"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if req.Deadline != nil {
deadline, err := time.Parse(time.RFC3339, *req.Deadline)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deadline format (use RFC3339)"})
return
}
if err := h.store.UpdateAssignmentDeadline(c.Request.Context(), id, deadline); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if assignment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
c.JSON(http.StatusOK, assignment)
}
// CompleteAssignment marks an assignment as completed
// POST /sdk/v1/training/assignments/:id/complete
func (h *TrainingHandlers) CompleteAssignment(c *gin.Context) {
@@ -1111,3 +1186,679 @@ func (h *TrainingHandlers) PreviewVideoScript(c *gin.Context) {
c.JSON(http.StatusOK, script)
}
// ============================================================================
// Training Block Endpoints (Controls → Schulungsmodule)
// ============================================================================
// ListBlockConfigs returns all block configs for the tenant
// GET /sdk/v1/training/blocks
func (h *TrainingHandlers) ListBlockConfigs(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
configs, err := h.store.ListBlockConfigs(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"blocks": configs,
"total": len(configs),
})
}
// CreateBlockConfig creates a new block configuration
// POST /sdk/v1/training/blocks
func (h *TrainingHandlers) CreateBlockConfig(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var req training.CreateBlockConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config := &training.TrainingBlockConfig{
TenantID: tenantID,
Name: req.Name,
Description: req.Description,
DomainFilter: req.DomainFilter,
CategoryFilter: req.CategoryFilter,
SeverityFilter: req.SeverityFilter,
TargetAudienceFilter: req.TargetAudienceFilter,
RegulationArea: req.RegulationArea,
ModuleCodePrefix: req.ModuleCodePrefix,
FrequencyType: req.FrequencyType,
DurationMinutes: req.DurationMinutes,
PassThreshold: req.PassThreshold,
MaxControlsPerModule: req.MaxControlsPerModule,
}
if config.FrequencyType == "" {
config.FrequencyType = training.FrequencyAnnual
}
if config.DurationMinutes == 0 {
config.DurationMinutes = 45
}
if config.PassThreshold == 0 {
config.PassThreshold = 70
}
if config.MaxControlsPerModule == 0 {
config.MaxControlsPerModule = 20
}
if err := h.store.CreateBlockConfig(c.Request.Context(), config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
}
// GetBlockConfig returns a single block config
// GET /sdk/v1/training/blocks/:id
func (h *TrainingHandlers) GetBlockConfig(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
return
}
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if config == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
return
}
c.JSON(http.StatusOK, config)
}
// UpdateBlockConfig updates a block config
// PUT /sdk/v1/training/blocks/:id
func (h *TrainingHandlers) UpdateBlockConfig(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
return
}
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if config == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
return
}
var req training.UpdateBlockConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != nil {
config.Name = *req.Name
}
if req.Description != nil {
config.Description = *req.Description
}
if req.DomainFilter != nil {
config.DomainFilter = *req.DomainFilter
}
if req.CategoryFilter != nil {
config.CategoryFilter = *req.CategoryFilter
}
if req.SeverityFilter != nil {
config.SeverityFilter = *req.SeverityFilter
}
if req.TargetAudienceFilter != nil {
config.TargetAudienceFilter = *req.TargetAudienceFilter
}
if req.MaxControlsPerModule != nil {
config.MaxControlsPerModule = *req.MaxControlsPerModule
}
if req.DurationMinutes != nil {
config.DurationMinutes = *req.DurationMinutes
}
if req.PassThreshold != nil {
config.PassThreshold = *req.PassThreshold
}
if req.IsActive != nil {
config.IsActive = *req.IsActive
}
if err := h.store.UpdateBlockConfig(c.Request.Context(), config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
// DeleteBlockConfig deletes a block config
// DELETE /sdk/v1/training/blocks/:id
func (h *TrainingHandlers) DeleteBlockConfig(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
return
}
if err := h.store.DeleteBlockConfig(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// PreviewBlock performs a dry run showing matching controls and proposed roles
// POST /sdk/v1/training/blocks/:id/preview
func (h *TrainingHandlers) PreviewBlock(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
return
}
preview, err := h.blockGenerator.Preview(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, preview)
}
// GenerateBlock runs the full generation pipeline
// POST /sdk/v1/training/blocks/:id/generate
func (h *TrainingHandlers) GenerateBlock(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
return
}
var req training.GenerateBlockRequest
if err := c.ShouldBindJSON(&req); err != nil {
// Defaults are fine
req.Language = "de"
req.AutoMatrix = true
}
result, err := h.blockGenerator.Generate(c.Request.Context(), id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
// GetBlockControls returns control links for a block config
// GET /sdk/v1/training/blocks/:id/controls
func (h *TrainingHandlers) GetBlockControls(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
return
}
links, err := h.store.GetControlLinksForBlock(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"controls": links,
"total": len(links),
})
}
// ListCanonicalControls returns filtered canonical controls for browsing
// GET /sdk/v1/training/canonical/controls
func (h *TrainingHandlers) ListCanonicalControls(c *gin.Context) {
domain := c.Query("domain")
category := c.Query("category")
severity := c.Query("severity")
targetAudience := c.Query("target_audience")
controls, err := h.store.QueryCanonicalControls(c.Request.Context(),
domain, category, severity, targetAudience,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"controls": controls,
"total": len(controls),
})
}
// GetCanonicalMeta returns aggregated metadata about canonical controls
// GET /sdk/v1/training/canonical/meta
func (h *TrainingHandlers) GetCanonicalMeta(c *gin.Context) {
meta, err := h.store.GetCanonicalControlMeta(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, meta)
}
// ============================================================================
// Media Streaming Endpoint
// ============================================================================
// StreamMedia returns a redirect to a presigned URL for a media file
// GET /sdk/v1/training/media/:mediaId/stream
func (h *TrainingHandlers) StreamMedia(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
return
}
media, err := h.store.GetMedia(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
if h.ttsClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "media streaming not available"})
return
}
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), media.Bucket, media.ObjectKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate streaming URL: " + err.Error()})
return
}
c.Redirect(http.StatusTemporaryRedirect, url)
}
// ============================================================================
// Certificate Endpoints
// ============================================================================
// GenerateCertificate generates a certificate for a completed assignment
// POST /sdk/v1/training/certificates/generate/:assignmentId
func (h *TrainingHandlers) GenerateCertificate(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
tenantID := rbac.GetTenantID(c)
assignment, err := h.store.GetAssignment(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if assignment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
if assignment.Status != training.AssignmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "assignment is not completed"})
return
}
if assignment.QuizPassed == nil || !*assignment.QuizPassed {
c.JSON(http.StatusBadRequest, gin.H{"error": "quiz has not been passed"})
return
}
// Generate certificate ID
certID := uuid.New()
if err := h.store.SetCertificateID(c.Request.Context(), assignmentID, certID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit log
userID := rbac.GetUserID(c)
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
TenantID: tenantID,
UserID: &userID,
Action: training.AuditActionCertificateIssued,
EntityType: training.AuditEntityCertificate,
EntityID: &certID,
Details: map[string]interface{}{
"assignment_id": assignmentID.String(),
"user_name": assignment.UserName,
"module_title": assignment.ModuleTitle,
},
})
// Reload assignment with certificate_id
assignment, _ = h.store.GetAssignment(c.Request.Context(), assignmentID)
c.JSON(http.StatusOK, gin.H{
"certificate_id": certID,
"assignment": assignment,
})
}
// DownloadCertificatePDF generates and returns a PDF certificate
// GET /sdk/v1/training/certificates/:id/pdf
func (h *TrainingHandlers) DownloadCertificatePDF(c *gin.Context) {
certID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
assignment, err := h.store.GetAssignmentByCertificateID(c.Request.Context(), certID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if assignment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
// Get module for title
module, _ := h.store.GetModule(c.Request.Context(), assignment.ModuleID)
courseName := assignment.ModuleTitle
if module != nil {
courseName = module.Title
}
score := 0
if assignment.QuizScore != nil {
score = int(*assignment.QuizScore)
}
issuedAt := assignment.UpdatedAt
if assignment.CompletedAt != nil {
issuedAt = *assignment.CompletedAt
}
// Use academy PDF generator
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
CertificateID: certID.String(),
UserName: assignment.UserName,
CourseName: courseName,
Score: score,
IssuedAt: issuedAt,
ValidUntil: issuedAt.AddDate(1, 0, 0),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()})
return
}
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+certID.String()[:8]+".pdf")
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}
// ListCertificates returns all certificates for a tenant
// GET /sdk/v1/training/certificates
func (h *TrainingHandlers) ListCertificates(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
certificates, err := h.store.ListCertificates(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"certificates": certificates,
"total": len(certificates),
})
}
// ============================================================================
// Interactive Video Endpoints
// ============================================================================
// GenerateInteractiveVideo triggers the full interactive video pipeline
// POST /sdk/v1/training/content/:moduleId/generate-interactive
func (h *TrainingHandlers) GenerateInteractiveVideo(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
module, err := h.store.GetModule(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if module == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
return
}
media, err := h.contentGenerator.GenerateInteractiveVideo(c.Request.Context(), *module)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, media)
}
// GetInteractiveManifest returns the interactive video manifest with checkpoints and progress
// GET /sdk/v1/training/content/:moduleId/interactive-manifest
func (h *TrainingHandlers) GetInteractiveManifest(c *gin.Context) {
moduleID, err := uuid.Parse(c.Param("moduleId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
return
}
// Get interactive video media
mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Find interactive video
var interactiveMedia *training.TrainingMedia
for i := range mediaList {
if mediaList[i].MediaType == training.MediaTypeInteractiveVideo && mediaList[i].Status == training.MediaStatusCompleted {
interactiveMedia = &mediaList[i]
break
}
}
if interactiveMedia == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no interactive video found for this module"})
return
}
// Get checkpoints
checkpoints, err := h.store.ListCheckpoints(c.Request.Context(), moduleID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Optional: get assignment ID for progress
assignmentIDStr := c.Query("assignment_id")
// Build manifest entries
entries := make([]training.CheckpointManifestEntry, len(checkpoints))
for i, cp := range checkpoints {
// Get questions for this checkpoint
questions, _ := h.store.GetCheckpointQuestions(c.Request.Context(), cp.ID)
cpQuestions := make([]training.CheckpointQuestion, len(questions))
for j, q := range questions {
cpQuestions[j] = training.CheckpointQuestion{
Question: q.Question,
Options: q.Options,
CorrectIndex: q.CorrectIndex,
Explanation: q.Explanation,
}
}
entry := training.CheckpointManifestEntry{
CheckpointID: cp.ID,
Index: cp.CheckpointIndex,
Title: cp.Title,
TimestampSeconds: cp.TimestampSeconds,
Questions: cpQuestions,
}
// Get progress if assignment_id provided
if assignmentIDStr != "" {
if assignmentID, err := uuid.Parse(assignmentIDStr); err == nil {
progress, _ := h.store.GetCheckpointProgress(c.Request.Context(), assignmentID, cp.ID)
entry.Progress = progress
}
}
entries[i] = entry
}
// Get stream URL
streamURL := ""
if h.ttsClient != nil {
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), interactiveMedia.Bucket, interactiveMedia.ObjectKey)
if err == nil {
streamURL = url
}
}
manifest := training.InteractiveVideoManifest{
MediaID: interactiveMedia.ID,
StreamURL: streamURL,
Checkpoints: entries,
}
c.JSON(http.StatusOK, manifest)
}
// SubmitCheckpointQuiz handles checkpoint quiz submission
// POST /sdk/v1/training/checkpoints/:checkpointId/submit
func (h *TrainingHandlers) SubmitCheckpointQuiz(c *gin.Context) {
checkpointID, err := uuid.Parse(c.Param("checkpointId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid checkpoint ID"})
return
}
var req training.SubmitCheckpointQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
assignmentID, err := uuid.Parse(req.AssignmentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
// Get checkpoint questions
questions, err := h.store.GetCheckpointQuestions(c.Request.Context(), checkpointID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(questions) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no questions found for this checkpoint"})
return
}
// Grade answers
correctCount := 0
feedback := make([]training.CheckpointQuizFeedback, len(questions))
for i, q := range questions {
isCorrect := false
if i < len(req.Answers) && req.Answers[i] == q.CorrectIndex {
isCorrect = true
correctCount++
}
feedback[i] = training.CheckpointQuizFeedback{
Question: q.Question,
Correct: isCorrect,
Explanation: q.Explanation,
}
}
score := float64(correctCount) / float64(len(questions)) * 100
passed := score >= 70 // 70% threshold for checkpoint
// Update progress
progress := &training.CheckpointProgress{
AssignmentID: assignmentID,
CheckpointID: checkpointID,
Passed: passed,
Attempts: 1,
}
if err := h.store.UpsertCheckpointProgress(c.Request.Context(), progress); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Audit log
userID := rbac.GetUserID(c)
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
TenantID: rbac.GetTenantID(c),
UserID: &userID,
Action: training.AuditAction("checkpoint_submitted"),
EntityType: training.AuditEntityType("checkpoint"),
EntityID: &checkpointID,
Details: map[string]interface{}{
"assignment_id": assignmentID.String(),
"score": score,
"passed": passed,
"correct": correctCount,
"total": len(questions),
},
})
c.JSON(http.StatusOK, training.SubmitCheckpointQuizResponse{
Passed: passed,
Score: score,
Feedback: feedback,
})
}
// GetCheckpointProgress returns all checkpoint progress for an assignment
// GET /sdk/v1/training/checkpoints/progress/:assignmentId
func (h *TrainingHandlers) GetCheckpointProgress(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
progress, err := h.store.ListCheckpointProgress(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"progress": progress,
"total": len(progress),
})
}