refactor(go): split academy_handlers, workshop_handlers, content_generator

- academy_handlers.go (1046 LOC) → academy_handlers.go (228) + academy_enrollment_handlers.go (320) + academy_generation_handlers.go (472)
- workshop_handlers.go (923 LOC) → workshop_handlers.go (292) + workshop_interaction_handlers.go (452) + workshop_export_handlers.go (196)
- content_generator.go (978 LOC) → content_generator.go (491) + content_generator_media.go (497)

All files under 500 LOC hard cap. Zero behavior changes, no exported symbol renames. Both packages vet clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-19 09:44:07 +02:00
parent a83056b5e7
commit e0b3c54212
8 changed files with 2127 additions and 2126 deletions

View File

@@ -0,0 +1,320 @@
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/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Enrollment Management
// ============================================================================
// CreateEnrollment enrolls a user in a course
// POST /sdk/v1/academy/enrollments
func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) {
var req academy.EnrollUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
// Verify course exists
course, err := h.store.GetCourse(c.Request.Context(), req.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
enrollment := &academy.Enrollment{
TenantID: tenantID,
CourseID: req.CourseID,
UserID: req.UserID,
UserName: req.UserName,
UserEmail: req.UserEmail,
Status: academy.EnrollmentStatusNotStarted,
Deadline: req.Deadline,
}
if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment})
}
// ListEnrollments lists enrollments for the current tenant
// GET /sdk/v1/academy/enrollments
func (h *AcademyHandlers) ListEnrollments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.EnrollmentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = academy.EnrollmentStatus(status)
}
if courseIDStr := c.Query("course_id"); courseIDStr != "" {
if courseID, err := uuid.Parse(courseIDStr); err == nil {
filters.CourseID = &courseID
}
}
if userIDStr := c.Query("user_id"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
filters.UserID = &userID
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.EnrollmentListResponse{
Enrollments: enrollments,
Total: total,
})
}
// UpdateProgress updates an enrollment's progress
// PUT /sdk/v1/academy/enrollments/:id/progress
func (h *AcademyHandlers) UpdateProgress(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
var req academy.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Progress < 0 || req.Progress > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"})
return
}
if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enrollment": updated})
}
// CompleteEnrollment marks an enrollment as completed
// POST /sdk/v1/academy/enrollments/:id/complete
func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status == academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"})
return
}
if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"enrollment": updated,
"message": "enrollment completed",
})
}
// ============================================================================
// Certificate Management
// ============================================================================
// GetCertificate retrieves a certificate
// GET /sdk/v1/academy/certificates/:id
func (h *AcademyHandlers) GetCertificate(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
c.JSON(http.StatusOK, gin.H{"certificate": cert})
}
// GenerateCertificate generates a certificate for a completed enrollment
// POST /sdk/v1/academy/enrollments/:id/certificate
func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status != academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"})
return
}
// Check if certificate already exists
existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if existing != nil {
c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"})
return
}
// Get the course for the certificate title
course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
courseTitle := "Unknown Course"
if course != nil {
courseTitle = course.Title
}
// Certificate is valid for 1 year by default
validUntil := time.Now().UTC().AddDate(1, 0, 0)
cert := &academy.Certificate{
EnrollmentID: enrollmentID,
UserName: enrollment.UserName,
CourseTitle: courseTitle,
ValidUntil: &validUntil,
}
if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"certificate": cert})
}
// DownloadCertificatePDF generates and downloads a certificate as PDF
// GET /sdk/v1/academy/certificates/:id/pdf
func (h *AcademyHandlers) DownloadCertificatePDF(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
validUntil := time.Now().UTC().AddDate(1, 0, 0)
if cert.ValidUntil != nil {
validUntil = *cert.ValidUntil
}
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
CertificateID: cert.ID.String(),
UserName: cert.UserName,
CourseName: cert.CourseTitle,
IssuedAt: cert.IssuedAt,
ValidUntil: validUntil,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PDF: " + err.Error()})
return
}
shortID := cert.ID.String()[:8]
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf")
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}

View File

