Files
breakpilot-compliance/ai-compliance-sdk/internal/api/handlers/academy_generation_handlers.go
Sharang Parnerkar e0b3c54212 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>
2026-04-19 09:44:07 +02:00

473 lines
14 KiB
Go

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
}