refactor(go): split academy_handlers, workshop_handlers, content_generator
- academy_handlers.go (1046 LOC) → academy_handlers.go (228) + academy_enrollment_handlers.go (320) + academy_generation_handlers.go (472) - workshop_handlers.go (923 LOC) → workshop_handlers.go (292) + workshop_interaction_handlers.go (452) + workshop_export_handlers.go (196) - content_generator.go (978 LOC) → content_generator.go (491) + content_generator_media.go (497) All files under 500 LOC hard cap. Zero behavior changes, no exported symbol renames. Both packages vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
@@ -218,822 +216,6 @@ func (h *AcademyHandlers) DeleteCourse(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "course deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "course deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Enrollment Management
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// CreateEnrollment enrolls a user in a course
|
|
||||||
// POST /sdk/v1/academy/enrollments
|
|
||||||
func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) {
|
|
||||||
var req academy.EnrollUserRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantID := rbac.GetTenantID(c)
|
|
||||||
|
|
||||||
// Verify course exists
|
|
||||||
course, err := h.store.GetCourse(c.Request.Context(), req.CourseID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if course == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enrollment := &academy.Enrollment{
|
|
||||||
TenantID: tenantID,
|
|
||||||
CourseID: req.CourseID,
|
|
||||||
UserID: req.UserID,
|
|
||||||
UserName: req.UserName,
|
|
||||||
UserEmail: req.UserEmail,
|
|
||||||
Status: academy.EnrollmentStatusNotStarted,
|
|
||||||
Deadline: req.Deadline,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListEnrollments lists enrollments for the current tenant
|
|
||||||
// GET /sdk/v1/academy/enrollments
|
|
||||||
func (h *AcademyHandlers) ListEnrollments(c *gin.Context) {
|
|
||||||
tenantID := rbac.GetTenantID(c)
|
|
||||||
|
|
||||||
filters := &academy.EnrollmentFilters{
|
|
||||||
Limit: 50,
|
|
||||||
}
|
|
||||||
|
|
||||||
if status := c.Query("status"); status != "" {
|
|
||||||
filters.Status = academy.EnrollmentStatus(status)
|
|
||||||
}
|
|
||||||
if courseIDStr := c.Query("course_id"); courseIDStr != "" {
|
|
||||||
if courseID, err := uuid.Parse(courseIDStr); err == nil {
|
|
||||||
filters.CourseID = &courseID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
|
||||||
if userID, err := uuid.Parse(userIDStr); err == nil {
|
|
||||||
filters.UserID = &userID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if limitStr := c.Query("limit"); limitStr != "" {
|
|
||||||
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
|
|
||||||
filters.Limit = limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if offsetStr := c.Query("offset"); offsetStr != "" {
|
|
||||||
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
|
|
||||||
filters.Offset = offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, academy.EnrollmentListResponse{
|
|
||||||
Enrollments: enrollments,
|
|
||||||
Total: total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProgress updates an enrollment's progress
|
|
||||||
// PUT /sdk/v1/academy/enrollments/:id/progress
|
|
||||||
func (h *AcademyHandlers) UpdateProgress(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if enrollment == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req academy.UpdateProgressRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Progress < 0 || req.Progress > 100 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch updated enrollment
|
|
||||||
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"enrollment": updated})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteEnrollment marks an enrollment as completed
|
|
||||||
// POST /sdk/v1/academy/enrollments/:id/complete
|
|
||||||
func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if enrollment == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if enrollment.Status == academy.EnrollmentStatusCompleted {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch updated enrollment
|
|
||||||
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"enrollment": updated,
|
|
||||||
"message": "enrollment completed",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Certificate Management
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// GetCertificate retrieves a certificate
|
|
||||||
// GET /sdk/v1/academy/certificates/:id
|
|
||||||
func (h *AcademyHandlers) GetCertificate(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := h.store.GetCertificate(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cert == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"certificate": cert})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateCertificate generates a certificate for a completed enrollment
|
|
||||||
// POST /sdk/v1/academy/enrollments/:id/certificate
|
|
||||||
func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) {
|
|
||||||
enrollmentID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if enrollment == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if enrollment.Status != academy.EnrollmentStatusCompleted {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if certificate already exists
|
|
||||||
existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if existing != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the course for the certificate title
|
|
||||||
course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
courseTitle := "Unknown Course"
|
|
||||||
if course != nil {
|
|
||||||
courseTitle = course.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
// Certificate is valid for 1 year by default
|
|
||||||
validUntil := time.Now().UTC().AddDate(1, 0, 0)
|
|
||||||
|
|
||||||
cert := &academy.Certificate{
|
|
||||||
EnrollmentID: enrollmentID,
|
|
||||||
UserName: enrollment.UserName,
|
|
||||||
CourseTitle: courseTitle,
|
|
||||||
ValidUntil: &validUntil,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"certificate": cert})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Quiz Submission
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// SubmitQuiz submits quiz answers and returns the results
|
|
||||||
// POST /sdk/v1/academy/enrollments/:id/quiz
|
|
||||||
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
|
|
||||||
enrollmentID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req academy.SubmitQuizRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify enrollment exists
|
|
||||||
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if enrollment == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the lesson with quiz questions
|
|
||||||
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lesson == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lesson.QuizQuestions) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Answers) != len(lesson.QuizQuestions) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grade the quiz
|
|
||||||
correctCount := 0
|
|
||||||
var results []academy.QuizResult
|
|
||||||
|
|
||||||
for i, question := range lesson.QuizQuestions {
|
|
||||||
correct := req.Answers[i] == question.CorrectIndex
|
|
||||||
if correct {
|
|
||||||
correctCount++
|
|
||||||
}
|
|
||||||
results = append(results, academy.QuizResult{
|
|
||||||
Question: question.Question,
|
|
||||||
Correct: correct,
|
|
||||||
Explanation: question.Explanation,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
totalQuestions := len(lesson.QuizQuestions)
|
|
||||||
score := 0
|
|
||||||
if totalQuestions > 0 {
|
|
||||||
score = (correctCount * 100) / totalQuestions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass threshold: 70%
|
|
||||||
passed := score >= 70
|
|
||||||
|
|
||||||
response := academy.SubmitQuizResponse{
|
|
||||||
Score: score,
|
|
||||||
Passed: passed,
|
|
||||||
CorrectAnswers: correctCount,
|
|
||||||
TotalQuestions: totalQuestions,
|
|
||||||
Results: results,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Lesson Update
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// UpdateLesson updates a lesson's content, title, or quiz questions
|
|
||||||
// PUT /sdk/v1/academy/lessons/:id
|
|
||||||
func (h *AcademyHandlers) UpdateLesson(c *gin.Context) {
|
|
||||||
lessonID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lesson == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Title *string `json:"title"`
|
|
||||||
Description *string `json:"description"`
|
|
||||||
ContentURL *string `json:"content_url"`
|
|
||||||
DurationMinutes *int `json:"duration_minutes"`
|
|
||||||
QuizQuestions *[]academy.QuizQuestion `json:"quiz_questions"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Title != nil {
|
|
||||||
lesson.Title = *req.Title
|
|
||||||
}
|
|
||||||
if req.Description != nil {
|
|
||||||
lesson.Description = *req.Description
|
|
||||||
}
|
|
||||||
if req.ContentURL != nil {
|
|
||||||
lesson.ContentURL = *req.ContentURL
|
|
||||||
}
|
|
||||||
if req.DurationMinutes != nil {
|
|
||||||
lesson.DurationMinutes = *req.DurationMinutes
|
|
||||||
}
|
|
||||||
if req.QuizQuestions != nil {
|
|
||||||
lesson.QuizQuestions = *req.QuizQuestions
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.UpdateLesson(c.Request.Context(), lesson); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"lesson": lesson})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestQuiz evaluates quiz answers without requiring an enrollment
|
|
||||||
// POST /sdk/v1/academy/lessons/:id/quiz-test
|
|
||||||
func (h *AcademyHandlers) TestQuiz(c *gin.Context) {
|
|
||||||
lessonID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid lesson ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lesson, err := h.store.GetLesson(c.Request.Context(), lessonID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if lesson == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lesson.QuizQuestions) == 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Answers []int `json:"answers"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Answers) != len(lesson.QuizQuestions) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
correctCount := 0
|
|
||||||
var results []academy.QuizResult
|
|
||||||
for i, question := range lesson.QuizQuestions {
|
|
||||||
correct := req.Answers[i] == question.CorrectIndex
|
|
||||||
if correct {
|
|
||||||
correctCount++
|
|
||||||
}
|
|
||||||
results = append(results, academy.QuizResult{
|
|
||||||
Question: question.Question,
|
|
||||||
Correct: correct,
|
|
||||||
Explanation: question.Explanation,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
totalQuestions := len(lesson.QuizQuestions)
|
|
||||||
score := 0
|
|
||||||
if totalQuestions > 0 {
|
|
||||||
score = (correctCount * 100) / totalQuestions
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, academy.SubmitQuizResponse{
|
|
||||||
Score: score,
|
|
||||||
Passed: score >= 70,
|
|
||||||
CorrectAnswers: correctCount,
|
|
||||||
TotalQuestions: totalQuestions,
|
|
||||||
Results: results,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Statistics
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// GetStatistics returns academy statistics for the current tenant
|
|
||||||
// GET /sdk/v1/academy/statistics
|
|
||||||
func (h *AcademyHandlers) GetStatistics(c *gin.Context) {
|
|
||||||
tenantID := rbac.GetTenantID(c)
|
|
||||||
|
|
||||||
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Certificate PDF Download
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// DownloadCertificatePDF generates and downloads a certificate as PDF
|
|
||||||
// GET /sdk/v1/academy/certificates/:id/pdf
|
|
||||||
func (h *AcademyHandlers) DownloadCertificatePDF(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cert, err := h.store.GetCertificate(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cert == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
validUntil := time.Now().UTC().AddDate(1, 0, 0)
|
|
||||||
if cert.ValidUntil != nil {
|
|
||||||
validUntil = *cert.ValidUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
|
|
||||||
CertificateID: cert.ID.String(),
|
|
||||||
UserName: cert.UserName,
|
|
||||||
CourseName: cert.CourseTitle,
|
|
||||||
IssuedAt: cert.IssuedAt,
|
|
||||||
ValidUntil: validUntil,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate PDF: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
shortID := cert.ID.String()[:8]
|
|
||||||
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf")
|
|
||||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Course Generation from Training Modules
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// regulationToCategory maps training regulation areas to academy categories
|
|
||||||
var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{
|
|
||||||
training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics,
|
|
||||||
training.RegulationNIS2: academy.CourseCategoryITSecurity,
|
|
||||||
training.RegulationISO27001: academy.CourseCategoryITSecurity,
|
|
||||||
training.RegulationAIAct: academy.CourseCategoryAILiteracy,
|
|
||||||
training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection,
|
|
||||||
training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection,
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateCourseFromTraining creates an academy course from a training module
|
|
||||||
// POST /sdk/v1/academy/courses/generate
|
|
||||||
func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) {
|
|
||||||
if h.trainingStore == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
ModuleID string `json:"module_id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
moduleID, err := uuid.Parse(req.ModuleID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantID := rbac.GetTenantID(c)
|
|
||||||
|
|
||||||
// 1. Get the training module
|
|
||||||
module, err := h.trainingStore.GetModule(c.Request.Context(), moduleID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if module == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "training module not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If module already linked to an academy course, return that
|
|
||||||
if module.AcademyCourseID != nil {
|
|
||||||
existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID)
|
|
||||||
if err == nil && existing != nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get generated content (if any)
|
|
||||||
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID)
|
|
||||||
|
|
||||||
// 3. Get quiz questions (if any)
|
|
||||||
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID)
|
|
||||||
|
|
||||||
// 4. Determine academy category from regulation area
|
|
||||||
category, ok := regulationToCategory[module.RegulationArea]
|
|
||||||
if !ok {
|
|
||||||
category = academy.CourseCategoryCustom
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Build lessons from content + quiz
|
|
||||||
var lessons []academy.Lesson
|
|
||||||
orderIdx := 0
|
|
||||||
|
|
||||||
// Lesson 1: Text content (if generated)
|
|
||||||
if content != nil && content.ContentBody != "" {
|
|
||||||
lessons = append(lessons, academy.Lesson{
|
|
||||||
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
|
||||||
Description: content.Summary,
|
|
||||||
LessonType: academy.LessonTypeText,
|
|
||||||
ContentURL: content.ContentBody, // Store markdown in content_url for text lessons
|
|
||||||
DurationMinutes: estimateReadingTime(content.ContentBody),
|
|
||||||
OrderIndex: orderIdx,
|
|
||||||
})
|
|
||||||
orderIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lesson 2: Quiz (if questions exist)
|
|
||||||
if len(quizQuestions) > 0 {
|
|
||||||
var academyQuiz []academy.QuizQuestion
|
|
||||||
for _, q := range quizQuestions {
|
|
||||||
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
|
||||||
Question: q.Question,
|
|
||||||
Options: q.Options,
|
|
||||||
CorrectIndex: q.CorrectIndex,
|
|
||||||
Explanation: q.Explanation,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
lessons = append(lessons, academy.Lesson{
|
|
||||||
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
|
||||||
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
|
||||||
LessonType: academy.LessonTypeQuiz,
|
|
||||||
DurationMinutes: len(quizQuestions) * 2, // ~2 min per question
|
|
||||||
OrderIndex: orderIdx,
|
|
||||||
QuizQuestions: academyQuiz,
|
|
||||||
})
|
|
||||||
orderIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no content or quiz exists, create a placeholder
|
|
||||||
if len(lessons) == 0 {
|
|
||||||
lessons = append(lessons, academy.Lesson{
|
|
||||||
Title: module.Title,
|
|
||||||
Description: module.Description,
|
|
||||||
LessonType: academy.LessonTypeText,
|
|
||||||
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
|
||||||
DurationMinutes: module.DurationMinutes,
|
|
||||||
OrderIndex: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Create the academy course
|
|
||||||
course := &academy.Course{
|
|
||||||
TenantID: tenantID,
|
|
||||||
Title: module.Title,
|
|
||||||
Description: module.Description,
|
|
||||||
Category: category,
|
|
||||||
DurationMinutes: module.DurationMinutes,
|
|
||||||
RequiredForRoles: []string{},
|
|
||||||
IsActive: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Create lessons
|
|
||||||
for i := range lessons {
|
|
||||||
lessons[i].CourseID = course.ID
|
|
||||||
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
course.Lessons = lessons
|
|
||||||
|
|
||||||
// 8. Link training module to academy course
|
|
||||||
if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil {
|
|
||||||
// Non-fatal: course is created, just not linked
|
|
||||||
fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"course": course})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateAllCourses creates academy courses for all training modules that don't have one yet
|
|
||||||
// POST /sdk/v1/academy/courses/generate-all
|
|
||||||
func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) {
|
|
||||||
if h.trainingStore == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantID := rbac.GetTenantID(c)
|
|
||||||
|
|
||||||
// Get all training modules
|
|
||||||
modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
generated := 0
|
|
||||||
skipped := 0
|
|
||||||
var errors []string
|
|
||||||
|
|
||||||
for _, module := range modules {
|
|
||||||
// Skip if already linked
|
|
||||||
if module.AcademyCourseID != nil {
|
|
||||||
skipped++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get content and quiz
|
|
||||||
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID)
|
|
||||||
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID)
|
|
||||||
|
|
||||||
category, ok := regulationToCategory[module.RegulationArea]
|
|
||||||
if !ok {
|
|
||||||
category = academy.CourseCategoryCustom
|
|
||||||
}
|
|
||||||
|
|
||||||
var lessons []academy.Lesson
|
|
||||||
orderIdx := 0
|
|
||||||
|
|
||||||
if content != nil && content.ContentBody != "" {
|
|
||||||
lessons = append(lessons, academy.Lesson{
|
|
||||||
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
|
||||||
Description: content.Summary,
|
|
||||||
LessonType: academy.LessonTypeText,
|
|
||||||
ContentURL: content.ContentBody,
|
|
||||||
DurationMinutes: estimateReadingTime(content.ContentBody),
|
|
||||||
OrderIndex: orderIdx,
|
|
||||||
})
|
|
||||||
orderIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(quizQuestions) > 0 {
|
|
||||||
var academyQuiz []academy.QuizQuestion
|
|
||||||
for _, q := range quizQuestions {
|
|
||||||
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
|
||||||
Question: q.Question,
|
|
||||||
Options: q.Options,
|
|
||||||
CorrectIndex: q.CorrectIndex,
|
|
||||||
Explanation: q.Explanation,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
lessons = append(lessons, academy.Lesson{
|
|
||||||
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
|
||||||
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
|
||||||
LessonType: academy.LessonTypeQuiz,
|
|
||||||
DurationMinutes: len(quizQuestions) * 2,
|
|
||||||
OrderIndex: orderIdx,
|
|
||||||
QuizQuestions: academyQuiz,
|
|
||||||
})
|
|
||||||
orderIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lessons) == 0 {
|
|
||||||
lessons = append(lessons, academy.Lesson{
|
|
||||||
Title: module.Title,
|
|
||||||
Description: module.Description,
|
|
||||||
LessonType: academy.LessonTypeText,
|
|
||||||
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
|
||||||
DurationMinutes: module.DurationMinutes,
|
|
||||||
OrderIndex: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
course := &academy.Course{
|
|
||||||
TenantID: tenantID,
|
|
||||||
Title: module.Title,
|
|
||||||
Description: module.Description,
|
|
||||||
Category: category,
|
|
||||||
DurationMinutes: module.DurationMinutes,
|
|
||||||
RequiredForRoles: []string{},
|
|
||||||
IsActive: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
|
||||||
errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range lessons {
|
|
||||||
lessons[i].CourseID = course.ID
|
|
||||||
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
|
||||||
errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID)
|
|
||||||
generated++
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"generated": generated,
|
|
||||||
"skipped": skipped,
|
|
||||||
"errors": errors,
|
|
||||||
"total": len(modules),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// estimateReadingTime estimates reading time in minutes from markdown content
|
// estimateReadingTime estimates reading time in minutes from markdown content
|
||||||
// Average reading speed: ~200 words per minute
|
// Average reading speed: ~200 words per minute
|
||||||
func estimateReadingTime(content string) int {
|
func estimateReadingTime(content string) int {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
@@ -291,633 +290,3 @@ func (h *WorkshopHandlers) CompleteSession(c *gin.Context) {
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Participant Management
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// JoinSession allows a participant to join a session
|
|
||||||
// POST /sdk/v1/workshops/join/:code
|
|
||||||
func (h *WorkshopHandlers) JoinSession(c *gin.Context) {
|
|
||||||
code := c.Param("code")
|
|
||||||
|
|
||||||
session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if session == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req workshop.JoinSessionRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user ID if authenticated
|
|
||||||
var userID *uuid.UUID
|
|
||||||
if id := rbac.GetUserID(c); id != uuid.Nil {
|
|
||||||
userID = &id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if authentication is required
|
|
||||||
if session.RequireAuth && userID == nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
participant := &workshop.Participant{
|
|
||||||
SessionID: session.ID,
|
|
||||||
UserID: userID,
|
|
||||||
Name: req.Name,
|
|
||||||
Email: req.Email,
|
|
||||||
Role: req.Role,
|
|
||||||
Department: req.Department,
|
|
||||||
CanEdit: true,
|
|
||||||
CanComment: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if participant.Role == "" {
|
|
||||||
participant.Role = workshop.ParticipantRoleStakeholder
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, workshop.JoinSessionResponse{
|
|
||||||
Participant: *participant,
|
|
||||||
Session: *session,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListParticipants lists participants in a session
|
|
||||||
// GET /sdk/v1/workshops/:id/participants
|
|
||||||
func (h *WorkshopHandlers) ListParticipants(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
participants, err := h.store.ListParticipants(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"participants": participants,
|
|
||||||
"total": len(participants),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LeaveSession removes a participant from a session
|
|
||||||
// POST /sdk/v1/workshops/:id/leave
|
|
||||||
func (h *WorkshopHandlers) LeaveSession(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
ParticipantID uuid.UUID `json:"participant_id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "left session"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Wizard Navigation & Responses
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// SubmitResponse submits a response to a question
|
|
||||||
// POST /sdk/v1/workshops/:id/responses
|
|
||||||
func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req workshop.SubmitResponseRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get participant ID from request or context
|
|
||||||
participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine value type
|
|
||||||
valueType := "string"
|
|
||||||
switch req.Value.(type) {
|
|
||||||
case bool:
|
|
||||||
valueType = "boolean"
|
|
||||||
case float64:
|
|
||||||
valueType = "number"
|
|
||||||
case []interface{}:
|
|
||||||
valueType = "array"
|
|
||||||
case map[string]interface{}:
|
|
||||||
valueType = "object"
|
|
||||||
}
|
|
||||||
|
|
||||||
response := &workshop.Response{
|
|
||||||
SessionID: sessionID,
|
|
||||||
ParticipantID: participantID,
|
|
||||||
StepNumber: req.StepNumber,
|
|
||||||
FieldID: req.FieldID,
|
|
||||||
Value: req.Value,
|
|
||||||
ValueType: valueType,
|
|
||||||
Status: workshop.ResponseStatusSubmitted,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.SaveResponse(c.Request.Context(), response); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update participant activity
|
|
||||||
h.store.UpdateParticipantActivity(c.Request.Context(), participantID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"response": response})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResponses retrieves responses for a session
|
|
||||||
// GET /sdk/v1/workshops/:id/responses
|
|
||||||
func (h *WorkshopHandlers) GetResponses(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var stepNumber *int
|
|
||||||
if step := c.Query("step"); step != "" {
|
|
||||||
if s, err := strconv.Atoi(step); err == nil {
|
|
||||||
stepNumber = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"responses": responses,
|
|
||||||
"total": len(responses),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdvanceStep moves the session to the next step
|
|
||||||
// POST /sdk/v1/workshops/:id/advance
|
|
||||||
func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if session == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.CurrentStep >= session.TotalSteps {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark current step as completed
|
|
||||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100)
|
|
||||||
|
|
||||||
// Advance to next step
|
|
||||||
if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize next step
|
|
||||||
h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"previous_step": session.CurrentStep,
|
|
||||||
"current_step": session.CurrentStep + 1,
|
|
||||||
"message": "advanced to next step",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoToStep navigates to a specific step (if allowed)
|
|
||||||
// POST /sdk/v1/workshops/:id/goto
|
|
||||||
func (h *WorkshopHandlers) GoToStep(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
StepNumber int `json:"step_number"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := h.store.GetSession(c.Request.Context(), sessionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if session == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if back navigation is allowed
|
|
||||||
if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate step number
|
|
||||||
if req.StepNumber < 1 || req.StepNumber > session.TotalSteps {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session.CurrentStep = req.StepNumber
|
|
||||||
if err := h.store.UpdateSession(c.Request.Context(), session); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"current_step": req.StepNumber,
|
|
||||||
"message": "navigated to step",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Statistics
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// GetSessionStats returns statistics for a session
|
|
||||||
// GET /sdk/v1/workshops/:id/stats
|
|
||||||
func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := h.store.GetSessionStats(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSessionSummary returns a complete summary of a session
|
|
||||||
// GET /sdk/v1/workshops/:id/summary
|
|
||||||
func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) {
|
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if summary == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Participant Management (Extended)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// UpdateParticipant updates a participant's info
|
|
||||||
// PUT /sdk/v1/workshops/:id/participants/:participantId
|
|
||||||
func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) {
|
|
||||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Role workshop.ParticipantRole `json:"role"`
|
|
||||||
Department string `json:"department"`
|
|
||||||
CanEdit *bool `json:"can_edit,omitempty"`
|
|
||||||
CanComment *bool `json:"can_comment,omitempty"`
|
|
||||||
CanApprove *bool `json:"can_approve,omitempty"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
participant, err := h.store.GetParticipant(c.Request.Context(), participantID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if participant == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name != "" {
|
|
||||||
participant.Name = req.Name
|
|
||||||
}
|
|
||||||
if req.Role != "" {
|
|
||||||
participant.Role = req.Role
|
|
||||||
}
|
|
||||||
if req.Department != "" {
|
|
||||||
participant.Department = req.Department
|
|
||||||
}
|
|
||||||
if req.CanEdit != nil {
|
|
||||||
participant.CanEdit = *req.CanEdit
|
|
||||||
}
|
|
||||||
if req.CanComment != nil {
|
|
||||||
participant.CanComment = *req.CanComment
|
|
||||||
}
|
|
||||||
if req.CanApprove != nil {
|
|
||||||
participant.CanApprove = *req.CanApprove
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"participant": participant})
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveParticipant removes a participant from a session
|
|
||||||
// DELETE /sdk/v1/workshops/:id/participants/:participantId
|
|
||||||
func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) {
|
|
||||||
participantID, err := uuid.Parse(c.Param("participantId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "participant removed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Comments
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// AddComment adds a comment to a session
|
|
||||||
// POST /sdk/v1/workshops/:id/comments
|
|
||||||
func (h *WorkshopHandlers) AddComment(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
ParticipantID uuid.UUID `json:"participant_id"`
|
|
||||||
StepNumber *int `json:"step_number,omitempty"`
|
|
||||||
FieldID *string `json:"field_id,omitempty"`
|
|
||||||
ResponseID *uuid.UUID `json:"response_id,omitempty"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Text == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
comment := &workshop.Comment{
|
|
||||||
SessionID: sessionID,
|
|
||||||
ParticipantID: req.ParticipantID,
|
|
||||||
StepNumber: req.StepNumber,
|
|
||||||
FieldID: req.FieldID,
|
|
||||||
ResponseID: req.ResponseID,
|
|
||||||
Text: req.Text,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.store.AddComment(c.Request.Context(), comment); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"comment": comment})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetComments retrieves comments for a session
|
|
||||||
// GET /sdk/v1/workshops/:id/comments
|
|
||||||
func (h *WorkshopHandlers) GetComments(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var stepNumber *int
|
|
||||||
if step := c.Query("step"); step != "" {
|
|
||||||
if s, err := strconv.Atoi(step); err == nil {
|
|
||||||
stepNumber = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"comments": comments,
|
|
||||||
"total": len(comments),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Export
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// ExportSession exports session data
|
|
||||||
// GET /sdk/v1/workshops/:id/export
|
|
||||||
func (h *WorkshopHandlers) ExportSession(c *gin.Context) {
|
|
||||||
sessionID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
format := c.DefaultQuery("format", "json")
|
|
||||||
|
|
||||||
// Get complete session data
|
|
||||||
summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if summary == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all responses
|
|
||||||
responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil)
|
|
||||||
|
|
||||||
// Get all comments
|
|
||||||
comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil)
|
|
||||||
|
|
||||||
// Get stats
|
|
||||||
stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID)
|
|
||||||
|
|
||||||
exportData := gin.H{
|
|
||||||
"session": summary.Session,
|
|
||||||
"participants": summary.Participants,
|
|
||||||
"step_progress": summary.StepProgress,
|
|
||||||
"responses": responses,
|
|
||||||
"comments": comments,
|
|
||||||
"stats": stats,
|
|
||||||
"exported_at": time.Now().UTC(),
|
|
||||||
}
|
|
||||||
|
|
||||||
switch format {
|
|
||||||
case "json":
|
|
||||||
c.JSON(http.StatusOK, exportData)
|
|
||||||
case "md":
|
|
||||||
// Generate markdown format
|
|
||||||
md := generateSessionMarkdown(summary, responses, comments, stats)
|
|
||||||
c.Header("Content-Type", "text/markdown")
|
|
||||||
c.Header("Content-Disposition", "attachment; filename=workshop-session.md")
|
|
||||||
c.String(http.StatusOK, md)
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusOK, exportData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateSessionMarkdown generates a markdown export of the session
|
|
||||||
func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string {
|
|
||||||
md := "# Workshop Session: " + summary.Session.Title + "\n\n"
|
|
||||||
md += "**Type:** " + summary.Session.SessionType + "\n"
|
|
||||||
md += "**Status:** " + string(summary.Session.Status) + "\n"
|
|
||||||
md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n"
|
|
||||||
|
|
||||||
if summary.Session.Description != "" {
|
|
||||||
md += "## Description\n\n" + summary.Session.Description + "\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Participants
|
|
||||||
md += "## Participants\n\n"
|
|
||||||
for _, p := range summary.Participants {
|
|
||||||
md += "- **" + p.Name + "** (" + string(p.Role) + ")"
|
|
||||||
if p.Department != "" {
|
|
||||||
md += " - " + p.Department
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
|
|
||||||
// Progress
|
|
||||||
md += "## Progress\n\n"
|
|
||||||
md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n"
|
|
||||||
md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n"
|
|
||||||
md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n"
|
|
||||||
|
|
||||||
// Step progress
|
|
||||||
if len(summary.StepProgress) > 0 {
|
|
||||||
md += "### Step Progress\n\n"
|
|
||||||
for _, sp := range summary.StepProgress {
|
|
||||||
md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n"
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responses by step
|
|
||||||
if len(responses) > 0 {
|
|
||||||
md += "## Responses\n\n"
|
|
||||||
currentStep := 0
|
|
||||||
for _, r := range responses {
|
|
||||||
if r.StepNumber != currentStep {
|
|
||||||
currentStep = r.StepNumber
|
|
||||||
md += "### Step " + strconv.Itoa(currentStep) + "\n\n"
|
|
||||||
}
|
|
||||||
md += "- **" + r.FieldID + ":** "
|
|
||||||
switch v := r.Value.(type) {
|
|
||||||
case string:
|
|
||||||
md += v
|
|
||||||
case bool:
|
|
||||||
if v {
|
|
||||||
md += "Yes"
|
|
||||||
} else {
|
|
||||||
md += "No"
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
md += "See JSON export for complex value"
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comments
|
|
||||||
if len(comments) > 0 {
|
|
||||||
md += "## Comments\n\n"
|
|
||||||
for _, c := range comments {
|
|
||||||
md += "- " + c.Text
|
|
||||||
if c.StepNumber != nil {
|
|
||||||
md += " (Step " + strconv.Itoa(*c.StepNumber) + ")"
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
}
|
|
||||||
md += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
md += "---\n*Exported from AI Compliance SDK Workshop Module*\n"
|
|
||||||
|
|
||||||
return md
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -145,6 +145,143 @@ func (g *ContentGenerator) GenerateQuizQuestions(ctx context.Context, module Tra
|
|||||||
return questions, nil
|
return questions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateAllModuleContent generates text content for all modules that don't have published content yet
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBlockContent generates training content for a module based on linked canonical controls
|
||||||
|
func (g *ContentGenerator) GenerateBlockContent(
|
||||||
|
ctx context.Context,
|
||||||
|
module TrainingModule,
|
||||||
|
controls []CanonicalControlSummary,
|
||||||
|
language string,
|
||||||
|
) (*ModuleContent, error) {
|
||||||
|
if language == "" {
|
||||||
|
language = "de"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := buildBlockContentPrompt(module, controls, language)
|
||||||
|
|
||||||
|
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||||
|
Messages: []llm.Message{
|
||||||
|
{Role: "system", Content: getContentSystemPrompt(language)},
|
||||||
|
{Role: "user", Content: prompt},
|
||||||
|
},
|
||||||
|
Temperature: 0.15,
|
||||||
|
MaxTokens: 8192,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LLM block content generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBody := resp.Message.Content
|
||||||
|
|
||||||
|
// PII check
|
||||||
|
if g.piiDetector != nil && g.piiDetector.ContainsPII(contentBody) {
|
||||||
|
findings := g.piiDetector.FindPII(contentBody)
|
||||||
|
for _, f := range findings {
|
||||||
|
contentBody = strings.ReplaceAll(contentBody, f.Match, "[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := contentBody
|
||||||
|
if len(summary) > 200 {
|
||||||
|
summary = summary[:200] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
content := &ModuleContent{
|
||||||
|
ModuleID: module.ID,
|
||||||
|
ContentFormat: ContentFormatMarkdown,
|
||||||
|
ContentBody: contentBody,
|
||||||
|
Summary: summary,
|
||||||
|
GeneratedBy: "llm_block_" + resp.Provider,
|
||||||
|
LLMModel: resp.Model,
|
||||||
|
IsPublished: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.store.CreateModuleContent(ctx, content); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save block content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
g.store.LogAction(ctx, &AuditLogEntry{
|
||||||
|
TenantID: module.TenantID,
|
||||||
|
Action: AuditActionContentGenerated,
|
||||||
|
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,
|
||||||
|
"controls_count": len(controls),
|
||||||
|
"source": "block_generator",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Prompt Templates
|
// Prompt Templates
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -237,6 +374,58 @@ Antworte NUR mit dem JSON-Array.`, count)
|
|||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildBlockContentPrompt creates a prompt that incorporates canonical controls
|
||||||
|
func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
if language == "en" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Create training material for the following compliance module:\n\n"))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Module Code:** %s\n", module.ModuleCode))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Title:** %s\n", module.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Duration:** %d minutes\n\n", module.DurationMinutes))
|
||||||
|
sb.WriteString(fmt.Sprintf("This module is based on %d security controls:\n\n", len(controls)))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:\n\n"))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Modulcode:** %s\n", module.ModuleCode))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Titel:** %s\n", module.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Dauer:** %d Minuten\n\n", module.DurationMinutes))
|
||||||
|
sb.WriteString(fmt.Sprintf("Dieses Modul basiert auf %d Sicherheits-Controls:\n\n", len(controls)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ctrl := range controls {
|
||||||
|
sb.WriteString(fmt.Sprintf("### Control %d: %s — %s\n", i+1, ctrl.ControlID, ctrl.Title))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Ziel:** %s\n", ctrl.Objective))
|
||||||
|
if len(ctrl.Requirements) > 0 {
|
||||||
|
sb.WriteString("**Anforderungen:**\n")
|
||||||
|
for _, req := range ctrl.Requirements {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if language == "en" {
|
||||||
|
sb.WriteString(`Create the material as Markdown:
|
||||||
|
1. Introduction: Why are these controls important?
|
||||||
|
2. Per control: Explanation, practical tips, examples
|
||||||
|
3. Summary + action items
|
||||||
|
4. Checklist for daily work
|
||||||
|
|
||||||
|
Use clear, understandable language. Target audience: employees in companies (50-1,500 employees).`)
|
||||||
|
} else {
|
||||||
|
sb.WriteString(`Erstelle das Material als Markdown:
|
||||||
|
1. Einfuehrung: Warum sind diese Controls wichtig?
|
||||||
|
2. Pro Control: Erklaerung, praktische Hinweise, Beispiele
|
||||||
|
3. Zusammenfassung + Handlungsanweisungen
|
||||||
|
4. Checkliste fuer den Alltag
|
||||||
|
|
||||||
|
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
|
||||||
|
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
// parseQuizResponse parses LLM JSON response into QuizQuestion structs
|
// parseQuizResponse parses LLM JSON response into QuizQuestion structs
|
||||||
func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
|
func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
|
||||||
// Try to extract JSON from the response (LLM might add text around it)
|
// Try to extract JSON from the response (LLM might add text around it)
|
||||||
@@ -294,685 +483,9 @@ func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, err
|
|||||||
return questions, nil
|
return questions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateBlockContent generates training content for a module based on linked canonical controls
|
|
||||||
func (g *ContentGenerator) GenerateBlockContent(
|
|
||||||
ctx context.Context,
|
|
||||||
module TrainingModule,
|
|
||||||
controls []CanonicalControlSummary,
|
|
||||||
language string,
|
|
||||||
) (*ModuleContent, error) {
|
|
||||||
if language == "" {
|
|
||||||
language = "de"
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := buildBlockContentPrompt(module, controls, language)
|
|
||||||
|
|
||||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
|
||||||
Messages: []llm.Message{
|
|
||||||
{Role: "system", Content: getContentSystemPrompt(language)},
|
|
||||||
{Role: "user", Content: prompt},
|
|
||||||
},
|
|
||||||
Temperature: 0.15,
|
|
||||||
MaxTokens: 8192,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("LLM block content generation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentBody := resp.Message.Content
|
|
||||||
|
|
||||||
// PII check
|
|
||||||
if g.piiDetector != nil && g.piiDetector.ContainsPII(contentBody) {
|
|
||||||
findings := g.piiDetector.FindPII(contentBody)
|
|
||||||
for _, f := range findings {
|
|
||||||
contentBody = strings.ReplaceAll(contentBody, f.Match, "[REDACTED]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := contentBody
|
|
||||||
if len(summary) > 200 {
|
|
||||||
summary = summary[:200] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
content := &ModuleContent{
|
|
||||||
ModuleID: module.ID,
|
|
||||||
ContentFormat: ContentFormatMarkdown,
|
|
||||||
ContentBody: contentBody,
|
|
||||||
Summary: summary,
|
|
||||||
GeneratedBy: "llm_block_" + resp.Provider,
|
|
||||||
LLMModel: resp.Model,
|
|
||||||
IsPublished: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.store.CreateModuleContent(ctx, content); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to save block content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
g.store.LogAction(ctx, &AuditLogEntry{
|
|
||||||
TenantID: module.TenantID,
|
|
||||||
Action: AuditActionContentGenerated,
|
|
||||||
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,
|
|
||||||
"controls_count": len(controls),
|
|
||||||
"source": "block_generator",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildBlockContentPrompt creates a prompt that incorporates canonical controls
|
|
||||||
func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
if language == "en" {
|
|
||||||
sb.WriteString(fmt.Sprintf("Create training material for the following compliance module:\n\n"))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Module Code:** %s\n", module.ModuleCode))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Title:** %s\n", module.Title))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Duration:** %d minutes\n\n", module.DurationMinutes))
|
|
||||||
sb.WriteString(fmt.Sprintf("This module is based on %d security controls:\n\n", len(controls)))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(fmt.Sprintf("Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:\n\n"))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Modulcode:** %s\n", module.ModuleCode))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Titel:** %s\n", module.Title))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Dauer:** %d Minuten\n\n", module.DurationMinutes))
|
|
||||||
sb.WriteString(fmt.Sprintf("Dieses Modul basiert auf %d Sicherheits-Controls:\n\n", len(controls)))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, ctrl := range controls {
|
|
||||||
sb.WriteString(fmt.Sprintf("### Control %d: %s — %s\n", i+1, ctrl.ControlID, ctrl.Title))
|
|
||||||
sb.WriteString(fmt.Sprintf("**Ziel:** %s\n", ctrl.Objective))
|
|
||||||
if len(ctrl.Requirements) > 0 {
|
|
||||||
sb.WriteString("**Anforderungen:**\n")
|
|
||||||
for _, req := range ctrl.Requirements {
|
|
||||||
sb.WriteString(fmt.Sprintf("- %s\n", req))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if language == "en" {
|
|
||||||
sb.WriteString(`Create the material as Markdown:
|
|
||||||
1. Introduction: Why are these controls important?
|
|
||||||
2. Per control: Explanation, practical tips, examples
|
|
||||||
3. Summary + action items
|
|
||||||
4. Checklist for daily work
|
|
||||||
|
|
||||||
Use clear, understandable language. Target audience: employees in companies (50-1,500 employees).`)
|
|
||||||
} else {
|
|
||||||
sb.WriteString(`Erstelle das Material als Markdown:
|
|
||||||
1. Einfuehrung: Warum sind diese Controls wichtig?
|
|
||||||
2. Pro Control: Erklaerung, praktische Hinweise, Beispiele
|
|
||||||
3. Zusammenfassung + Handlungsanweisungen
|
|
||||||
4. Checkliste fuer den Alltag
|
|
||||||
|
|
||||||
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
|
|
||||||
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "}")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateText(text string, maxLen int) string {
|
func truncateText(text string, maxLen int) string {
|
||||||
if len(text) <= maxLen {
|
if len(text) <= maxLen {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
return text[:maxLen] + "..."
|
return text[:maxLen] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Interactive Video Pipeline
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const narratorSystemPrompt = `Du bist ein professioneller AI Teacher fuer Compliance-Schulungen.
|
|
||||||
Dein Stil ist foermlich aber freundlich, klar und paedagogisch wertvoll.
|
|
||||||
Du sprichst die Lernenden direkt an ("Sie") und fuehrst sie durch die Schulung.
|
|
||||||
Du erzeugst IMMER deutschsprachige Inhalte.
|
|
||||||
|
|
||||||
Dein Output ist ein JSON-Objekt im Format NarratorScript.
|
|
||||||
Jede Section sollte etwa 3 Minuten Sprechzeit haben (~450 Woerter Narrator-Text).
|
|
||||||
Nach jeder Section kommt ein Checkpoint mit 3-5 Quiz-Fragen.
|
|
||||||
Die Fragen testen das Verstaendnis des gerade Gelernten.
|
|
||||||
Jede Frage hat genau 4 Antwortmoeglichkeiten, wobei correct_index (0-basiert) die richtige Antwort angibt.
|
|
||||||
|
|
||||||
Antworte NUR mit dem JSON-Objekt, ohne Markdown-Codeblock-Wrapper.`
|
|
||||||
|
|
||||||
// GenerateNarratorScript generates a narrator-style video script with checkpoints via LLM
|
|
||||||
func (g *ContentGenerator) GenerateNarratorScript(ctx context.Context, module TrainingModule) (*NarratorScript, error) {
|
|
||||||
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentContext := ""
|
|
||||||
if content != nil {
|
|
||||||
contentContext = fmt.Sprintf("\n\n**Vorhandener Schulungsinhalt (als Basis):**\n%s", truncateText(content.ContentBody, 4000))
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := fmt.Sprintf(`Erstelle ein interaktives Schulungsvideo-Skript mit Erzaehlerpersona und Checkpoints.
|
|
||||||
|
|
||||||
**Modul:** %s — %s
|
|
||||||
**Verordnung:** %s
|
|
||||||
**Beschreibung:** %s
|
|
||||||
**Dauer:** ca. %d Minuten
|
|
||||||
%s
|
|
||||||
|
|
||||||
Erstelle ein NarratorScript-JSON mit:
|
|
||||||
- "title": Titel der Schulung
|
|
||||||
- "intro": Begruessungstext ("Hallo, ich bin Ihr AI Teacher. Heute lernen Sie...")
|
|
||||||
- "sections": Array mit 3-4 Abschnitten, jeder mit:
|
|
||||||
- "heading": Abschnittsueberschrift
|
|
||||||
- "narrator_text": Fliesstext im Erzaehlstil (~450 Woerter, ~3 Min Sprechzeit)
|
|
||||||
- "bullet_points": 3-5 Kernpunkte fuer die Folie
|
|
||||||
- "transition": Ueberleitung zum naechsten Abschnitt oder Checkpoint
|
|
||||||
- "checkpoint": Quiz-Block mit:
|
|
||||||
- "title": Checkpoint-Titel
|
|
||||||
- "questions": Array mit 3-5 Fragen, je:
|
|
||||||
- "question": Fragetext
|
|
||||||
- "options": Array mit 4 Antworten
|
|
||||||
- "correct_index": Index der richtigen Antwort (0-basiert)
|
|
||||||
- "explanation": Erklaerung der richtigen Antwort
|
|
||||||
- "outro": Abschlussworte
|
|
||||||
- "total_duration_estimate": geschaetzte Gesamtdauer in Sekunden
|
|
||||||
|
|
||||||
Antworte NUR mit dem JSON-Objekt.`,
|
|
||||||
module.ModuleCode, module.Title,
|
|
||||||
string(module.RegulationArea),
|
|
||||||
module.Description,
|
|
||||||
module.DurationMinutes,
|
|
||||||
contentContext,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
|
||||||
Messages: []llm.Message{
|
|
||||||
{Role: "system", Content: narratorSystemPrompt},
|
|
||||||
{Role: "user", Content: prompt},
|
|
||||||
},
|
|
||||||
Temperature: 0.2,
|
|
||||||
MaxTokens: 8192,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("LLM narrator script generation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseNarratorScript(resp.Message.Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseNarratorScript extracts a NarratorScript from LLM output
|
|
||||||
func parseNarratorScript(content string) (*NarratorScript, error) {
|
|
||||||
// Find JSON object in response
|
|
||||||
start := strings.Index(content, "{")
|
|
||||||
end := strings.LastIndex(content, "}")
|
|
||||||
if start < 0 || end <= start {
|
|
||||||
return nil, fmt.Errorf("no JSON object found in LLM response")
|
|
||||||
}
|
|
||||||
jsonStr := content[start : end+1]
|
|
||||||
|
|
||||||
var script NarratorScript
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse narrator script JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(script.Sections) == 0 {
|
|
||||||
return nil, fmt.Errorf("narrator script has no sections")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &script, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateInteractiveVideo orchestrates the full interactive video pipeline:
|
|
||||||
// NarratorScript → TTS Audio → Slides+Video → DB Checkpoints + Quiz Questions
|
|
||||||
func (g *ContentGenerator) GenerateInteractiveVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
|
||||||
if g.ttsClient == nil {
|
|
||||||
return nil, fmt.Errorf("TTS client not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Generate NarratorScript via LLM
|
|
||||||
script, err := g.GenerateNarratorScript(ctx, module)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("narrator script generation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Synthesize audio per section via TTS service
|
|
||||||
sections := make([]SectionAudio, len(script.Sections))
|
|
||||||
for i, s := range script.Sections {
|
|
||||||
// Combine narrator text with intro/outro for first/last section
|
|
||||||
text := s.NarratorText
|
|
||||||
if i == 0 && script.Intro != "" {
|
|
||||||
text = script.Intro + "\n\n" + text
|
|
||||||
}
|
|
||||||
if i == len(script.Sections)-1 && script.Outro != "" {
|
|
||||||
text = text + "\n\n" + script.Outro
|
|
||||||
}
|
|
||||||
sections[i] = SectionAudio{
|
|
||||||
Text: text,
|
|
||||||
Heading: s.Heading,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
audioResp, err := g.ttsClient.SynthesizeSections(ctx, &SynthesizeSectionsRequest{
|
|
||||||
Sections: sections,
|
|
||||||
Voice: "de_DE-thorsten-high",
|
|
||||||
ModuleID: module.ID.String(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("section audio synthesis failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Generate interactive video via TTS service
|
|
||||||
videoResp, err := g.ttsClient.GenerateInteractiveVideo(ctx, &GenerateInteractiveVideoRequest{
|
|
||||||
Script: script,
|
|
||||||
Audio: audioResp,
|
|
||||||
ModuleID: module.ID.String(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("interactive video generation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Save TrainingMedia record
|
|
||||||
scriptJSON, _ := json.Marshal(script)
|
|
||||||
media := &TrainingMedia{
|
|
||||||
ModuleID: module.ID,
|
|
||||||
MediaType: MediaTypeInteractiveVideo,
|
|
||||||
Status: MediaStatusProcessing,
|
|
||||||
Bucket: "compliance-training-video",
|
|
||||||
ObjectKey: fmt.Sprintf("video/%s/interactive.mp4", module.ID.String()),
|
|
||||||
MimeType: "video/mp4",
|
|
||||||
Language: "de",
|
|
||||||
GeneratedBy: "tts_ffmpeg_interactive",
|
|
||||||
Metadata: scriptJSON,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := g.store.CreateMedia(ctx, media); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create media record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update media with video result
|
|
||||||
media.Status = MediaStatusCompleted
|
|
||||||
media.FileSizeBytes = videoResp.SizeBytes
|
|
||||||
media.DurationSeconds = videoResp.DurationSeconds
|
|
||||||
media.ObjectKey = videoResp.ObjectKey
|
|
||||||
media.Bucket = videoResp.Bucket
|
|
||||||
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
|
|
||||||
|
|
||||||
// Auto-publish
|
|
||||||
g.store.PublishMedia(ctx, media.ID, true)
|
|
||||||
|
|
||||||
// 5. Create Checkpoints + Quiz Questions in DB
|
|
||||||
// Clear old checkpoints first
|
|
||||||
g.store.DeleteCheckpointsForModule(ctx, module.ID)
|
|
||||||
|
|
||||||
for i, section := range script.Sections {
|
|
||||||
if section.Checkpoint == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate timestamp from cumulative audio durations
|
|
||||||
var timestamp float64
|
|
||||||
if i < len(audioResp.Sections) {
|
|
||||||
// Checkpoint timestamp = end of this section's audio
|
|
||||||
timestamp = audioResp.Sections[i].StartTimestamp + audioResp.Sections[i].Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
cp := &Checkpoint{
|
|
||||||
ModuleID: module.ID,
|
|
||||||
CheckpointIndex: i,
|
|
||||||
Title: section.Checkpoint.Title,
|
|
||||||
TimestampSeconds: timestamp,
|
|
||||||
}
|
|
||||||
if err := g.store.CreateCheckpoint(ctx, cp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create checkpoint %d: %w", i, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save quiz questions for this checkpoint
|
|
||||||
for j, q := range section.Checkpoint.Questions {
|
|
||||||
question := &QuizQuestion{
|
|
||||||
ModuleID: module.ID,
|
|
||||||
Question: q.Question,
|
|
||||||
Options: q.Options,
|
|
||||||
CorrectIndex: q.CorrectIndex,
|
|
||||||
Explanation: q.Explanation,
|
|
||||||
Difficulty: DifficultyMedium,
|
|
||||||
SortOrder: j,
|
|
||||||
}
|
|
||||||
if err := g.store.CreateCheckpointQuizQuestion(ctx, question, cp.ID); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create checkpoint question: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Audit log
|
|
||||||
g.store.LogAction(ctx, &AuditLogEntry{
|
|
||||||
TenantID: module.TenantID,
|
|
||||||
Action: AuditAction("interactive_video_generated"),
|
|
||||||
EntityType: AuditEntityModule,
|
|
||||||
EntityID: &module.ID,
|
|
||||||
Details: map[string]interface{}{
|
|
||||||
"module_code": module.ModuleCode,
|
|
||||||
"media_id": media.ID.String(),
|
|
||||||
"duration_seconds": videoResp.DurationSeconds,
|
|
||||||
"sections": len(script.Sections),
|
|
||||||
"checkpoints": countCheckpoints(script),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return media, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func countCheckpoints(script *NarratorScript) int {
|
|
||||||
count := 0
|
|
||||||
for _, s := range script.Sections {
|
|
||||||
if s.Checkpoint != nil {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|||||||
497
ai-compliance-sdk/internal/training/content_generator_media.go
Normal file
497
ai-compliance-sdk/internal/training/content_generator_media.go
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
package training
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VideoScript represents a structured presentation script
|
||||||
|
type VideoScript struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Sections []VideoScriptSection `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoScriptSection is one slide in the presentation
|
||||||
|
type VideoScriptSection struct {
|
||||||
|
Heading string `json:"heading"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
BulletPoints []string `json:"bullet_points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAudio generates audio for a module using the TTS service
|
||||||
|
func (g *ContentGenerator) GenerateAudio(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
||||||
|
// Get published content
|
||||||
|
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get content: %w", err)
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.ttsClient == nil {
|
||||||
|
return nil, fmt.Errorf("TTS client not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create media record (processing)
|
||||||
|
media := &TrainingMedia{
|
||||||
|
ModuleID: module.ID,
|
||||||
|
ContentID: &content.ID,
|
||||||
|
MediaType: MediaTypeAudio,
|
||||||
|
Status: MediaStatusProcessing,
|
||||||
|
Bucket: "compliance-training-audio",
|
||||||
|
ObjectKey: fmt.Sprintf("audio/%s/%s.mp3", module.ID.String(), content.ID.String()),
|
||||||
|
MimeType: "audio/mpeg",
|
||||||
|
VoiceModel: "de_DE-thorsten-high",
|
||||||
|
Language: "de",
|
||||||
|
GeneratedBy: "tts_piper",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.store.CreateMedia(ctx, media); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create media record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call TTS service
|
||||||
|
ttsResp, err := g.ttsClient.Synthesize(ctx, &TTSSynthesizeRequest{
|
||||||
|
Text: content.ContentBody,
|
||||||
|
Language: "de",
|
||||||
|
Voice: "thorsten-high",
|
||||||
|
ModuleID: module.ID.String(),
|
||||||
|
ContentID: content.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
|
||||||
|
return nil, fmt.Errorf("TTS synthesis failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update media record
|
||||||
|
media.Status = MediaStatusCompleted
|
||||||
|
media.FileSizeBytes = ttsResp.SizeBytes
|
||||||
|
media.DurationSeconds = ttsResp.DurationSeconds
|
||||||
|
media.ObjectKey = ttsResp.ObjectKey
|
||||||
|
media.Bucket = ttsResp.Bucket
|
||||||
|
|
||||||
|
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, ttsResp.SizeBytes, ttsResp.DurationSeconds, "")
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
g.store.LogAction(ctx, &AuditLogEntry{
|
||||||
|
TenantID: module.TenantID,
|
||||||
|
Action: AuditAction("audio_generated"),
|
||||||
|
EntityType: AuditEntityModule,
|
||||||
|
EntityID: &module.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"module_code": module.ModuleCode,
|
||||||
|
"media_id": media.ID.String(),
|
||||||
|
"duration_seconds": ttsResp.DurationSeconds,
|
||||||
|
"size_bytes": ttsResp.SizeBytes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVideoScript generates a structured video script from module content via LLM
|
||||||
|
func (g *ContentGenerator) GenerateVideoScript(ctx context.Context, module TrainingModule) (*VideoScript, error) {
|
||||||
|
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get content: %w", err)
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
return nil, fmt.Errorf("no published content for module %s", module.ModuleCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Erstelle ein strukturiertes Folien-Script fuer eine Praesentations-Video-Schulung.
|
||||||
|
|
||||||
|
**Modul:** %s — %s
|
||||||
|
**Inhalt:**
|
||||||
|
%s
|
||||||
|
|
||||||
|
Erstelle 5-8 Folien. Jede Folie hat:
|
||||||
|
- heading: Kurze Ueberschrift (max 60 Zeichen)
|
||||||
|
- text: Erklaerungstext (1-2 Saetze)
|
||||||
|
- bullet_points: 2-4 Kernpunkte
|
||||||
|
|
||||||
|
Antworte NUR mit einem JSON-Objekt in diesem Format:
|
||||||
|
{
|
||||||
|
"title": "Titel der Praesentation",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"heading": "Folienueberschrift",
|
||||||
|
"text": "Erklaerungstext fuer diese Folie.",
|
||||||
|
"bullet_points": ["Punkt 1", "Punkt 2", "Punkt 3"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`, module.ModuleCode, module.Title, truncateText(content.ContentBody, 3000))
|
||||||
|
|
||||||
|
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||||
|
Messages: []llm.Message{
|
||||||
|
{Role: "system", Content: "Du bist ein Experte fuer Compliance-Schulungspraesentationen. Erstelle strukturierte Folien-Scripts als JSON. Antworte NUR mit dem JSON-Objekt."},
|
||||||
|
{Role: "user", Content: prompt},
|
||||||
|
},
|
||||||
|
Temperature: 0.15,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LLM video script generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
var script VideoScript
|
||||||
|
jsonStr := resp.Message.Content
|
||||||
|
start := strings.Index(jsonStr, "{")
|
||||||
|
end := strings.LastIndex(jsonStr, "}")
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
jsonStr = jsonStr[start : end+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse video script JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(script.Sections) == 0 {
|
||||||
|
return nil, fmt.Errorf("video script has no sections")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &script, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVideo generates a presentation video for a module
|
||||||
|
func (g *ContentGenerator) GenerateVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
||||||
|
if g.ttsClient == nil {
|
||||||
|
return nil, fmt.Errorf("TTS client not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for published audio, generate if missing
|
||||||
|
audio, _ := g.store.GetPublishedAudio(ctx, module.ID)
|
||||||
|
if audio == nil {
|
||||||
|
// Try to generate audio first
|
||||||
|
var err error
|
||||||
|
audio, err = g.GenerateAudio(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("audio generation required but failed: %w", err)
|
||||||
|
}
|
||||||
|
// Auto-publish the audio
|
||||||
|
g.store.PublishMedia(ctx, audio.ID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate video script via LLM
|
||||||
|
script, err := g.GenerateVideoScript(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("video script generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create media record
|
||||||
|
media := &TrainingMedia{
|
||||||
|
ModuleID: module.ID,
|
||||||
|
MediaType: MediaTypeVideo,
|
||||||
|
Status: MediaStatusProcessing,
|
||||||
|
Bucket: "compliance-training-video",
|
||||||
|
ObjectKey: fmt.Sprintf("video/%s/presentation.mp4", module.ID.String()),
|
||||||
|
MimeType: "video/mp4",
|
||||||
|
Language: "de",
|
||||||
|
GeneratedBy: "tts_ffmpeg",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.store.CreateMedia(ctx, media); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create media record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build script map for TTS service
|
||||||
|
scriptMap := map[string]interface{}{
|
||||||
|
"title": script.Title,
|
||||||
|
"module_code": module.ModuleCode,
|
||||||
|
"sections": script.Sections,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call TTS service video generation
|
||||||
|
videoResp, err := g.ttsClient.GenerateVideo(ctx, &TTSGenerateVideoRequest{
|
||||||
|
Script: scriptMap,
|
||||||
|
AudioObjectKey: audio.ObjectKey,
|
||||||
|
ModuleID: module.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusFailed, 0, 0, err.Error())
|
||||||
|
return nil, fmt.Errorf("video generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update media record
|
||||||
|
media.Status = MediaStatusCompleted
|
||||||
|
media.FileSizeBytes = videoResp.SizeBytes
|
||||||
|
media.DurationSeconds = videoResp.DurationSeconds
|
||||||
|
media.ObjectKey = videoResp.ObjectKey
|
||||||
|
media.Bucket = videoResp.Bucket
|
||||||
|
|
||||||
|
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
g.store.LogAction(ctx, &AuditLogEntry{
|
||||||
|
TenantID: module.TenantID,
|
||||||
|
Action: AuditAction("video_generated"),
|
||||||
|
EntityType: AuditEntityModule,
|
||||||
|
EntityID: &module.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"module_code": module.ModuleCode,
|
||||||
|
"media_id": media.ID.String(),
|
||||||
|
"duration_seconds": videoResp.DurationSeconds,
|
||||||
|
"size_bytes": videoResp.SizeBytes,
|
||||||
|
"slides": len(script.Sections),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Interactive Video Pipeline
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const narratorSystemPrompt = `Du bist ein professioneller AI Teacher fuer Compliance-Schulungen.
|
||||||
|
Dein Stil ist foermlich aber freundlich, klar und paedagogisch wertvoll.
|
||||||
|
Du sprichst die Lernenden direkt an ("Sie") und fuehrst sie durch die Schulung.
|
||||||
|
Du erzeugst IMMER deutschsprachige Inhalte.
|
||||||
|
|
||||||
|
Dein Output ist ein JSON-Objekt im Format NarratorScript.
|
||||||
|
Jede Section sollte etwa 3 Minuten Sprechzeit haben (~450 Woerter Narrator-Text).
|
||||||
|
Nach jeder Section kommt ein Checkpoint mit 3-5 Quiz-Fragen.
|
||||||
|
Die Fragen testen das Verstaendnis des gerade Gelernten.
|
||||||
|
Jede Frage hat genau 4 Antwortmoeglichkeiten, wobei correct_index (0-basiert) die richtige Antwort angibt.
|
||||||
|
|
||||||
|
Antworte NUR mit dem JSON-Objekt, ohne Markdown-Codeblock-Wrapper.`
|
||||||
|
|
||||||
|
// GenerateNarratorScript generates a narrator-style video script with checkpoints via LLM
|
||||||
|
func (g *ContentGenerator) GenerateNarratorScript(ctx context.Context, module TrainingModule) (*NarratorScript, error) {
|
||||||
|
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentContext := ""
|
||||||
|
if content != nil {
|
||||||
|
contentContext = fmt.Sprintf("\n\n**Vorhandener Schulungsinhalt (als Basis):**\n%s", truncateText(content.ContentBody, 4000))
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`Erstelle ein interaktives Schulungsvideo-Skript mit Erzaehlerpersona und Checkpoints.
|
||||||
|
|
||||||
|
**Modul:** %s — %s
|
||||||
|
**Verordnung:** %s
|
||||||
|
**Beschreibung:** %s
|
||||||
|
**Dauer:** ca. %d Minuten
|
||||||
|
%s
|
||||||
|
|
||||||
|
Erstelle ein NarratorScript-JSON mit:
|
||||||
|
- "title": Titel der Schulung
|
||||||
|
- "intro": Begruessungstext ("Hallo, ich bin Ihr AI Teacher. Heute lernen Sie...")
|
||||||
|
- "sections": Array mit 3-4 Abschnitten, jeder mit:
|
||||||
|
- "heading": Abschnittsueberschrift
|
||||||
|
- "narrator_text": Fliesstext im Erzaehlstil (~450 Woerter, ~3 Min Sprechzeit)
|
||||||
|
- "bullet_points": 3-5 Kernpunkte fuer die Folie
|
||||||
|
- "transition": Ueberleitung zum naechsten Abschnitt oder Checkpoint
|
||||||
|
- "checkpoint": Quiz-Block mit:
|
||||||
|
- "title": Checkpoint-Titel
|
||||||
|
- "questions": Array mit 3-5 Fragen, je:
|
||||||
|
- "question": Fragetext
|
||||||
|
- "options": Array mit 4 Antworten
|
||||||
|
- "correct_index": Index der richtigen Antwort (0-basiert)
|
||||||
|
- "explanation": Erklaerung der richtigen Antwort
|
||||||
|
- "outro": Abschlussworte
|
||||||
|
- "total_duration_estimate": geschaetzte Gesamtdauer in Sekunden
|
||||||
|
|
||||||
|
Antworte NUR mit dem JSON-Objekt.`,
|
||||||
|
module.ModuleCode, module.Title,
|
||||||
|
string(module.RegulationArea),
|
||||||
|
module.Description,
|
||||||
|
module.DurationMinutes,
|
||||||
|
contentContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||||
|
Messages: []llm.Message{
|
||||||
|
{Role: "system", Content: narratorSystemPrompt},
|
||||||
|
{Role: "user", Content: prompt},
|
||||||
|
},
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxTokens: 8192,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LLM narrator script generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseNarratorScript(resp.Message.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNarratorScript extracts a NarratorScript from LLM output
|
||||||
|
func parseNarratorScript(content string) (*NarratorScript, error) {
|
||||||
|
// Find JSON object in response
|
||||||
|
start := strings.Index(content, "{")
|
||||||
|
end := strings.LastIndex(content, "}")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return nil, fmt.Errorf("no JSON object found in LLM response")
|
||||||
|
}
|
||||||
|
jsonStr := content[start : end+1]
|
||||||
|
|
||||||
|
var script NarratorScript
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse narrator script JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(script.Sections) == 0 {
|
||||||
|
return nil, fmt.Errorf("narrator script has no sections")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &script, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateInteractiveVideo orchestrates the full interactive video pipeline:
|
||||||
|
// NarratorScript → TTS Audio → Slides+Video → DB Checkpoints + Quiz Questions
|
||||||
|
func (g *ContentGenerator) GenerateInteractiveVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
||||||
|
if g.ttsClient == nil {
|
||||||
|
return nil, fmt.Errorf("TTS client not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Generate NarratorScript via LLM
|
||||||
|
script, err := g.GenerateNarratorScript(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("narrator script generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Synthesize audio per section via TTS service
|
||||||
|
sections := make([]SectionAudio, len(script.Sections))
|
||||||
|
for i, s := range script.Sections {
|
||||||
|
// Combine narrator text with intro/outro for first/last section
|
||||||
|
text := s.NarratorText
|
||||||
|
if i == 0 && script.Intro != "" {
|
||||||
|
text = script.Intro + "\n\n" + text
|
||||||
|
}
|
||||||
|
if i == len(script.Sections)-1 && script.Outro != "" {
|
||||||
|
text = text + "\n\n" + script.Outro
|
||||||
|
}
|
||||||
|
sections[i] = SectionAudio{
|
||||||
|
Text: text,
|
||||||
|
Heading: s.Heading,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioResp, err := g.ttsClient.SynthesizeSections(ctx, &SynthesizeSectionsRequest{
|
||||||
|
Sections: sections,
|
||||||
|
Voice: "de_DE-thorsten-high",
|
||||||
|
ModuleID: module.ID.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("section audio synthesis failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate interactive video via TTS service
|
||||||
|
videoResp, err := g.ttsClient.GenerateInteractiveVideo(ctx, &GenerateInteractiveVideoRequest{
|
||||||
|
Script: script,
|
||||||
|
Audio: audioResp,
|
||||||
|
ModuleID: module.ID.String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("interactive video generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save TrainingMedia record
|
||||||
|
scriptJSON, _ := json.Marshal(script)
|
||||||
|
media := &TrainingMedia{
|
||||||
|
ModuleID: module.ID,
|
||||||
|
MediaType: MediaTypeInteractiveVideo,
|
||||||
|
Status: MediaStatusProcessing,
|
||||||
|
Bucket: "compliance-training-video",
|
||||||
|
ObjectKey: fmt.Sprintf("video/%s/interactive.mp4", module.ID.String()),
|
||||||
|
MimeType: "video/mp4",
|
||||||
|
Language: "de",
|
||||||
|
GeneratedBy: "tts_ffmpeg_interactive",
|
||||||
|
Metadata: scriptJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.store.CreateMedia(ctx, media); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create media record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update media with video result
|
||||||
|
media.Status = MediaStatusCompleted
|
||||||
|
media.FileSizeBytes = videoResp.SizeBytes
|
||||||
|
media.DurationSeconds = videoResp.DurationSeconds
|
||||||
|
media.ObjectKey = videoResp.ObjectKey
|
||||||
|
media.Bucket = videoResp.Bucket
|
||||||
|
g.store.UpdateMediaStatus(ctx, media.ID, MediaStatusCompleted, videoResp.SizeBytes, videoResp.DurationSeconds, "")
|
||||||
|
|
||||||
|
// Auto-publish
|
||||||
|
g.store.PublishMedia(ctx, media.ID, true)
|
||||||
|
|
||||||
|
// 5. Create Checkpoints + Quiz Questions in DB
|
||||||
|
// Clear old checkpoints first
|
||||||
|
g.store.DeleteCheckpointsForModule(ctx, module.ID)
|
||||||
|
|
||||||
|
for i, section := range script.Sections {
|
||||||
|
if section.Checkpoint == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate timestamp from cumulative audio durations
|
||||||
|
var timestamp float64
|
||||||
|
if i < len(audioResp.Sections) {
|
||||||
|
// Checkpoint timestamp = end of this section's audio
|
||||||
|
timestamp = audioResp.Sections[i].StartTimestamp + audioResp.Sections[i].Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
cp := &Checkpoint{
|
||||||
|
ModuleID: module.ID,
|
||||||
|
CheckpointIndex: i,
|
||||||
|
Title: section.Checkpoint.Title,
|
||||||
|
TimestampSeconds: timestamp,
|
||||||
|
}
|
||||||
|
if err := g.store.CreateCheckpoint(ctx, cp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create checkpoint %d: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save quiz questions for this checkpoint
|
||||||
|
for j, q := range section.Checkpoint.Questions {
|
||||||
|
question := &QuizQuestion{
|
||||||
|
ModuleID: module.ID,
|
||||||
|
Question: q.Question,
|
||||||
|
Options: q.Options,
|
||||||
|
CorrectIndex: q.CorrectIndex,
|
||||||
|
Explanation: q.Explanation,
|
||||||
|
Difficulty: DifficultyMedium,
|
||||||
|
SortOrder: j,
|
||||||
|
}
|
||||||
|
if err := g.store.CreateCheckpointQuizQuestion(ctx, question, cp.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create checkpoint question: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Audit log
|
||||||
|
g.store.LogAction(ctx, &AuditLogEntry{
|
||||||
|
TenantID: module.TenantID,
|
||||||
|
Action: AuditAction("interactive_video_generated"),
|
||||||
|
EntityType: AuditEntityModule,
|
||||||
|
EntityID: &module.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"module_code": module.ModuleCode,
|
||||||
|
"media_id": media.ID.String(),
|
||||||
|
"duration_seconds": videoResp.DurationSeconds,
|
||||||
|
"sections": len(script.Sections),
|
||||||
|
"checkpoints": countCheckpoints(script),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCheckpoints(script *NarratorScript) int {
|
||||||
|
count := 0
|
||||||
|
for _, s := range script.Sections {
|
||||||
|
if s.Checkpoint != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user