@@ -0,0 +1,472 @@
package handlers
import (
"fmt"
"net/http"
"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"
"github.com/google/uuid"
)
// ============================================================================
// Quiz Submission
// ============================================================================
// SubmitQuiz submits quiz answers and returns the results
// POST /sdk/v1/academy/enrollments/:id/quiz
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
var req academy.SubmitQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify enrollment exists
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
// Get the lesson with quiz questions
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
// Grade the quiz
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
c.JSON(http.StatusOK, academy.SubmitQuizResponse{
Score: score,
Passed: score >= 70,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
})
}
// ============================================================================
// Lesson Update
// ============================================================================
// UpdateLesson updates a lesson's content, title, or quiz questions
// PUT /sdk/v1/academy/lessons/:id
func (h *AcademyHandlers) UpdateLesson(c *gin.Context) {
lessonID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
return
}
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
var req struct {
Title *string `json:"title"`
Description *string `json:"description"`
ContentURL *string `json:"content_url"`
DurationMinutes *int `json:"duration_minutes"`
QuizQuestions *[]academy.QuizQuestion `json:"quiz_questions"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
lesson.Title = *req.Title
}
if req.Description != nil {
lesson.Description = *req.Description
}
if req.ContentURL != nil {
lesson.ContentURL = *req.ContentURL
}
if req.DurationMinutes != nil {
lesson.DurationMinutes = *req.DurationMinutes
}
if req.QuizQuestions != nil {
lesson.QuizQuestions = *req.QuizQuestions
}
if err := h.store.UpdateLesson(c.Request.Context(), lesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"lesson": lesson})
}
// TestQuiz evaluates quiz answers without requiring an enrollment
// POST /sdk/v1/academy/lessons/:id/quiz-test
func (h *AcademyHandlers) TestQuiz(c *gin.Context) {
lessonID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
return
}
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
var req struct {
Answers []int `json:"answers"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
c.JSON(http.StatusOK, academy.SubmitQuizResponse{
Score: score,
Passed: score >= 70,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns academy statistics for the current tenant
// GET /sdk/v1/academy/statistics
func (h *AcademyHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ============================================================================
// Course Generation from Training Modules
// ============================================================================
// regulationToCategory maps training regulation areas to academy categories
var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{
training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics,
training.RegulationNIS2: academy.CourseCategoryITSecurity,
training.RegulationISO27001: academy.CourseCategoryITSecurity,
training.RegulationAIAct: academy.CourseCategoryAILiteracy,
training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection,
training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection,
}
// GenerateCourseFromTraining creates an academy course from a training module
// POST /sdk/v1/academy/courses/generate
func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) {
if h.trainingStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
return
}
var req struct {
ModuleID string `json:"module_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
moduleID, err := uuid.Parse(req.ModuleID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"})
return
}
tenantID := rbac.GetTenantID(c)
// 1. Get the training module
module, err := h.trainingStore.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": "training module not found"})
return
}
// If module already linked to an academy course, return that
if module.AcademyCourseID != nil {
existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID)
if err == nil && existing != nil {
c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"})
return
}
}
// 2. Get generated content (if any)
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID)
// 3. Get quiz questions (if any)
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID)
// 4. Determine academy category from regulation area
category, ok := regulationToCategory[module.RegulationArea]
if !ok {
category = academy.CourseCategoryCustom
}
// 5. Build lessons from content + quiz
lessons := buildModuleLessons(*module, content, quizQuestions)
// 6. Create the academy course
course := &academy.Course{
TenantID: tenantID,
Title: module.Title,
Description: module.Description,
Category: category,
DurationMinutes: module.DurationMinutes,
RequiredForRoles: []string{},
IsActive: true,
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()})
return
}
// 7. Create lessons
for i := range lessons {
lessons[i].CourseID = course.ID
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()})
return
}
}
course.Lessons = lessons
// 8. Link training module to academy course
if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil {
// Non-fatal: course is created, just not linked
fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err)
}
c.JSON(http.StatusCreated, gin.H{"course": course})
}
// GenerateAllCourses creates academy courses for all training modules that don't have one yet
// POST /sdk/v1/academy/courses/generate-all
func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) {
if h.trainingStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
return
}
tenantID := rbac.GetTenantID(c)
// Get all training modules
modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
generated := 0
skipped := 0
var errors []string
for _, module := range modules {
// Skip if already linked
if module.AcademyCourseID != nil {
skipped++
continue
}
// Get content and quiz
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID)
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID)
category, ok := regulationToCategory[module.RegulationArea]
if !ok {
category = academy.CourseCategoryCustom
}
lessons := buildModuleLessons(module, content, quizQuestions)
course := &academy.Course{
TenantID: tenantID,
Title: module.Title,
Description: module.Description,
Category: category,
DurationMinutes: module.DurationMinutes,
RequiredForRoles: []string{},
IsActive: true,
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
for i := range lessons {
lessons[i].CourseID = course.ID
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err))
}
}
_ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID)
generated++
}
c.JSON(http.StatusOK, gin.H{
"generated": generated,
"skipped": skipped,
"errors": errors,
"total": len(modules),
})
}
// buildModuleLessons constructs academy lessons from a training module, its content, and quiz questions.
func buildModuleLessons(module training.TrainingModule, content *training.ModuleContent, quizQuestions []training.QuizQuestion) []academy.Lesson {
var lessons []academy.Lesson
orderIdx := 0
// Lesson 1: Text content (if generated)
if content != nil && content.ContentBody != "" {
lessons = append(lessons, academy.Lesson{
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
Description: content.Summary,
LessonType: academy.LessonTypeText,
ContentURL: content.ContentBody,
DurationMinutes: estimateReadingTime(content.ContentBody),
OrderIndex: orderIdx,
})
orderIdx++
}
// Lesson 2: Quiz (if questions exist)
if len(quizQuestions) > 0 {
var academyQuiz []academy.QuizQuestion
for _, q := range quizQuestions {
academyQuiz = append(academyQuiz, academy.QuizQuestion{
Question: q.Question,
Options: q.Options,
CorrectIndex: q.CorrectIndex,
Explanation: q.Explanation,
})
}
lessons = append(lessons, academy.Lesson{
Title: fmt.Sprintf("%s - Quiz", module.Title),
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
LessonType: academy.LessonTypeQuiz,
DurationMinutes: len(quizQuestions) * 2, // ~2 min per question
OrderIndex: orderIdx,
QuizQuestions: academyQuiz,
})
}
// If no content or quiz exists, create a placeholder
if len(lessons) == 0 {
lessons = append(lessons, academy.Lesson{
Title: module.Title,
Description: module.Description,
LessonType: academy.LessonTypeText,
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
DurationMinutes: module.DurationMinutes,
OrderIndex: 0,
})
}
return lessons
}

View File

@@ -1,11 +1,9 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy" "github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac" "github.com/breakpilot/ai-compliance-sdk/internal/rbac"
@@ -218,822 +216,6 @@ func (h *AcademyHandlers) DeleteCourse(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "course deleted"}) c.JSON(http.StatusOK, gin.H{"message": "course deleted"})
} }
// ============================================================================
// Enrollment Management
// ============================================================================
// CreateEnrollment enrolls a user in a course
// POST /sdk/v1/academy/enrollments
func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) {
var req academy.EnrollUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
// Verify course exists
course, err := h.store.GetCourse(c.Request.Context(), req.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
enrollment := &academy.Enrollment{
TenantID: tenantID,
CourseID: req.CourseID,
UserID: req.UserID,
UserName: req.UserName,
UserEmail: req.UserEmail,
Status: academy.EnrollmentStatusNotStarted,
Deadline: req.Deadline,
}
if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment})
}
// ListEnrollments lists enrollments for the current tenant
// GET /sdk/v1/academy/enrollments
func (h *AcademyHandlers) ListEnrollments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.EnrollmentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = academy.EnrollmentStatus(status)
}
if courseIDStr := c.Query("course_id"); courseIDStr != "" {
if courseID, err := uuid.Parse(courseIDStr); err == nil {
filters.CourseID = &courseID
}
}
if userIDStr := c.Query("user_id"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
filters.UserID = &userID
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.EnrollmentListResponse{
Enrollments: enrollments,
Total: total,
})
}
// UpdateProgress updates an enrollment's progress
// PUT /sdk/v1/academy/enrollments/:id/progress
func (h *AcademyHandlers) UpdateProgress(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
var req academy.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Progress < 0 || req.Progress > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"})
return
}
if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enrollment": updated})
}
// CompleteEnrollment marks an enrollment as completed
// POST /sdk/v1/academy/enrollments/:id/complete
func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status == academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"})
return
}
if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"enrollment": updated,
"message": "enrollment completed",
})
}
// ============================================================================
// Certificate Management
// ============================================================================
// GetCertificate retrieves a certificate
// GET /sdk/v1/academy/certificates/:id
func (h *AcademyHandlers) GetCertificate(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
c.JSON(http.StatusOK, gin.H{"certificate": cert})
}
// GenerateCertificate generates a certificate for a completed enrollment
// POST /sdk/v1/academy/enrollments/:id/certificate
func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status != academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"})
return
}
// Check if certificate already exists
existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if existing != nil {
c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"})
return
}
// Get the course for the certificate title
course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
courseTitle := "Unknown Course"
if course != nil {
courseTitle = course.Title
}
// Certificate is valid for 1 year by default
validUntil := time.Now().UTC().AddDate(1, 0, 0)
cert := &academy.Certificate{
EnrollmentID: enrollmentID,
UserName: enrollment.UserName,
CourseTitle: courseTitle,
ValidUntil: &validUntil,
}
if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"certificate": cert})
}
// ============================================================================
// Quiz Submission
// ============================================================================
// SubmitQuiz submits quiz answers and returns the results
// POST /sdk/v1/academy/enrollments/:id/quiz
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
var req academy.SubmitQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify enrollment exists
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
// Get the lesson with quiz questions
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
// Grade the quiz
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
// Pass threshold: 70%
passed := score >= 70
response := academy.SubmitQuizResponse{
Score: score,
Passed: passed,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
}
c.JSON(http.StatusOK, response)
}
// ============================================================================
// Lesson Update
// ============================================================================
// UpdateLesson updates a lesson's content, title, or quiz questions
// PUT /sdk/v1/academy/lessons/:id
func (h *AcademyHandlers) UpdateLesson(c *gin.Context) {
lessonID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
return
}
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
var req struct {
Title *string `json:"title"`
Description *string `json:"description"`
ContentURL *string `json:"content_url"`
DurationMinutes *int `json:"duration_minutes"`
QuizQuestions *[]academy.QuizQuestion `json:"quiz_questions"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
lesson.Title = *req.Title
}
if req.Description != nil {
lesson.Description = *req.Description
}
if req.ContentURL != nil {
lesson.ContentURL = *req.ContentURL
}
if req.DurationMinutes != nil {
lesson.DurationMinutes = *req.DurationMinutes
}
if req.QuizQuestions != nil {
lesson.QuizQuestions = *req.QuizQuestions
}
if err := h.store.UpdateLesson(c.Request.Context(), lesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"lesson": lesson})
}
// TestQuiz evaluates quiz answers without requiring an enrollment
// POST /sdk/v1/academy/lessons/:id/quiz-test
func (h *AcademyHandlers) TestQuiz(c *gin.Context) {
lessonID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
return
}
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
var req struct {
Answers []int `json:"answers"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
c.JSON(http.StatusOK, academy.SubmitQuizResponse{
Score: score,
Passed: score >= 70,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns academy statistics for the current tenant
// GET /sdk/v1/academy/statistics
func (h *AcademyHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ============================================================================
// Certificate PDF Download
// ============================================================================
// DownloadCertificatePDF generates and downloads a certificate as PDF
// GET /sdk/v1/academy/certificates/:id/pdf
func (h *AcademyHandlers) DownloadCertificatePDF(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
validUntil := time.Now().UTC().AddDate(1, 0, 0)
if cert.ValidUntil != nil {
validUntil = *cert.ValidUntil
}
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
CertificateID: cert.ID.String(),
UserName: cert.UserName,
CourseName: cert.CourseTitle,
IssuedAt: cert.IssuedAt,
ValidUntil: validUntil,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PDF: " + err.Error()})
return
}
shortID := cert.ID.String()[:8]
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf")
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}
// ============================================================================
// Course Generation from Training Modules
// ============================================================================
// regulationToCategory maps training regulation areas to academy categories
var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{
training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics,
training.RegulationNIS2: academy.CourseCategoryITSecurity,
training.RegulationISO27001: academy.CourseCategoryITSecurity,
training.RegulationAIAct: academy.CourseCategoryAILiteracy,
training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection,
training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection,
}
// GenerateCourseFromTraining creates an academy course from a training module
// POST /sdk/v1/academy/courses/generate
func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) {
if h.trainingStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
return
}
var req struct {
ModuleID string `json:"module_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
moduleID, err := uuid.Parse(req.ModuleID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"})
return
}
tenantID := rbac.GetTenantID(c)
// 1. Get the training module
module, err := h.trainingStore.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": "training module not found"})
return
}
// If module already linked to an academy course, return that
if module.AcademyCourseID != nil {
existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID)
if err == nil && existing != nil {
c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"})
return
}
}
// 2. Get generated content (if any)
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID)
// 3. Get quiz questions (if any)
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID)
// 4. Determine academy category from regulation area
category, ok := regulationToCategory[module.RegulationArea]
if !ok {
category = academy.CourseCategoryCustom
}
// 5. Build lessons from content + quiz
var lessons []academy.Lesson
orderIdx := 0
// Lesson 1: Text content (if generated)
if content != nil && content.ContentBody != "" {
lessons = append(lessons, academy.Lesson{
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
Description: content.Summary,
LessonType: academy.LessonTypeText,
ContentURL: content.ContentBody, // Store markdown in content_url for text lessons
DurationMinutes: estimateReadingTime(content.ContentBody),
OrderIndex: orderIdx,
})
orderIdx++
}
// Lesson 2: Quiz (if questions exist)
if len(quizQuestions) > 0 {
var academyQuiz []academy.QuizQuestion
for _, q := range quizQuestions {
academyQuiz = append(academyQuiz, academy.QuizQuestion{
Question: q.Question,
Options: q.Options,
CorrectIndex: q.CorrectIndex,
Explanation: q.Explanation,
})
}
lessons = append(lessons, academy.Lesson{
Title: fmt.Sprintf("%s - Quiz", module.Title),
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
LessonType: academy.LessonTypeQuiz,
DurationMinutes: len(quizQuestions) * 2, // ~2 min per question
OrderIndex: orderIdx,
QuizQuestions: academyQuiz,
})
orderIdx++
}
// If no content or quiz exists, create a placeholder
if len(lessons) == 0 {
lessons = append(lessons, academy.Lesson{
Title: module.Title,
Description: module.Description,
LessonType: academy.LessonTypeText,
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
DurationMinutes: module.DurationMinutes,
OrderIndex: 0,
})
}
// 6. Create the academy course
course := &academy.Course{
TenantID: tenantID,
Title: module.Title,
Description: module.Description,
Category: category,
DurationMinutes: module.DurationMinutes,
RequiredForRoles: []string{},
IsActive: true,
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()})
return
}
// 7. Create lessons
for i := range lessons {
lessons[i].CourseID = course.ID
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()})
return
}
}
course.Lessons = lessons
// 8. Link training module to academy course
if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil {
// Non-fatal: course is created, just not linked
fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err)
}
c.JSON(http.StatusCreated, gin.H{"course": course})
}
// GenerateAllCourses creates academy courses for all training modules that don't have one yet
// POST /sdk/v1/academy/courses/generate-all
func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) {
if h.trainingStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
return
}
tenantID := rbac.GetTenantID(c)
// Get all training modules
modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
generated := 0
skipped := 0
var errors []string
for _, module := range modules {
// Skip if already linked
if module.AcademyCourseID != nil {
skipped++
continue
}
// Get content and quiz
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID)
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID)
category, ok := regulationToCategory[module.RegulationArea]
if !ok {
category = academy.CourseCategoryCustom
}
var lessons []academy.Lesson
orderIdx := 0
if content != nil && content.ContentBody != "" {
lessons = append(lessons, academy.Lesson{
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
Description: content.Summary,
LessonType: academy.LessonTypeText,
ContentURL: content.ContentBody,
DurationMinutes: estimateReadingTime(content.ContentBody),
OrderIndex: orderIdx,
})
orderIdx++
}
if len(quizQuestions) > 0 {
var academyQuiz []academy.QuizQuestion
for _, q := range quizQuestions {
academyQuiz = append(academyQuiz, academy.QuizQuestion{
Question: q.Question,
Options: q.Options,
CorrectIndex: q.CorrectIndex,
Explanation: q.Explanation,
})
}
lessons = append(lessons, academy.Lesson{
Title: fmt.Sprintf("%s - Quiz", module.Title),
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
LessonType: academy.LessonTypeQuiz,
DurationMinutes: len(quizQuestions) * 2,
OrderIndex: orderIdx,
QuizQuestions: academyQuiz,
})
orderIdx++
}
if len(lessons) == 0 {
lessons = append(lessons, academy.Lesson{
Title: module.Title,
Description: module.Description,
LessonType: academy.LessonTypeText,
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
DurationMinutes: module.DurationMinutes,
OrderIndex: 0,
})
}
course := &academy.Course{
TenantID: tenantID,
Title: module.Title,
Description: module.Description,
Category: category,
DurationMinutes: module.DurationMinutes,
RequiredForRoles: []string{},
IsActive: true,
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
for i := range lessons {
lessons[i].CourseID = course.ID
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err))
}
}
_ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID)
generated++
}
c.JSON(http.StatusOK, gin.H{
"generated": generated,
"skipped": skipped,
"errors": errors,
"total": len(modules),
})
}
// estimateReadingTime estimates reading time in minutes from markdown content // estimateReadingTime estimates reading time in minutes from markdown content
// Average reading speed: ~200 words per minute // Average reading speed: ~200 words per minute
func estimateReadingTime(content string) int { func estimateReadingTime(content string) int {

View File

@@ -0,0 +1,196 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Statistics
// ============================================================================
// GetSessionStats returns statistics for a session
// GET /sdk/v1/workshops/:id/stats
func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
stats, err := h.store.GetSessionStats(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetSessionSummary returns a complete summary of a session
// GET /sdk/v1/workshops/:id/summary
func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summary == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
c.JSON(http.StatusOK, summary)
}
// ============================================================================
// Export
// ============================================================================
// ExportSession exports session data
// GET /sdk/v1/workshops/:id/export
func (h *WorkshopHandlers) ExportSession(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
format := c.DefaultQuery("format", "json")
// Get complete session data
summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summary == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// Get all responses
responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil)
// Get all comments
comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil)
// Get stats
stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID)
exportData := gin.H{
"session": summary.Session,
"participants": summary.Participants,
"step_progress": summary.StepProgress,
"responses": responses,
"comments": comments,
"stats": stats,
"exported_at": time.Now().UTC(),
}
switch format {
case "json":
c.JSON(http.StatusOK, exportData)
case "md":
// Generate markdown format
md := generateSessionMarkdown(summary, responses, comments, stats)
c.Header("Content-Type", "text/markdown")
c.Header("Content-Disposition", "attachment; filename=workshop-session.md")
c.String(http.StatusOK, md)
default:
c.JSON(http.StatusOK, exportData)
}
}
// generateSessionMarkdown generates a markdown export of the session
func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string {
md := "# Workshop Session: " + summary.Session.Title + "\n\n"
md += "**Type:** " + summary.Session.SessionType + "\n"
md += "**Status:** " + string(summary.Session.Status) + "\n"
md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n"
if summary.Session.Description != "" {
md += "## Description\n\n" + summary.Session.Description + "\n\n"
}
// Participants
md += "## Participants\n\n"
for _, p := range summary.Participants {
md += "- **" + p.Name + "** (" + string(p.Role) + ")"
if p.Department != "" {
md += " - " + p.Department
}
md += "\n"
}
md += "\n"
// Progress
md += "## Progress\n\n"
md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n"
md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n"
md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n"
// Step progress
if len(summary.StepProgress) > 0 {
md += "### Step Progress\n\n"
for _, sp := range summary.StepProgress {
md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n"
}
md += "\n"
}
// Responses by step
if len(responses) > 0 {
md += "## Responses\n\n"
currentStep := 0
for _, r := range responses {
if r.StepNumber != currentStep {
currentStep = r.StepNumber
md += "### Step " + strconv.Itoa(currentStep) + "\n\n"
}
md += "- **" + r.FieldID + ":** "
switch v := r.Value.(type) {
case string:
md += v
case bool:
if v {
md += "Yes"
} else {
md += "No"
}
default:
md += "See JSON export for complex value"
}
md += "\n"
}
md += "\n"
}
// Comments
if len(comments) > 0 {
md += "## Comments\n\n"
for _, c := range comments {
md += "- " + c.Text
if c.StepNumber != nil {
md += " (Step " + strconv.Itoa(*c.StepNumber) + ")"
}
md += "\n"
}
md += "\n"
}
md += "---\n*Exported from AI Compliance SDK Workshop Module*\n"
return md
}

