From e0b3c54212598c606678a108497aa8257e87592e Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:44:07 +0200 Subject: [PATCH] refactor(go): split academy_handlers, workshop_handlers, content_generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../handlers/academy_enrollment_handlers.go | 320 +++++++ .../handlers/academy_generation_handlers.go | 472 ++++++++++ .../internal/api/handlers/academy_handlers.go | 818 ----------------- .../api/handlers/workshop_export_handlers.go | 196 ++++ .../api/handlers/workshop_handlers.go | 631 ------------- .../handlers/workshop_interaction_handlers.go | 452 +++++++++ .../internal/training/content_generator.go | 867 ++++-------------- .../training/content_generator_media.go | 497 ++++++++++ 8 files changed, 2127 insertions(+), 2126 deletions(-) create mode 100644 ai-compliance-sdk/internal/api/handlers/academy_enrollment_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/academy_generation_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/workshop_export_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/workshop_interaction_handlers.go create mode 100644 ai-compliance-sdk/internal/training/content_generator_media.go diff --git a/ai-compliance-sdk/internal/api/handlers/academy_enrollment_handlers.go b/ai-compliance-sdk/internal/api/handlers/academy_enrollment_handlers.go new file mode 100644 index 0000000..b3a901a --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/academy_enrollment_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/academy_generation_handlers.go b/ai-compliance-sdk/internal/api/handlers/academy_generation_handlers.go new file mode 100644 index 0000000..dc0a4bf --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/academy_generation_handlers.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/api/handlers/academy_handlers.go b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go index d097fe0..1b7f95f 100644 --- a/ai-compliance-sdk/internal/api/handlers/academy_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go @@ -1,11 +1,9 @@ package handlers import ( - "fmt" "net/http" "strconv" "strings" - "time" "github.com/breakpilot/ai-compliance-sdk/internal/academy" "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"}) } -// ============================================================================ -// 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 // Average reading speed: ~200 words per minute func estimateReadingTime(content string) int { diff --git a/ai-compliance-sdk/internal/api/handlers/workshop_export_handlers.go b/ai-compliance-sdk/internal/api/handlers/workshop_export_handlers.go new file mode 100644 index 0000000..4d4db33 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/workshop_export_handlers.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go b/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go index f74aed2..00f5165 100644 --- a/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go @@ -2,7 +2,6 @@ package handlers import ( "net/http" - "strconv" "time" "github.com/breakpilot/ai-compliance-sdk/internal/rbac" @@ -291,633 +290,3 @@ func (h *WorkshopHandlers) CompleteSession(c *gin.Context) { "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 -} diff --git a/ai-compliance-sdk/internal/api/handlers/workshop_interaction_handlers.go b/ai-compliance-sdk/internal/api/handlers/workshop_interaction_handlers.go new file mode 100644 index 0000000..b0fb8cb --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/workshop_interaction_handlers.go @@ -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), + }) +} diff --git a/ai-compliance-sdk/internal/training/content_generator.go b/ai-compliance-sdk/internal/training/content_generator.go index 2e55de7..dd7c34a 100644 --- a/ai-compliance-sdk/internal/training/content_generator.go +++ b/ai-compliance-sdk/internal/training/content_generator.go @@ -85,12 +85,12 @@ func (g *ContentGenerator) GenerateModuleContent(ctx context.Context, module Tra EntityType: AuditEntityModule, EntityID: &module.ID, Details: map[string]interface{}{ - "module_code": module.ModuleCode, - "provider": resp.Provider, - "model": resp.Model, - "content_id": content.ID.String(), - "version": content.Version, - "tokens_used": resp.Usage.TotalTokens, + "module_code": module.ModuleCode, + "provider": resp.Provider, + "model": resp.Model, + "content_id": content.ID.String(), + "version": content.Version, + "tokens_used": resp.Usage.TotalTokens, }, }) @@ -145,153 +145,66 @@ func (g *ContentGenerator) GenerateQuizQuestions(ctx context.Context, module Tra return questions, 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)", +// GenerateAllModuleContent generates text content for all modules that don't have published content yet +func (g *ContentGenerator) GenerateAllModuleContent(ctx context.Context, tenantID uuid.UUID, language string) (*BulkResult, error) { + if language == "" { + language = "de" } - regulation := regulationLabels[module.RegulationArea] - if regulation == "" { - regulation = string(module.RegulationArea) + modules, _, err := g.store.ListModules(ctx, tenantID, &ModuleFilters{Limit: 100}) + if err != nil { + return nil, fmt.Errorf("failed to list modules: %w", err) } - 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 -} - -// 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) { + 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 } - 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 { - questions = []QuizQuestion{} + 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 } - 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 @@ -369,6 +282,98 @@ func (g *ContentGenerator) GenerateBlockContent( 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 func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string { var sb strings.Builder @@ -421,304 +426,61 @@ Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorh return sb.String() } -// GenerateAllModuleContent generates text content for all modules that don't have published content yet -func (g *ContentGenerator) GenerateAllModuleContent(ctx context.Context, tenantID uuid.UUID, language string) (*BulkResult, error) { - if language == "" { - language = "de" - } - - 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, "}") +// 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 = jsonStr[start : end+1] + jsonStr = response[start : end+1] } - if err := json.Unmarshal([]byte(jsonStr), &script); err != nil { - return nil, fmt.Errorf("failed to parse video script JSON: %w", err) + type rawQuestion struct { + 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 { - return nil, fmt.Errorf("video script has no sections") + var rawQuestions []rawQuestion + if err := json.Unmarshal([]byte(jsonStr), &rawQuestions); err != nil { + return nil, fmt.Errorf("invalid JSON from LLM: %w", err) } - 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) + var questions []QuizQuestion + for _, rq := range rawQuestions { + difficulty := Difficulty(rq.Difficulty) + if difficulty != DifficultyEasy && difficulty != DifficultyMedium && difficulty != DifficultyHard { + difficulty = DifficultyMedium } - // 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 - script, err := g.GenerateVideoScript(ctx, module) - if err != nil { - return nil, fmt.Errorf("video script generation failed: %w", err) + if questions == nil { + questions = []QuizQuestion{} } - // 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 + return questions, nil } func truncateText(text string, maxLen int) string { @@ -727,252 +489,3 @@ func truncateText(text string, maxLen int) string { } 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 -} diff --git a/ai-compliance-sdk/internal/training/content_generator_media.go b/ai-compliance-sdk/internal/training/content_generator_media.go new file mode 100644 index 0000000..4413db0 --- /dev/null +++ b/ai-compliance-sdk/internal/training/content_generator_media.go @@ -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 +}