feat(academy): bridge Academy with Training Engine for course generation
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 46s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 29s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 46s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 29s
- Add POST /academy/courses/generate endpoint that creates an academy
course from a training module (with content + quiz as lessons)
- Add POST /academy/courses/generate-all to bulk-generate all courses
- Fix academy API response mapping (snake_case → camelCase)
- Fix fetchCourses/fetchCourse/fetchEnrollments/fetchStats to unwrap
backend response wrappers ({courses:[...]}, {course:{...}})
- Add "Alle Kurse generieren" button to academy overview page
- Fix bulkResult.errors crash in training page (optional chaining)
- Add SetAcademyCourseID to training store for bidirectional linking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -114,7 +114,7 @@ func main() {
|
||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||
draftingHandlers := handlers.NewDraftingHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder)
|
||||
academyHandlers := handlers.NewAcademyHandlers(academyStore)
|
||||
academyHandlers := handlers.NewAcademyHandlers(academyStore, trainingStore)
|
||||
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
|
||||
incidentHandlers := handlers.NewIncidentHandlers(incidentStore)
|
||||
vendorHandlers := handlers.NewVendorHandlers(vendorStore)
|
||||
@@ -483,6 +483,13 @@ func main() {
|
||||
|
||||
// Statistics
|
||||
academyRoutes.GET("/stats", academyHandlers.GetStatistics)
|
||||
|
||||
// Course Generation from Training Modules
|
||||
academyRoutes.POST("/courses/generate", academyHandlers.GenerateCourseFromTraining)
|
||||
academyRoutes.POST("/courses/generate-all", academyHandlers.GenerateAllCourses)
|
||||
|
||||
// Certificate PDF
|
||||
academyRoutes.GET("/certificates/:id/pdf", academyHandlers.DownloadCertificatePDF)
|
||||
}
|
||||
|
||||
// Training Engine routes - Compliance Training Content Pipeline
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AcademyHandlers handles academy HTTP requests
|
||||
type AcademyHandlers struct {
|
||||
store *academy.Store
|
||||
store *academy.Store
|
||||
trainingStore *training.Store
|
||||
}
|
||||
|
||||
// NewAcademyHandlers creates new academy handlers
|
||||
func NewAcademyHandlers(store *academy.Store) *AcademyHandlers {
|
||||
return &AcademyHandlers{store: store}
|
||||
func NewAcademyHandlers(store *academy.Store, trainingStore *training.Store) *AcademyHandlers {
|
||||
return &AcademyHandlers{store: store, trainingStore: trainingStore}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -630,3 +634,288 @@ func (h *AcademyHandlers) DownloadCertificatePDF(c *gin.Context) {
|
||||
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf")
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Course Generation from Training Modules
|
||||
// ============================================================================
|
||||
|
||||
// regulationToCategory maps training regulation areas to academy categories
|
||||
var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{
|
||||
training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics,
|
||||
training.RegulationNIS2: academy.CourseCategoryITSecurity,
|
||||
training.RegulationISO27001: academy.CourseCategoryITSecurity,
|
||||
training.RegulationAIAct: academy.CourseCategoryAILiteracy,
|
||||
training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection,
|
||||
training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection,
|
||||
}
|
||||
|
||||
// GenerateCourseFromTraining creates an academy course from a training module
|
||||
// POST /sdk/v1/academy/courses/generate
|
||||
func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) {
|
||||
if h.trainingStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ModuleID string `json:"module_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
moduleID, err := uuid.Parse(req.ModuleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
// 1. Get the training module
|
||||
module, err := h.trainingStore.GetModule(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if module == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "training module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// If module already linked to an academy course, return that
|
||||
if module.AcademyCourseID != nil {
|
||||
existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID)
|
||||
if err == nil && existing != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get generated content (if any)
|
||||
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID)
|
||||
|
||||
// 3. Get quiz questions (if any)
|
||||
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID)
|
||||
|
||||
// 4. Determine academy category from regulation area
|
||||
category, ok := regulationToCategory[module.RegulationArea]
|
||||
if !ok {
|
||||
category = academy.CourseCategoryCustom
|
||||
}
|
||||
|
||||
// 5. Build lessons from content + quiz
|
||||
var lessons []academy.Lesson
|
||||
orderIdx := 0
|
||||
|
||||
// Lesson 1: Text content (if generated)
|
||||
if content != nil && content.ContentBody != "" {
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
||||
Description: content.Summary,
|
||||
LessonType: academy.LessonTypeText,
|
||||
ContentURL: content.ContentBody, // Store markdown in content_url for text lessons
|
||||
DurationMinutes: estimateReadingTime(content.ContentBody),
|
||||
OrderIndex: orderIdx,
|
||||
})
|
||||
orderIdx++
|
||||
}
|
||||
|
||||
// Lesson 2: Quiz (if questions exist)
|
||||
if len(quizQuestions) > 0 {
|
||||
var academyQuiz []academy.QuizQuestion
|
||||
for _, q := range quizQuestions {
|
||||
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
||||
Question: q.Question,
|
||||
Options: q.Options,
|
||||
CorrectIndex: q.CorrectIndex,
|
||||
Explanation: q.Explanation,
|
||||
})
|
||||
}
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
||||
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
||||
LessonType: academy.LessonTypeQuiz,
|
||||
DurationMinutes: len(quizQuestions) * 2, // ~2 min per question
|
||||
OrderIndex: orderIdx,
|
||||
QuizQuestions: academyQuiz,
|
||||
})
|
||||
orderIdx++
|
||||
}
|
||||
|
||||
// If no content or quiz exists, create a placeholder
|
||||
if len(lessons) == 0 {
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
LessonType: academy.LessonTypeText,
|
||||
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
OrderIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 6. Create the academy course
|
||||
course := &academy.Course{
|
||||
TenantID: tenantID,
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
Category: category,
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
RequiredForRoles: []string{},
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Create lessons
|
||||
for i := range lessons {
|
||||
lessons[i].CourseID = course.ID
|
||||
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
course.Lessons = lessons
|
||||
|
||||
// 8. Link training module to academy course
|
||||
if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil {
|
||||
// Non-fatal: course is created, just not linked
|
||||
fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"course": course})
|
||||
}
|
||||
|
||||
// GenerateAllCourses creates academy courses for all training modules that don't have one yet
|
||||
// POST /sdk/v1/academy/courses/generate-all
|
||||
func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) {
|
||||
if h.trainingStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
// Get all training modules
|
||||
modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
generated := 0
|
||||
skipped := 0
|
||||
var errors []string
|
||||
|
||||
for _, module := range modules {
|
||||
// Skip if already linked
|
||||
if module.AcademyCourseID != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Get content and quiz
|
||||
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID)
|
||||
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID)
|
||||
|
||||
category, ok := regulationToCategory[module.RegulationArea]
|
||||
if !ok {
|
||||
category = academy.CourseCategoryCustom
|
||||
}
|
||||
|
||||
var lessons []academy.Lesson
|
||||
orderIdx := 0
|
||||
|
||||
if content != nil && content.ContentBody != "" {
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
||||
Description: content.Summary,
|
||||
LessonType: academy.LessonTypeText,
|
||||
ContentURL: content.ContentBody,
|
||||
DurationMinutes: estimateReadingTime(content.ContentBody),
|
||||
OrderIndex: orderIdx,
|
||||
})
|
||||
orderIdx++
|
||||
}
|
||||
|
||||
if len(quizQuestions) > 0 {
|
||||
var academyQuiz []academy.QuizQuestion
|
||||
for _, q := range quizQuestions {
|
||||
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
||||
Question: q.Question,
|
||||
Options: q.Options,
|
||||
CorrectIndex: q.CorrectIndex,
|
||||
Explanation: q.Explanation,
|
||||
})
|
||||
}
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
||||
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
||||
LessonType: academy.LessonTypeQuiz,
|
||||
DurationMinutes: len(quizQuestions) * 2,
|
||||
OrderIndex: orderIdx,
|
||||
QuizQuestions: academyQuiz,
|
||||
})
|
||||
orderIdx++
|
||||
}
|
||||
|
||||
if len(lessons) == 0 {
|
||||
lessons = append(lessons, academy.Lesson{
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
LessonType: academy.LessonTypeText,
|
||||
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
OrderIndex: 0,
|
||||
})
|
||||
}
|
||||
|
||||
course := &academy.Course{
|
||||
TenantID: tenantID,
|
||||
Title: module.Title,
|
||||
Description: module.Description,
|
||||
Category: category,
|
||||
DurationMinutes: module.DurationMinutes,
|
||||
RequiredForRoles: []string{},
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range lessons {
|
||||
lessons[i].CourseID = course.ID
|
||||
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err))
|
||||
}
|
||||
}
|
||||
|
||||
_ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID)
|
||||
generated++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"generated": generated,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"total": len(modules),
|
||||
})
|
||||
}
|
||||
|
||||
// estimateReadingTime estimates reading time in minutes from markdown content
|
||||
// Average reading speed: ~200 words per minute
|
||||
func estimateReadingTime(content string) int {
|
||||
words := len(strings.Fields(content))
|
||||
minutes := words / 200
|
||||
if minutes < 5 {
|
||||
minutes = 5
|
||||
}
|
||||
return minutes
|
||||
}
|
||||
|
||||
@@ -235,6 +235,14 @@ func (s *Store) UpdateModule(ctx context.Context, module *TrainingModule) error
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAcademyCourseID links a training module to an academy course
|
||||
func (s *Store) SetAcademyCourseID(ctx context.Context, moduleID, courseID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_modules SET academy_course_id = $2, updated_at = $3 WHERE id = $1
|
||||
`, moduleID, courseID, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Matrix Operations
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user