View File

@@ -2,7 +2,6 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac" "github.com/breakpilot/ai-compliance-sdk/internal/rbac"
@@ -291,633 +290,3 @@ func (h *WorkshopHandlers) CompleteSession(c *gin.Context) {
"summary": summary, "summary": summary,
}) })
} }
// ============================================================================
// Participant Management
// ============================================================================
// JoinSession allows a participant to join a session
// POST /sdk/v1/workshops/join/:code
func (h *WorkshopHandlers) JoinSession(c *gin.Context) {
code := c.Param("code")
session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled {
c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"})
return
}
var req workshop.JoinSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID if authenticated
var userID *uuid.UUID
if id := rbac.GetUserID(c); id != uuid.Nil {
userID = &id
}
// Check if authentication is required
if session.RequireAuth && userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"})
return
}
participant := &workshop.Participant{
SessionID: session.ID,
UserID: userID,
Name: req.Name,
Email: req.Email,
Role: req.Role,
Department: req.Department,
CanEdit: true,
CanComment: true,
}
if participant.Role == "" {
participant.Role = workshop.ParticipantRoleStakeholder
}
if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, workshop.JoinSessionResponse{
Participant: *participant,
Session: *session,
})
}
// ListParticipants lists participants in a session
// GET /sdk/v1/workshops/:id/participants
func (h *WorkshopHandlers) ListParticipants(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
participants, err := h.store.ListParticipants(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"participants": participants,
"total": len(participants),
})
}
// LeaveSession removes a participant from a session
// POST /sdk/v1/workshops/:id/leave
func (h *WorkshopHandlers) LeaveSession(c *gin.Context) {
var req struct {
ParticipantID uuid.UUID `json:"participant_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "left session"})
}
// ============================================================================
// Wizard Navigation & Responses
// ============================================================================
// SubmitResponse submits a response to a question
// POST /sdk/v1/workshops/:id/responses
func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var req workshop.SubmitResponseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get participant ID from request or context
participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"})
return
}
// Determine value type
valueType := "string"
switch req.Value.(type) {
case bool:
valueType = "boolean"
case float64:
valueType = "number"
case []interface{}:
valueType = "array"
case map[string]interface{}:
valueType = "object"
}
response := &workshop.Response{
SessionID: sessionID,
ParticipantID: participantID,
StepNumber: req.StepNumber,
FieldID: req.FieldID,
Value: req.Value,
ValueType: valueType,
Status: workshop.ResponseStatusSubmitted,
}
if err := h.store.SaveResponse(c.Request.Context(), response); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update participant activity
h.store.UpdateParticipantActivity(c.Request.Context(), participantID)
c.JSON(http.StatusOK, gin.H{"response": response})
}
// GetResponses retrieves responses for a session
// GET /sdk/v1/workshops/:id/responses
func (h *WorkshopHandlers) GetResponses(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var stepNumber *int
if step := c.Query("step"); step != "" {
if s, err := strconv.Atoi(step); err == nil {
stepNumber = &s
}
}
responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"responses": responses,
"total": len(responses),
})
}
// AdvanceStep moves the session to the next step
// POST /sdk/v1/workshops/:id/advance
func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
session, err := h.store.GetSession(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if session.CurrentStep >= session.TotalSteps {
c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"})
return
}
// Mark current step as completed
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100)
// Advance to next step
if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Initialize next step
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0)
c.JSON(http.StatusOK, gin.H{
"previous_step": session.CurrentStep,
"current_step": session.CurrentStep + 1,
"message": "advanced to next step",
})
}
// GoToStep navigates to a specific step (if allowed)
// POST /sdk/v1/workshops/:id/goto
func (h *WorkshopHandlers) GoToStep(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var req struct {
StepNumber int `json:"step_number"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, err := h.store.GetSession(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// Check if back navigation is allowed
if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation {
c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"})
return
}
// Validate step number
if req.StepNumber < 1 || req.StepNumber > session.TotalSteps {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"})
return
}
session.CurrentStep = req.StepNumber
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"current_step": req.StepNumber,
"message": "navigated to step",
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetSessionStats returns statistics for a session
// GET /sdk/v1/workshops/:id/stats
func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
stats, err := h.store.GetSessionStats(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetSessionSummary returns a complete summary of a session
// GET /sdk/v1/workshops/:id/summary
func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summary == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
c.JSON(http.StatusOK, summary)
}
// ============================================================================
// Participant Management (Extended)
// ============================================================================
// UpdateParticipant updates a participant's info
// PUT /sdk/v1/workshops/:id/participants/:participantId
func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) {
participantID, err := uuid.Parse(c.Param("participantId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
return
}
var req struct {
Name string `json:"name"`
Role workshop.ParticipantRole `json:"role"`
Department string `json:"department"`
CanEdit *bool `json:"can_edit,omitempty"`
CanComment *bool `json:"can_comment,omitempty"`
CanApprove *bool `json:"can_approve,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
participant, err := h.store.GetParticipant(c.Request.Context(), participantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if participant == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if req.Name != "" {
participant.Name = req.Name
}
if req.Role != "" {
participant.Role = req.Role
}
if req.Department != "" {
participant.Department = req.Department
}
if req.CanEdit != nil {
participant.CanEdit = *req.CanEdit
}
if req.CanComment != nil {
participant.CanComment = *req.CanComment
}
if req.CanApprove != nil {
participant.CanApprove = *req.CanApprove
}
if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"participant": participant})
}
// RemoveParticipant removes a participant from a session
// DELETE /sdk/v1/workshops/:id/participants/:participantId
func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) {
participantID, err := uuid.Parse(c.Param("participantId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
return
}
if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "participant removed"})
}
// ============================================================================
// Comments
// ============================================================================
// AddComment adds a comment to a session
// POST /sdk/v1/workshops/:id/comments
func (h *WorkshopHandlers) AddComment(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var req struct {
ParticipantID uuid.UUID `json:"participant_id"`
StepNumber *int `json:"step_number,omitempty"`
FieldID *string `json:"field_id,omitempty"`
ResponseID *uuid.UUID `json:"response_id,omitempty"`
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Text == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"})
return
}
comment := &workshop.Comment{
SessionID: sessionID,
ParticipantID: req.ParticipantID,
StepNumber: req.StepNumber,
FieldID: req.FieldID,
ResponseID: req.ResponseID,
Text: req.Text,
}
if err := h.store.AddComment(c.Request.Context(), comment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"comment": comment})
}
// GetComments retrieves comments for a session
// GET /sdk/v1/workshops/:id/comments
func (h *WorkshopHandlers) GetComments(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var stepNumber *int
if step := c.Query("step"); step != "" {
if s, err := strconv.Atoi(step); err == nil {
stepNumber = &s
}
}
comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"comments": comments,
"total": len(comments),
})
}
// ============================================================================
// Export
// ============================================================================
// ExportSession exports session data
// GET /sdk/v1/workshops/:id/export
func (h *WorkshopHandlers) ExportSession(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
format := c.DefaultQuery("format", "json")
// Get complete session data
summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if summary == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// Get all responses
responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil)
// Get all comments
comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil)
// Get stats
stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID)
exportData := gin.H{
"session": summary.Session,
"participants": summary.Participants,
"step_progress": summary.StepProgress,
"responses": responses,
"comments": comments,
"stats": stats,
"exported_at": time.Now().UTC(),
}
switch format {
case "json":
c.JSON(http.StatusOK, exportData)
case "md":
// Generate markdown format
md := generateSessionMarkdown(summary, responses, comments, stats)
c.Header("Content-Type", "text/markdown")
c.Header("Content-Disposition", "attachment; filename=workshop-session.md")
c.String(http.StatusOK, md)
default:
c.JSON(http.StatusOK, exportData)
}
}
// generateSessionMarkdown generates a markdown export of the session
func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string {
md := "# Workshop Session: " + summary.Session.Title + "\n\n"
md += "**Type:** " + summary.Session.SessionType + "\n"
md += "**Status:** " + string(summary.Session.Status) + "\n"
md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n"
if summary.Session.Description != "" {
md += "## Description\n\n" + summary.Session.Description + "\n\n"
}
// Participants
md += "## Participants\n\n"
for _, p := range summary.Participants {
md += "- **" + p.Name + "** (" + string(p.Role) + ")"
if p.Department != "" {
md += " - " + p.Department
}
md += "\n"
}
md += "\n"
// Progress
md += "## Progress\n\n"
md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n"
md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n"
md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n"
// Step progress
if len(summary.StepProgress) > 0 {
md += "### Step Progress\n\n"
for _, sp := range summary.StepProgress {
md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n"
}
md += "\n"
}
// Responses by step
if len(responses) > 0 {
md += "## Responses\n\n"
currentStep := 0
for _, r := range responses {
if r.StepNumber != currentStep {
currentStep = r.StepNumber
md += "### Step " + strconv.Itoa(currentStep) + "\n\n"
}
md += "- **" + r.FieldID + ":** "
switch v := r.Value.(type) {
case string:
md += v
case bool:
if v {
md += "Yes"
} else {
md += "No"
}
default:
md += "See JSON export for complex value"
}
md += "\n"
}
md += "\n"
}
// Comments
if len(comments) > 0 {
md += "## Comments\n\n"
for _, c := range comments {
md += "- " + c.Text
if c.StepNumber != nil {
md += " (Step " + strconv.Itoa(*c.StepNumber) + ")"
}
md += "\n"
}
md += "\n"
}
md += "---\n*Exported from AI Compliance SDK Workshop Module*\n"
return md
}

View File

@@ -0,0 +1,452 @@
package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ============================================================================
// Participant Management
// ============================================================================
// JoinSession allows a participant to join a session
// POST /sdk/v1/workshops/join/:code
func (h *WorkshopHandlers) JoinSession(c *gin.Context) {
code := c.Param("code")
session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled {
c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"})
return
}
var req workshop.JoinSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID if authenticated
var userID *uuid.UUID
if id := rbac.GetUserID(c); id != uuid.Nil {
userID = &id
}
// Check if authentication is required
if session.RequireAuth && userID == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"})
return
}
participant := &workshop.Participant{
SessionID: session.ID,
UserID: userID,
Name: req.Name,
Email: req.Email,
Role: req.Role,
Department: req.Department,
CanEdit: true,
CanComment: true,
}
if participant.Role == "" {
participant.Role = workshop.ParticipantRoleStakeholder
}
if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, workshop.JoinSessionResponse{
Participant: *participant,
Session: *session,
})
}
// ListParticipants lists participants in a session
// GET /sdk/v1/workshops/:id/participants
func (h *WorkshopHandlers) ListParticipants(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
participants, err := h.store.ListParticipants(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"participants": participants,
"total": len(participants),
})
}
// LeaveSession removes a participant from a session
// POST /sdk/v1/workshops/:id/leave
func (h *WorkshopHandlers) LeaveSession(c *gin.Context) {
var req struct {
ParticipantID uuid.UUID `json:"participant_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "left session"})
}
// UpdateParticipant updates a participant's info
// PUT /sdk/v1/workshops/:id/participants/:participantId
func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) {
participantID, err := uuid.Parse(c.Param("participantId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
return
}
var req struct {
Name string `json:"name"`
Role workshop.ParticipantRole `json:"role"`
Department string `json:"department"`
CanEdit *bool `json:"can_edit,omitempty"`
CanComment *bool `json:"can_comment,omitempty"`
CanApprove *bool `json:"can_approve,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
participant, err := h.store.GetParticipant(c.Request.Context(), participantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if participant == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if req.Name != "" {
participant.Name = req.Name
}
if req.Role != "" {
participant.Role = req.Role
}
if req.Department != "" {
participant.Department = req.Department
}
if req.CanEdit != nil {
participant.CanEdit = *req.CanEdit
}
if req.CanComment != nil {
participant.CanComment = *req.CanComment
}
if req.CanApprove != nil {
participant.CanApprove = *req.CanApprove
}
if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"participant": participant})
}
// RemoveParticipant removes a participant from a session
// DELETE /sdk/v1/workshops/:id/participants/:participantId
func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) {
participantID, err := uuid.Parse(c.Param("participantId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
return
}
if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "participant removed"})
}
// ============================================================================
// Wizard Navigation & Responses
// ============================================================================
// SubmitResponse submits a response to a question
// POST /sdk/v1/workshops/:id/responses
func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var req workshop.SubmitResponseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get participant ID from request or context
participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"})
return
}
// Determine value type
valueType := "string"
switch req.Value.(type) {
case bool:
valueType = "boolean"
case float64:
valueType = "number"
case []interface{}:
valueType = "array"
case map[string]interface{}:
valueType = "object"
}
response := &workshop.Response{
SessionID: sessionID,
ParticipantID: participantID,
StepNumber: req.StepNumber,
FieldID: req.FieldID,
Value: req.Value,
ValueType: valueType,
Status: workshop.ResponseStatusSubmitted,
}
if err := h.store.SaveResponse(c.Request.Context(), response); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update participant activity
h.store.UpdateParticipantActivity(c.Request.Context(), participantID)
c.JSON(http.StatusOK, gin.H{"response": response})
}
// GetResponses retrieves responses for a session
// GET /sdk/v1/workshops/:id/responses
func (h *WorkshopHandlers) GetResponses(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var stepNumber *int
if step := c.Query("step"); step != "" {
if s, err := strconv.Atoi(step); err == nil {
stepNumber = &s
}
}
responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"responses": responses,
"total": len(responses),
})
}
// AdvanceStep moves the session to the next step
// POST /sdk/v1/workshops/:id/advance
func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
session, err := h.store.GetSession(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if session.CurrentStep >= session.TotalSteps {
c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"})
return
}
// Mark current step as completed
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100)
// Advance to next step
if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Initialize next step
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0)
c.JSON(http.StatusOK, gin.H{
"previous_step": session.CurrentStep,
"current_step": session.CurrentStep + 1,
"message": "advanced to next step",
})
}
// GoToStep navigates to a specific step (if allowed)
// POST /sdk/v1/workshops/:id/goto
func (h *WorkshopHandlers) GoToStep(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var req struct {
StepNumber int `json:"step_number"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, err := h.store.GetSession(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// Check if back navigation is allowed
if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation {
c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"})
return
}
// Validate step number
if req.StepNumber < 1 || req.StepNumber > session.TotalSteps {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"})
return
}
session.CurrentStep = req.StepNumber
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"current_step": req.StepNumber,
"message": "navigated to step",
})
}
// ============================================================================
// Comments
// ============================================================================
// AddComment adds a comment to a session
// POST /sdk/v1/workshops/:id/comments
func (h *WorkshopHandlers) AddComment(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var req struct {
ParticipantID uuid.UUID `json:"participant_id"`
StepNumber *int `json:"step_number,omitempty"`
FieldID *string `json:"field_id,omitempty"`
ResponseID *uuid.UUID `json:"response_id,omitempty"`
Text string `json:"text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Text == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"})
return
}
comment := &workshop.Comment{
SessionID: sessionID,
ParticipantID: req.ParticipantID,
StepNumber: req.StepNumber,
FieldID: req.FieldID,
ResponseID: req.ResponseID,
Text: req.Text,
}
if err := h.store.AddComment(c.Request.Context(), comment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"comment": comment})
}
// GetComments retrieves comments for a session
// GET /sdk/v1/workshops/:id/comments
func (h *WorkshopHandlers) GetComments(c *gin.Context) {
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
return
}
var stepNumber *int
if step := c.Query("step"); step != "" {
if s, err := strconv.Atoi(step); err == nil {
stepNumber = &s
}
}
comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"comments": comments,
"total": len(comments),
})
}

View File

@@ -85,12 +85,12 @@ func (g *ContentGenerator) GenerateModuleContent(ctx context.Context, module Tra
EntityType: AuditEntityModule, EntityType: AuditEntityModule,
EntityID: &module.ID, EntityID: &module.ID,
Details: map[string]interface{}{ Details: map[string]interface{}{
"module_code": module.ModuleCode, "module_code": module.ModuleCode,
"provider": resp.Provider, "provider": resp.Provider,
"model": resp.Model, "model": resp.Model,
"content_id": content.ID.String(), "content_id": content.ID.String(),
"version": content.Version, "version": content.Version,
"tokens_used": resp.Usage.TotalTokens, "tokens_used": resp.Usage.TotalTokens,
}, },
}) })
@@ -145,153 +145,66 @@ func (g *ContentGenerator) GenerateQuizQuestions(ctx context.Context, module Tra
return questions, nil return questions, nil
} }
// ============================================================================ // GenerateAllModuleContent generates text content for all modules that don't have published content yet
// Prompt Templates func (g *ContentGenerator) GenerateAllModuleContent(ctx context.Context, tenantID uuid.UUID, language string) (*BulkResult, error) {
// ============================================================================ if language == "" {
language = "de"
func getContentSystemPrompt(language string) string {
if language == "en" {
return "You are a compliance training content expert. Generate professional, accurate training material in Markdown format. Focus on practical relevance and legal accuracy. Do not include any personal data or fictional names."
}
return "Du bist ein Experte fuer Compliance-Schulungsinhalte. Erstelle professionelle, praezise Schulungsmaterialien im Markdown-Format. Fokussiere dich auf praktische Relevanz und rechtliche Genauigkeit. Verwende keine personenbezogenen Daten oder fiktiven Namen."
}
func getQuizSystemPrompt() string {
return `Du bist ein Experte fuer Compliance-Pruefungsfragen. Erstelle Multiple-Choice-Fragen als JSON-Array.
Jede Frage hat genau 4 Antwortoptionen, davon genau eine richtige.
Antworte NUR mit dem JSON-Array, ohne zusaetzlichen Text.
Format:
[
{
"question": "Frage hier?",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_index": 0,
"explanation": "Erklaerung warum Option A richtig ist.",
"difficulty": "medium"
}
]`
}
func buildContentPrompt(module TrainingModule, language string) string {
regulationLabels := map[RegulationArea]string{
RegulationDSGVO: "Datenschutz-Grundverordnung (DSGVO)",
RegulationNIS2: "NIS-2-Richtlinie",
RegulationISO27001: "ISO 27001 / ISMS",
RegulationAIAct: "EU AI Act / KI-Verordnung",
RegulationGeschGehG: "Geschaeftsgeheimnisgesetz (GeschGehG)",
RegulationHinSchG: "Hinweisgeberschutzgesetz (HinSchG)",
} }
regulation := regulationLabels[module.RegulationArea] modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
if regulation == "" { if err != nil {
regulation = string(module.RegulationArea) return nil, fmt.Errorf("failed to list modules: %w", err)
} }
return fmt.Sprintf(`Erstelle Schulungsmaterial fuer folgendes Compliance-Modul: result := &BulkResult{}
for _, module := range modules {
**Modulcode:** %s // Check if module already has published content
**Titel:** %s content, _ := g.store.GetPublishedContent(ctx, module.ID)
**Beschreibung:** %s if content != nil {
**Regulierungsbereich:** %s result.Skipped++
**Dauer:** %d Minuten
**NIS2-relevant:** %v
Das Material soll:
1. Eine kurze Einfuehrung in das Thema geben
2. Die wichtigsten rechtlichen Grundlagen erklaeren
3. Praktische Handlungsanweisungen fuer den Arbeitsalltag enthalten
4. Typische Fehler und Risiken aufzeigen
5. Eine Zusammenfassung der Kernpunkte bieten
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`,
module.ModuleCode, module.Title, module.Description,
regulation, module.DurationMinutes, module.NIS2Relevant)
}
func buildQuizPrompt(module TrainingModule, contentContext string, count int) string {
prompt := fmt.Sprintf(`Erstelle %d Multiple-Choice-Pruefungsfragen fuer das Compliance-Modul:
**Modulcode:** %s
**Titel:** %s
**Regulierungsbereich:** %s`, count, module.ModuleCode, module.Title, string(module.RegulationArea))
if contentContext != "" {
// Truncate content to avoid token limit
if len(contentContext) > 3000 {
contentContext = contentContext[:3000] + "..."
}
prompt += fmt.Sprintf(`
**Schulungsinhalt als Kontext:**
%s`, contentContext)
}
prompt += fmt.Sprintf(`
Erstelle genau %d Fragen mit je 4 Antwortoptionen.
Verteile die Schwierigkeitsgrade: easy, medium, hard.
Antworte NUR mit dem JSON-Array.`, count)
return prompt
}
// parseQuizResponse parses LLM JSON response into QuizQuestion structs
func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
// Try to extract JSON from the response (LLM might add text around it)
jsonStr := response
start := strings.Index(response, "[")
end := strings.LastIndex(response, "]")
if start >= 0 && end > start {
jsonStr = response[start : end+1]
}
type rawQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation"`
Difficulty string `json:"difficulty"`
}
var rawQuestions []rawQuestion
if err := json.Unmarshal([]byte(jsonStr), &rawQuestions); err != nil {
return nil, fmt.Errorf("invalid JSON from LLM: %w", err)
}
var questions []QuizQuestion
for _, rq := range rawQuestions {
difficulty := Difficulty(rq.Difficulty)
if difficulty != DifficultyEasy && difficulty != DifficultyMedium && difficulty != DifficultyHard {
difficulty = DifficultyMedium
}
q := QuizQuestion{
ModuleID: moduleID,
Question: rq.Question,
Options: rq.Options,
CorrectIndex: rq.CorrectIndex,
Explanation: rq.Explanation,
Difficulty: difficulty,
IsActive: true,
}
if len(q.Options) != 4 {
continue // Skip malformed questions
}
if q.CorrectIndex < 0 || q.CorrectIndex >= len(q.Options) {
continue continue
} }
questions = append(questions, q) _, err := g.GenerateModuleContent(ctx, module, language)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
result.Generated++
} }
if questions == nil { return result, nil
questions = []QuizQuestion{} }
// GenerateAllQuizQuestions generates quiz questions for all modules that don't have questions yet
func (g *ContentGenerator) GenerateAllQuizQuestions(ctx context.Context, tenantID uuid.UUID, count int) (*BulkResult, error) {
if count <= 0 {
count = 5
} }
return questions, nil modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
if err != nil {
return nil, fmt.Errorf("failed to list modules: %w", err)
}
result := &BulkResult{}
for _, module := range modules {
// Check if module already has quiz questions
questions, _ := g.store.ListQuizQuestions(ctx, module.ID)
if len(questions) > 0 {
result.Skipped++
continue
}
_, err := g.GenerateQuizQuestions(ctx, module, count)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
result.Generated++
}
return result, nil
} }
// GenerateBlockContent generates training content for a module based on linked canonical controls // GenerateBlockContent generates training content for a module based on linked canonical controls
@@ -369,6 +282,98 @@ func (g *ContentGenerator) GenerateBlockContent(
return content, nil return content, nil
} }
// ============================================================================
// Prompt Templates
// ============================================================================
func getContentSystemPrompt(language string) string {
if language == "en" {
return "You are a compliance training content expert. Generate professional, accurate training material in Markdown format. Focus on practical relevance and legal accuracy. Do not include any personal data or fictional names."
}
return "Du bist ein Experte fuer Compliance-Schulungsinhalte. Erstelle professionelle, praezise Schulungsmaterialien im Markdown-Format. Fokussiere dich auf praktische Relevanz und rechtliche Genauigkeit. Verwende keine personenbezogenen Daten oder fiktiven Namen."
}
func getQuizSystemPrompt() string {
return `Du bist ein Experte fuer Compliance-Pruefungsfragen. Erstelle Multiple-Choice-Fragen als JSON-Array.
Jede Frage hat genau 4 Antwortoptionen, davon genau eine richtige.
Antworte NUR mit dem JSON-Array, ohne zusaetzlichen Text.
Format:
[
{
"question": "Frage hier?",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_index": 0,
"explanation": "Erklaerung warum Option A richtig ist.",
"difficulty": "medium"
}
]`
}
func buildContentPrompt(module TrainingModule, language string) string {
regulationLabels := map[RegulationArea]string{
RegulationDSGVO: "Datenschutz-Grundverordnung (DSGVO)",
RegulationNIS2: "NIS-2-Richtlinie",
RegulationISO27001: "ISO 27001 / ISMS",
RegulationAIAct: "EU AI Act / KI-Verordnung",
RegulationGeschGehG: "Geschaeftsgeheimnisgesetz (GeschGehG)",
RegulationHinSchG: "Hinweisgeberschutzgesetz (HinSchG)",
}
regulation := regulationLabels[module.RegulationArea]
if regulation == "" {
regulation = string(module.RegulationArea)
}
return fmt.Sprintf(`Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:
**Modulcode:** %s
**Titel:** %s
**Beschreibung:** %s
**Regulierungsbereich:** %s
**Dauer:** %d Minuten
**NIS2-relevant:** %v
Das Material soll:
1. Eine kurze Einfuehrung in das Thema geben
2. Die wichtigsten rechtlichen Grundlagen erklaeren
3. Praktische Handlungsanweisungen fuer den Arbeitsalltag enthalten
4. Typische Fehler und Risiken aufzeigen
5. Eine Zusammenfassung der Kernpunkte bieten
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`,
module.ModuleCode, module.Title, module.Description,
regulation, module.DurationMinutes, module.NIS2Relevant)
}
func buildQuizPrompt(module TrainingModule, contentContext string, count int) string {
prompt := fmt.Sprintf(`Erstelle %d Multiple-Choice-Pruefungsfragen fuer das Compliance-Modul:
**Modulcode:** %s
**Titel:** %s
**Regulierungsbereich:** %s`, count, module.ModuleCode, module.Title, string(module.RegulationArea))
if contentContext != "" {
// Truncate content to avoid token limit
if len(contentContext) > 3000 {
contentContext = contentContext[:3000] + "..."
}
prompt += fmt.Sprintf(`
**Schulungsinhalt als Kontext:**
%s`, contentContext)
}
prompt += fmt.Sprintf(`
Erstelle genau %d Fragen mit je 4 Antwortoptionen.
Verteile die Schwierigkeitsgrade: easy, medium, hard.
Antworte NUR mit dem JSON-Array.`, count)
return prompt
}
// buildBlockContentPrompt creates a prompt that incorporates canonical controls // buildBlockContentPrompt creates a prompt that incorporates canonical controls
func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string { func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string {
var sb strings.Builder var sb strings.Builder
@@ -421,304 +426,61 @@ Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorh
return sb.String() return sb.String()
} }
// GenerateAllModuleContent generates text content for all modules that don't have published content yet // parseQuizResponse parses LLM JSON response into QuizQuestion structs
func (g *ContentGenerator) GenerateAllModuleContent(ctx context.Context, tenantID uuid.UUID, language string) (*BulkResult, error) { func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
if language == "" { // Try to extract JSON from the response (LLM might add text around it)
language = "de" jsonStr := response
} start := strings.Index(response, "[")
end := strings.LastIndex(response, "]")
modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
if err != nil {
return nil, fmt.Errorf("failed to list modules: %w", err)
}
result := &BulkResult{}
for _, module := range modules {
// Check if module already has published content
content, _ := g.store.GetPublishedContent(ctx, module.ID)
if content != nil {
result.Skipped++
continue
}
_, err := g.GenerateModuleContent(ctx, module, language)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
result.Generated++
}
return result, nil
}
// GenerateAllQuizQuestions generates quiz questions for all modules that don't have questions yet
func (g *ContentGenerator) GenerateAllQuizQuestions(ctx context.Context, tenantID uuid.UUID, count int) (*BulkResult, error) {
if count <= 0 {
count = 5
}
modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100})
if err != nil {
return nil, fmt.Errorf("failed to list modules: %w", err)
}
result := &BulkResult{}
for _, module := range modules {
// Check if module already has quiz questions
questions, _ := g.store.ListQuizQuestions(ctx, module.ID)
if len(questions) > 0 {
result.Skipped++
continue
}
_, err := g.GenerateQuizQuestions(ctx, module, count)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
continue
}
result.Generated++
}
return result, nil
}
// GenerateAudio generates audio for a module using the TTS service
func (g *ContentGenerator) GenerateAudio(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
// Get published content
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
if content == nil {
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
}
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// Create media record (processing)
media := &TrainingMedia{
ModuleID: module.ID,
ContentID: &content.ID,
MediaType: MediaTypeAudio,
Status: MediaStatusProcessing,
Bucket: "compliance-training-audio",
ObjectKey: fmt.Sprintf("audio/%s/%s.mp3", module.ID.String(), content.ID.String()),
MimeType: "audio/mpeg",
VoiceModel: "de_DE-thorsten-high",
Language: "de",
GeneratedBy: "tts_piper",
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Call TTS service
ttsResp, err := g.ttsClient.Synthesize(ctx, &TTSSynthesizeRequest{
Text: content.ContentBody,
Language: "de",
Voice: "thorsten-high",
ModuleID: module.ID.String(),
ContentID: content.ID.String(),
})
if err != nil {
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
return nil, fmt.Errorf("TTS synthesis failed: %w", err)
}
// Update media record
media.Status = MediaStatusCompleted
media.FileSizeBytes = ttsResp.SizeBytes
media.DurationSeconds = ttsResp.DurationSeconds
media.ObjectKey = ttsResp.ObjectKey
media.Bucket = ttsResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, ttsResp.SizeBytes, ttsResp.DurationSeconds, "")
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("audio_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": ttsResp.DurationSeconds,
"size_bytes": ttsResp.SizeBytes,
},
})
return media, nil
}
// VideoScript represents a structured presentation script
type VideoScript struct {
Title string `json:"title"`
Sections []VideoScriptSection `json:"sections"`
}
// VideoScriptSection is one slide in the presentation
type VideoScriptSection struct {
Heading string `json:"heading"`
Text string `json:"text"`
BulletPoints []string `json:"bullet_points"`
}
// GenerateVideoScript generates a structured video script from module content via LLM
func (g *ContentGenerator) GenerateVideoScript(ctx context.Context, module TrainingModule) (*VideoScript, error) {
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
if content == nil {
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
}
prompt := fmt.Sprintf(`Erstelle ein strukturiertes Folien-Script fuer eine Praesentations-Video-Schulung.
**Modul:** %s — %s
**Inhalt:**
%s
Erstelle 5-8 Folien. Jede Folie hat:
- heading: Kurze Ueberschrift (max 60 Zeichen)
- text: Erklaerungstext (1-2 Saetze)
- bullet_points: 2-4 Kernpunkte
Antworte NUR mit einem JSON-Objekt in diesem Format:
{
"title": "Titel der Praesentation",
"sections": [
{
"heading": "Folienueberschrift",
"text": "Erklaerungstext fuer diese Folie.",
"bullet_points": ["Punkt 1", "Punkt 2", "Punkt 3"]
}
]
}`, module.ModuleCode, module.Title, truncateText(content.ContentBody, 3000))
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: "Du bist ein Experte fuer Compliance-Schulungspraesentationen. Erstelle strukturierte Folien-Scripts als JSON. Antworte NUR mit dem JSON-Objekt."},
{Role: "user", Content: prompt},
},
Temperature: 0.15,
MaxTokens: 4096,
})
if err != nil {
return nil, fmt.Errorf("LLM video script generation failed: %w", err)
}
// Parse JSON response
var script VideoScript
jsonStr := resp.Message.Content
start := strings.Index(jsonStr, "{")
end := strings.LastIndex(jsonStr, "}")
if start >= 0 && end > start { if start >= 0 && end > start {
jsonStr = jsonStr[start : end+1] jsonStr = response[start : end+1]
} }
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil { type rawQuestion struct {
return nil, fmt.Errorf("failed to parse video script JSON: %w", err) Question string `json:"question"`
Options []string `json:"options"`
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation"`
Difficulty string `json:"difficulty"`
} }
if len(script.Sections) == 0 { var rawQuestions []rawQuestion
return nil, fmt.Errorf("video script has no sections") if err := json.Unmarshal([]byte(jsonStr), &rawQuestions); err != nil {
return nil, fmt.Errorf("invalid JSON from LLM: %w", err)
} }
return &script, nil var questions []QuizQuestion
} for _, rq := range rawQuestions {
difficulty := Difficulty(rq.Difficulty)
// GenerateVideo generates a presentation video for a module if difficulty != DifficultyEasy && difficulty != DifficultyMedium && difficulty != DifficultyHard {
func (g *ContentGenerator) GenerateVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) { difficulty = DifficultyMedium
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// Check for published audio, generate if missing
audio, _ := g.store.GetPublishedAudio(ctx, module.ID)
if audio == nil {
// Try to generate audio first
var err error
audio, err = g.GenerateAudio(ctx, module)
if err != nil {
return nil, fmt.Errorf("audio generation required but failed: %w", err)
} }
// Auto-publish the audio
g.store.PublishMedia(ctx, audio.ID, true) q := QuizQuestion{
ModuleID: moduleID,
Question: rq.Question,
Options: rq.Options,
CorrectIndex: rq.CorrectIndex,
Explanation: rq.Explanation,
Difficulty: difficulty,
IsActive: true,
}
if len(q.Options) != 4 {
continue // Skip malformed questions
}
if q.CorrectIndex < 0 || q.CorrectIndex >= len(q.Options) {
continue
}
questions = append(questions, q)
} }
// Generate video script via LLM if questions == nil {
script, err := g.GenerateVideoScript(ctx, module) questions = []QuizQuestion{}
if err != nil {
return nil, fmt.Errorf("video script generation failed: %w", err)
} }
// Create media record return questions, nil
media := &TrainingMedia{
ModuleID: module.ID,
MediaType: MediaTypeVideo,
Status: MediaStatusProcessing,
Bucket: "compliance-training-video",
ObjectKey: fmt.Sprintf("video/%s/presentation.mp4", module.ID.String()),
MimeType: "video/mp4",
Language: "de",
GeneratedBy: "tts_ffmpeg",
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Build script map for TTS service
scriptMap := map[string]interface{}{
"title": script.Title,
"module_code": module.ModuleCode,
"sections": script.Sections,
}
// Call TTS service video generation
videoResp, err := g.ttsClient.GenerateVideo(ctx, &TTSGenerateVideoRequest{
Script: scriptMap,
AudioObjectKey: audio.ObjectKey,
ModuleID: module.ID.String(),
})
if err != nil {
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
return nil, fmt.Errorf("video generation failed: %w", err)
}
// Update media record
media.Status = MediaStatusCompleted
media.FileSizeBytes = videoResp.SizeBytes
media.DurationSeconds = videoResp.DurationSeconds
media.ObjectKey = videoResp.ObjectKey
media.Bucket = videoResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("video_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": videoResp.DurationSeconds,
"size_bytes": videoResp.SizeBytes,
"slides": len(script.Sections),
},
})
return media, nil
} }
func truncateText(text string, maxLen int) string { func truncateText(text string, maxLen int) string {
@@ -727,252 +489,3 @@ func truncateText(text string, maxLen int) string {
} }
return text[:maxLen] + "..." return text[:maxLen] + "..."
} }
// ============================================================================
// Interactive Video Pipeline
// ============================================================================
const narratorSystemPrompt = `Du bist ein professioneller AI Teacher fuer Compliance-Schulungen.
Dein Stil ist foermlich aber freundlich, klar und paedagogisch wertvoll.
Du sprichst die Lernenden direkt an ("Sie") und fuehrst sie durch die Schulung.
Du erzeugst IMMER deutschsprachige Inhalte.
Dein Output ist ein JSON-Objekt im Format NarratorScript.
Jede Section sollte etwa 3 Minuten Sprechzeit haben (~450 Woerter Narrator-Text).
Nach jeder Section kommt ein Checkpoint mit 3-5 Quiz-Fragen.
Die Fragen testen das Verstaendnis des gerade Gelernten.
Jede Frage hat genau 4 Antwortmoeglichkeiten, wobei correct_index (0-basiert) die richtige Antwort angibt.
Antworte NUR mit dem JSON-Objekt, ohne Markdown-Codeblock-Wrapper.`
// GenerateNarratorScript generates a narrator-style video script with checkpoints via LLM
func (g *ContentGenerator) GenerateNarratorScript(ctx context.Context, module TrainingModule) (*NarratorScript, error) {
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
contentContext := ""
if content != nil {
contentContext = fmt.Sprintf("\n\n**Vorhandener Schulungsinhalt (als Basis):**\n%s", truncateText(content.ContentBody, 4000))
}
prompt := fmt.Sprintf(`Erstelle ein interaktives Schulungsvideo-Skript mit Erzaehlerpersona und Checkpoints.
**Modul:** %s — %s
**Verordnung:** %s
**Beschreibung:** %s
**Dauer:** ca. %d Minuten
%s
Erstelle ein NarratorScript-JSON mit:
- "title": Titel der Schulung
- "intro": Begruessungstext ("Hallo, ich bin Ihr AI Teacher. Heute lernen Sie...")
- "sections": Array mit 3-4 Abschnitten, jeder mit:
- "heading": Abschnittsueberschrift
- "narrator_text": Fliesstext im Erzaehlstil (~450 Woerter, ~3 Min Sprechzeit)
- "bullet_points": 3-5 Kernpunkte fuer die Folie
- "transition": Ueberleitung zum naechsten Abschnitt oder Checkpoint
- "checkpoint": Quiz-Block mit:
- "title": Checkpoint-Titel
- "questions": Array mit 3-5 Fragen, je:
- "question": Fragetext
- "options": Array mit 4 Antworten
- "correct_index": Index der richtigen Antwort (0-basiert)
- "explanation": Erklaerung der richtigen Antwort
- "outro": Abschlussworte
- "total_duration_estimate": geschaetzte Gesamtdauer in Sekunden
Antworte NUR mit dem JSON-Objekt.`,
module.ModuleCode, module.Title,
string(module.RegulationArea),
module.Description,
module.DurationMinutes,
contentContext,
)
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: narratorSystemPrompt},
{Role: "user", Content: prompt},
},
Temperature: 0.2,
MaxTokens: 8192,
})
if err != nil {
return nil, fmt.Errorf("LLM narrator script generation failed: %w", err)
}
return parseNarratorScript(resp.Message.Content)
}
// parseNarratorScript extracts a NarratorScript from LLM output
func parseNarratorScript(content string) (*NarratorScript, error) {
// Find JSON object in response
start := strings.Index(content, "{")
end := strings.LastIndex(content, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("no JSON object found in LLM response")
}
jsonStr := content[start : end+1]
var script NarratorScript
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
return nil, fmt.Errorf("failed to parse narrator script JSON: %w", err)
}
if len(script.Sections) == 0 {
return nil, fmt.Errorf("narrator script has no sections")
}
return &script, nil
}
// GenerateInteractiveVideo orchestrates the full interactive video pipeline:
// NarratorScript → TTS Audio → Slides+Video → DB Checkpoints + Quiz Questions
func (g *ContentGenerator) GenerateInteractiveVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// 1. Generate NarratorScript via LLM
script, err := g.GenerateNarratorScript(ctx, module)
if err != nil {
return nil, fmt.Errorf("narrator script generation failed: %w", err)
}
// 2. Synthesize audio per section via TTS service
sections := make([]SectionAudio, len(script.Sections))
for i, s := range script.Sections {
// Combine narrator text with intro/outro for first/last section
text := s.NarratorText
if i == 0 && script.Intro != "" {
text = script.Intro + "\n\n" + text
}
if i == len(script.Sections)-1 && script.Outro != "" {
text = text + "\n\n" + script.Outro
}
sections[i] = SectionAudio{
Text: text,
Heading: s.Heading,
}
}
audioResp, err := g.ttsClient.SynthesizeSections(ctx, &SynthesizeSectionsRequest{
Sections: sections,
Voice: "de_DE-thorsten-high",
ModuleID: module.ID.String(),
})
if err != nil {
return nil, fmt.Errorf("section audio synthesis failed: %w", err)
}
// 3. Generate interactive video via TTS service
videoResp, err := g.ttsClient.GenerateInteractiveVideo(ctx, &GenerateInteractiveVideoRequest{
Script: script,
Audio: audioResp,
ModuleID: module.ID.String(),
})
if err != nil {
return nil, fmt.Errorf("interactive video generation failed: %w", err)
}
// 4. Save TrainingMedia record
scriptJSON, _ := json.Marshal(script)
media := &TrainingMedia{
ModuleID: module.ID,
MediaType: MediaTypeInteractiveVideo,
Status: MediaStatusProcessing,
Bucket: "compliance-training-video",
ObjectKey: fmt.Sprintf("video/%s/interactive.mp4", module.ID.String()),
MimeType: "video/mp4",
Language: "de",
GeneratedBy: "tts_ffmpeg_interactive",
Metadata: scriptJSON,
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Update media with video result
media.Status = MediaStatusCompleted
media.FileSizeBytes = videoResp.SizeBytes
media.DurationSeconds = videoResp.DurationSeconds
media.ObjectKey = videoResp.ObjectKey
media.Bucket = videoResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
// Auto-publish
g.store.PublishMedia(ctx, media.ID, true)
// 5. Create Checkpoints + Quiz Questions in DB
// Clear old checkpoints first
g.store.DeleteCheckpointsForModule(ctx, module.ID)
for i, section := range script.Sections {
if section.Checkpoint == nil {
continue
}
// Calculate timestamp from cumulative audio durations
var timestamp float64
if i < len(audioResp.Sections) {
// Checkpoint timestamp = end of this section's audio
timestamp = audioResp.Sections[i].StartTimestamp + audioResp.Sections[i].Duration
}
cp := &Checkpoint{
ModuleID: module.ID,
CheckpointIndex: i,
Title: section.Checkpoint.Title,
TimestampSeconds: timestamp,
}
if err := g.store.CreateCheckpoint(ctx, cp); err != nil {
return nil, fmt.Errorf("failed to create checkpoint %d: %w", i, err)
}
// Save quiz questions for this checkpoint
for j, q := range section.Checkpoint.Questions {
question := &QuizQuestion{
ModuleID: module.ID,
Question: q.Question,
Options: q.Options,
CorrectIndex: q.CorrectIndex,
Explanation: q.Explanation,
Difficulty: DifficultyMedium,
SortOrder: j,
}
if err := g.store.CreateCheckpointQuizQuestion(ctx, question, cp.ID); err != nil {
return nil, fmt.Errorf("failed to create checkpoint question: %w", err)
}
}
}
// 6. Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("interactive_video_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": videoResp.DurationSeconds,
"sections": len(script.Sections),
"checkpoints": countCheckpoints(script),
},
})
return media, nil
}
func countCheckpoints(script *NarratorScript) int {
count := 0
for _, s := range script.Sections {
if s.Checkpoint != nil {
count++
}
}
return count
}

View File

@@ -0,0 +1,497 @@
package training
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
)
// VideoScript represents a structured presentation script
type VideoScript struct {
Title string `json:"title"`
Sections []VideoScriptSection `json:"sections"`
}
// VideoScriptSection is one slide in the presentation
type VideoScriptSection struct {
Heading string `json:"heading"`
Text string `json:"text"`
BulletPoints []string `json:"bullet_points"`
}
// GenerateAudio generates audio for a module using the TTS service
func (g *ContentGenerator) GenerateAudio(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
// Get published content
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
if content == nil {
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
}
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// Create media record (processing)
media := &TrainingMedia{
ModuleID: module.ID,
ContentID: &content.ID,
MediaType: MediaTypeAudio,
Status: MediaStatusProcessing,
Bucket: "compliance-training-audio",
ObjectKey: fmt.Sprintf("audio/%s/%s.mp3", module.ID.String(), content.ID.String()),
MimeType: "audio/mpeg",
VoiceModel: "de_DE-thorsten-high",
Language: "de",
GeneratedBy: "tts_piper",
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Call TTS service
ttsResp, err := g.ttsClient.Synthesize(ctx, &TTSSynthesizeRequest{
Text: content.ContentBody,
Language: "de",
Voice: "thorsten-high",
ModuleID: module.ID.String(),
ContentID: content.ID.String(),
})
if err != nil {
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
return nil, fmt.Errorf("TTS synthesis failed: %w", err)
}
// Update media record
media.Status = MediaStatusCompleted
media.FileSizeBytes = ttsResp.SizeBytes
media.DurationSeconds = ttsResp.DurationSeconds
media.ObjectKey = ttsResp.ObjectKey
media.Bucket = ttsResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, ttsResp.SizeBytes, ttsResp.DurationSeconds, "")
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("audio_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": ttsResp.DurationSeconds,
"size_bytes": ttsResp.SizeBytes,
},
})
return media, nil
}
// GenerateVideoScript generates a structured video script from module content via LLM
func (g *ContentGenerator) GenerateVideoScript(ctx context.Context, module TrainingModule) (*VideoScript, error) {
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
if content == nil {
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
}
prompt := fmt.Sprintf(`Erstelle ein strukturiertes Folien-Script fuer eine Praesentations-Video-Schulung.
**Modul:** %s — %s
**Inhalt:**
%s
Erstelle 5-8 Folien. Jede Folie hat:
- heading: Kurze Ueberschrift (max 60 Zeichen)
- text: Erklaerungstext (1-2 Saetze)
- bullet_points: 2-4 Kernpunkte
Antworte NUR mit einem JSON-Objekt in diesem Format:
{
"title": "Titel der Praesentation",
"sections": [
{
"heading": "Folienueberschrift",
"text": "Erklaerungstext fuer diese Folie.",
"bullet_points": ["Punkt 1", "Punkt 2", "Punkt 3"]
}
]
}`, module.ModuleCode, module.Title, truncateText(content.ContentBody, 3000))
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: "Du bist ein Experte fuer Compliance-Schulungspraesentationen. Erstelle strukturierte Folien-Scripts als JSON. Antworte NUR mit dem JSON-Objekt."},
{Role: "user", Content: prompt},
},
Temperature: 0.15,
MaxTokens: 4096,
})
if err != nil {
return nil, fmt.Errorf("LLM video script generation failed: %w", err)
}
// Parse JSON response
var script VideoScript
jsonStr := resp.Message.Content
start := strings.Index(jsonStr, "{")
end := strings.LastIndex(jsonStr, "}")
if start >= 0 && end > start {
jsonStr = jsonStr[start : end+1]
}
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
return nil, fmt.Errorf("failed to parse video script JSON: %w", err)
}
if len(script.Sections) == 0 {
return nil, fmt.Errorf("video script has no sections")
}
return &script, nil
}
// GenerateVideo generates a presentation video for a module
func (g *ContentGenerator) GenerateVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// Check for published audio, generate if missing
audio, _ := g.store.GetPublishedAudio(ctx, module.ID)
if audio == nil {
// Try to generate audio first
var err error
audio, err = g.GenerateAudio(ctx, module)
if err != nil {
return nil, fmt.Errorf("audio generation required but failed: %w", err)
}
// Auto-publish the audio
g.store.PublishMedia(ctx, audio.ID, true)
}
// Generate video script via LLM
script, err := g.GenerateVideoScript(ctx, module)
if err != nil {
return nil, fmt.Errorf("video script generation failed: %w", err)
}
// Create media record
media := &TrainingMedia{
ModuleID: module.ID,
MediaType: MediaTypeVideo,
Status: MediaStatusProcessing,
Bucket: "compliance-training-video",
ObjectKey: fmt.Sprintf("video/%s/presentation.mp4", module.ID.String()),
MimeType: "video/mp4",
Language: "de",
GeneratedBy: "tts_ffmpeg",
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Build script map for TTS service
scriptMap := map[string]interface{}{
"title": script.Title,
"module_code": module.ModuleCode,
"sections": script.Sections,
}
// Call TTS service video generation
videoResp, err := g.ttsClient.GenerateVideo(ctx, &TTSGenerateVideoRequest{
Script: scriptMap,
AudioObjectKey: audio.ObjectKey,
ModuleID: module.ID.String(),
})
if err != nil {
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
return nil, fmt.Errorf("video generation failed: %w", err)
}
// Update media record
media.Status = MediaStatusCompleted
media.FileSizeBytes = videoResp.SizeBytes
media.DurationSeconds = videoResp.DurationSeconds
media.ObjectKey = videoResp.ObjectKey
media.Bucket = videoResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
// Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("video_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": videoResp.DurationSeconds,
"size_bytes": videoResp.SizeBytes,
"slides": len(script.Sections),
},
})
return media, nil
}
// ============================================================================
// Interactive Video Pipeline
// ============================================================================
const narratorSystemPrompt = `Du bist ein professioneller AI Teacher fuer Compliance-Schulungen.
Dein Stil ist foermlich aber freundlich, klar und paedagogisch wertvoll.
Du sprichst die Lernenden direkt an ("Sie") und fuehrst sie durch die Schulung.
Du erzeugst IMMER deutschsprachige Inhalte.
Dein Output ist ein JSON-Objekt im Format NarratorScript.
Jede Section sollte etwa 3 Minuten Sprechzeit haben (~450 Woerter Narrator-Text).
Nach jeder Section kommt ein Checkpoint mit 3-5 Quiz-Fragen.
Die Fragen testen das Verstaendnis des gerade Gelernten.
Jede Frage hat genau 4 Antwortmoeglichkeiten, wobei correct_index (0-basiert) die richtige Antwort angibt.
Antworte NUR mit dem JSON-Objekt, ohne Markdown-Codeblock-Wrapper.`
// GenerateNarratorScript generates a narrator-style video script with checkpoints via LLM
func (g *ContentGenerator) GenerateNarratorScript(ctx context.Context, module TrainingModule) (*NarratorScript, error) {
content, err := g.store.GetPublishedContent(ctx, module.ID)
if err != nil {
return nil, fmt.Errorf("failed to get content: %w", err)
}
contentContext := ""
if content != nil {
contentContext = fmt.Sprintf("\n\n**Vorhandener Schulungsinhalt (als Basis):**\n%s", truncateText(content.ContentBody, 4000))
}
prompt := fmt.Sprintf(`Erstelle ein interaktives Schulungsvideo-Skript mit Erzaehlerpersona und Checkpoints.
**Modul:** %s — %s
**Verordnung:** %s
**Beschreibung:** %s
**Dauer:** ca. %d Minuten
%s
Erstelle ein NarratorScript-JSON mit:
- "title": Titel der Schulung
- "intro": Begruessungstext ("Hallo, ich bin Ihr AI Teacher. Heute lernen Sie...")
- "sections": Array mit 3-4 Abschnitten, jeder mit:
- "heading": Abschnittsueberschrift
- "narrator_text": Fliesstext im Erzaehlstil (~450 Woerter, ~3 Min Sprechzeit)
- "bullet_points": 3-5 Kernpunkte fuer die Folie
- "transition": Ueberleitung zum naechsten Abschnitt oder Checkpoint
- "checkpoint": Quiz-Block mit:
- "title": Checkpoint-Titel
- "questions": Array mit 3-5 Fragen, je:
- "question": Fragetext
- "options": Array mit 4 Antworten
- "correct_index": Index der richtigen Antwort (0-basiert)
- "explanation": Erklaerung der richtigen Antwort
- "outro": Abschlussworte
- "total_duration_estimate": geschaetzte Gesamtdauer in Sekunden
Antworte NUR mit dem JSON-Objekt.`,
module.ModuleCode, module.Title,
string(module.RegulationArea),
module.Description,
module.DurationMinutes,
contentContext,
)
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
Messages: []llm.Message{
{Role: "system", Content: narratorSystemPrompt},
{Role: "user", Content: prompt},
},
Temperature: 0.2,
MaxTokens: 8192,
})
if err != nil {
return nil, fmt.Errorf("LLM narrator script generation failed: %w", err)
}
return parseNarratorScript(resp.Message.Content)
}
// parseNarratorScript extracts a NarratorScript from LLM output
func parseNarratorScript(content string) (*NarratorScript, error) {
// Find JSON object in response
start := strings.Index(content, "{")
end := strings.LastIndex(content, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("no JSON object found in LLM response")
}
jsonStr := content[start : end+1]
var script NarratorScript
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
return nil, fmt.Errorf("failed to parse narrator script JSON: %w", err)
}
if len(script.Sections) == 0 {
return nil, fmt.Errorf("narrator script has no sections")
}
return &script, nil
}
// GenerateInteractiveVideo orchestrates the full interactive video pipeline:
// NarratorScript → TTS Audio → Slides+Video → DB Checkpoints + Quiz Questions
func (g *ContentGenerator) GenerateInteractiveVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
if g.ttsClient == nil {
return nil, fmt.Errorf("TTS client not configured")
}
// 1. Generate NarratorScript via LLM
script, err := g.GenerateNarratorScript(ctx, module)
if err != nil {
return nil, fmt.Errorf("narrator script generation failed: %w", err)
}
// 2. Synthesize audio per section via TTS service
sections := make([]SectionAudio, len(script.Sections))
for i, s := range script.Sections {
// Combine narrator text with intro/outro for first/last section
text := s.NarratorText
if i == 0 && script.Intro != "" {
text = script.Intro + "\n\n" + text
}
if i == len(script.Sections)-1 && script.Outro != "" {
text = text + "\n\n" + script.Outro
}
sections[i] = SectionAudio{
Text: text,
Heading: s.Heading,
}
}
audioResp, err := g.ttsClient.SynthesizeSections(ctx, &SynthesizeSectionsRequest{
Sections: sections,
Voice: "de_DE-thorsten-high",
ModuleID: module.ID.String(),
})
if err != nil {
return nil, fmt.Errorf("section audio synthesis failed: %w", err)
}
// 3. Generate interactive video via TTS service
videoResp, err := g.ttsClient.GenerateInteractiveVideo(ctx, &GenerateInteractiveVideoRequest{
Script: script,
Audio: audioResp,
ModuleID: module.ID.String(),
})
if err != nil {
return nil, fmt.Errorf("interactive video generation failed: %w", err)
}
// 4. Save TrainingMedia record
scriptJSON, _ := json.Marshal(script)
media := &TrainingMedia{
ModuleID: module.ID,
MediaType: MediaTypeInteractiveVideo,
Status: MediaStatusProcessing,
Bucket: "compliance-training-video",
ObjectKey: fmt.Sprintf("video/%s/interactive.mp4", module.ID.String()),
MimeType: "video/mp4",
Language: "de",
GeneratedBy: "tts_ffmpeg_interactive",
Metadata: scriptJSON,
}
if err := g.store.CreateMedia(ctx, media); err != nil {
return nil, fmt.Errorf("failed to create media record: %w", err)
}
// Update media with video result
media.Status = MediaStatusCompleted
media.FileSizeBytes = videoResp.SizeBytes
media.DurationSeconds = videoResp.DurationSeconds
media.ObjectKey = videoResp.ObjectKey
media.Bucket = videoResp.Bucket
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
// Auto-publish
g.store.PublishMedia(ctx, media.ID, true)
// 5. Create Checkpoints + Quiz Questions in DB
// Clear old checkpoints first
g.store.DeleteCheckpointsForModule(ctx, module.ID)
for i, section := range script.Sections {
if section.Checkpoint == nil {
continue
}
// Calculate timestamp from cumulative audio durations
var timestamp float64
if i < len(audioResp.Sections) {
// Checkpoint timestamp = end of this section's audio
timestamp = audioResp.Sections[i].StartTimestamp + audioResp.Sections[i].Duration
}
cp := &Checkpoint{
ModuleID: module.ID,
CheckpointIndex: i,
Title: section.Checkpoint.Title,
TimestampSeconds: timestamp,
}
if err := g.store.CreateCheckpoint(ctx, cp); err != nil {
return nil, fmt.Errorf("failed to create checkpoint %d: %w", i, err)
}
// Save quiz questions for this checkpoint
for j, q := range section.Checkpoint.Questions {
question := &QuizQuestion{
ModuleID: module.ID,
Question: q.Question,
Options: q.Options,
CorrectIndex: q.CorrectIndex,
Explanation: q.Explanation,
Difficulty: DifficultyMedium,
SortOrder: j,
}
if err := g.store.CreateCheckpointQuizQuestion(ctx, question, cp.ID); err != nil {
return nil, fmt.Errorf("failed to create checkpoint question: %w", err)
}
}
}
// 6. Audit log
g.store.LogAction(ctx, &AuditLogEntry{
TenantID: module.TenantID,
Action: AuditAction("interactive_video_generated"),
EntityType: AuditEntityModule,
EntityID: &module.ID,
Details: map[string]interface{}{
"module_code": module.ModuleCode,
"media_id": media.ID.String(),
"duration_seconds": videoResp.DurationSeconds,
"sections": len(script.Sections),
"checkpoints": countCheckpoints(script),
},
})
return media, nil
}
func countCheckpoints(script *NarratorScript) int {
count := 0
for _, s := range script.Sections {
if s.Checkpoint != nil {
count++
}
}
return count
}