diff --git a/admin-compliance/app/(sdk)/sdk/academy/page.tsx b/admin-compliance/app/(sdk)/sdk/academy/page.tsx index acbc0c8..b359b1b 100644 --- a/admin-compliance/app/(sdk)/sdk/academy/page.tsx +++ b/admin-compliance/app/(sdk)/sdk/academy/page.tsx @@ -15,7 +15,7 @@ import { isEnrollmentOverdue, getDaysUntilDeadline } from '@/lib/sdk/academy/types' -import { fetchSDKAcademyList } from '@/lib/sdk/academy/api' +import { fetchSDKAcademyList, generateAllCourses } from '@/lib/sdk/academy/api' // ============================================================================= // TYPES @@ -359,6 +359,8 @@ export default function AcademyPage() { const [enrollments, setEnrollments] = useState([]) const [statistics, setStatistics] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [isGenerating, setIsGenerating] = useState(false) + const [generateResult, setGenerateResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null) // Filters const [selectedCategory, setSelectedCategory] = useState('all') @@ -366,19 +368,6 @@ export default function AcademyPage() { // Load data from SDK backend useEffect(() => { - const loadData = async () => { - setIsLoading(true) - try { - const data = await fetchSDKAcademyList() - setCourses(data.courses) - setEnrollments(data.enrollments) - setStatistics(data.statistics) - } catch (error) { - console.error('Failed to load Academy data:', error) - } finally { - setIsLoading(false) - } - } loadData() }, []) @@ -446,6 +435,36 @@ export default function AcademyPage() { const stepInfo = STEP_EXPLANATIONS['academy'] + const loadData = async () => { + setIsLoading(true) + try { + const data = await fetchSDKAcademyList() + setCourses(data.courses) + setEnrollments(data.enrollments) + setStatistics(data.statistics) + } catch (error) { + console.error('Failed to load Academy data:', error) + } finally { + setIsLoading(false) + } + } + + const handleGenerateAll = async () => { + setIsGenerating(true) + setGenerateResult(null) + try { + const result = await generateAllCourses() + setGenerateResult({ generated: result.generated, skipped: result.skipped, errors: result.errors || [] }) + // Reload data to show new courses + await loadData() + } catch (error) { + console.error('Failed to generate courses:', error) + setGenerateResult({ generated: 0, skipped: 0, errors: [error instanceof Error ? error.message : 'Fehler bei der Generierung'] }) + } finally { + setIsGenerating(false) + } + } + const clearFilters = () => { setSelectedCategory('all') setSelectedStatus('all') @@ -461,17 +480,54 @@ export default function AcademyPage() { explanation={stepInfo?.explanation} tips={stepInfo?.tips} > - - - - - Kurs erstellen - +
+ + + + + + Kurs erstellen + +
+ {/* Generation Result */} + {generateResult && ( +
0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}> +
+ {generateResult.generated} Kurse generiert + {generateResult.skipped} uebersprungen + {generateResult.errors.length > 0 && ( + {generateResult.errors.length} Fehler + )} +
+ {generateResult.errors.length > 0 && ( +
+ {generateResult.errors.map((err, i) =>
{err}
)} +
+ )} +
+ )} + {/* Tab Navigation */} Generiert: {bulkResult.generated} Uebersprungen: {bulkResult.skipped} - {bulkResult.errors.length > 0 && ( + {bulkResult.errors?.length > 0 && ( Fehler: {bulkResult.errors.length} )} - {bulkResult.errors.length > 0 && ( + {bulkResult.errors?.length > 0 && (
{bulkResult.errors.map((err, i) =>
{err}
)}
diff --git a/admin-compliance/lib/sdk/academy/api.ts b/admin-compliance/lib/sdk/academy/api.ts index c9b90f4..253d616 100644 --- a/admin-compliance/lib/sdk/academy/api.ts +++ b/admin-compliance/lib/sdk/academy/api.ts @@ -5,7 +5,7 @@ * Connects to the ai-compliance-sdk backend via Next.js proxy */ -import { +import type { Course, CourseCategory, CourseCreateRequest, @@ -18,10 +18,11 @@ import { UpdateProgressRequest, Certificate, AcademyStatistics, + LessonType, SubmitQuizRequest, SubmitQuizResponse, - isEnrollmentOverdue } from './types' +import { isEnrollmentOverdue } from './types' // ============================================================================= // CONFIGURATION @@ -111,18 +112,72 @@ async function fetchWithTimeout( * Alle Kurse abrufen */ export async function fetchCourses(): Promise { - return fetchWithTimeout( + const res = await fetchWithTimeout<{ courses: Course[]; total: number }>( `${ACADEMY_API_BASE}/courses` ) + return mapCoursesFromBackend(res.courses || []) } /** * Einzelnen Kurs abrufen */ export async function fetchCourse(id: string): Promise { - return fetchWithTimeout( + const res = await fetchWithTimeout<{ course: BackendCourse }>( `${ACADEMY_API_BASE}/courses/${id}` ) + return mapCourseFromBackend(res.course) +} + +// Backend returns snake_case, frontend uses camelCase +interface BackendCourse { + id: string + title: string + description: string + category: CourseCategory + duration_minutes: number + required_for_roles: string[] + is_active: boolean + lessons?: BackendLesson[] + created_at: string + updated_at: string +} + +interface BackendLesson { + id: string + course_id: string + title: string + description?: string + lesson_type: LessonType + content_url?: string + duration_minutes: number + order_index: number + quiz_questions?: Array<{ question: string; options: string[]; correct_index: number; explanation: string }> +} + +function mapCourseFromBackend(bc: BackendCourse): Course { + return { + id: bc.id, + title: bc.title, + description: bc.description || '', + category: bc.category, + durationMinutes: bc.duration_minutes || 0, + requiredForRoles: bc.required_for_roles || [], + lessons: (bc.lessons || []).map(l => ({ + id: l.id, + courseId: l.course_id, + title: l.title, + type: l.lesson_type, + contentMarkdown: l.content_url || '', + durationMinutes: l.duration_minutes || 0, + order: l.order_index, + })), + createdAt: bc.created_at, + updatedAt: bc.updated_at, + } +} + +function mapCoursesFromBackend(courses: BackendCourse[]): Course[] { + return courses.map(mapCourseFromBackend) } /** @@ -173,12 +228,13 @@ export async function deleteCourse(id: string): Promise { export async function fetchEnrollments(courseId?: string): Promise { const params = new URLSearchParams() if (courseId) { - params.set('courseId', courseId) + params.set('course_id', courseId) } const queryString = params.toString() const url = `${ACADEMY_API_BASE}/enrollments${queryString ? `?${queryString}` : ''}` - return fetchWithTimeout(url) + const res = await fetchWithTimeout<{ enrollments: Enrollment[]; total: number }>(url) + return res.enrollments || [] } /** @@ -269,9 +325,22 @@ export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest): * Academy-Statistiken abrufen */ export async function fetchAcademyStatistics(): Promise { - return fetchWithTimeout( - `${ACADEMY_API_BASE}/statistics` - ) + const res = await fetchWithTimeout<{ + total_courses: number + total_enrollments: number + completion_rate: number + overdue_count: number + avg_completion_days: number + }>(`${ACADEMY_API_BASE}/stats`) + + return { + totalCourses: res.total_courses || 0, + totalEnrollments: res.total_enrollments || 0, + completionRate: res.completion_rate || 0, + overdueCount: res.overdue_count || 0, + byCategory: {} as Record, + byStatus: {} as Record, + } } // ============================================================================= @@ -279,16 +348,27 @@ export async function fetchAcademyStatistics(): Promise { // ============================================================================= /** - * KI-gestuetzten Kurs generieren (nutzt intern Training Content Pipeline) + * Academy-Kurs aus einem Training-Modul generieren */ -export async function generateCourse(request: GenerateCourseRequest): Promise { - return fetchWithTimeout( +export async function generateCourse(request: GenerateCourseRequest): Promise<{ course: Course }> { + return fetchWithTimeout<{ course: Course }>( `${ACADEMY_API_BASE}/courses/generate`, { method: 'POST', - body: JSON.stringify(request) + body: JSON.stringify({ module_id: request.moduleId || request.title }) }, - 120000 // 2 min timeout for LLM generation + 120000 // 2 min timeout + ) +} + +/** + * Alle Academy-Kurse aus Training-Modulen generieren + */ +export async function generateAllCourses(): Promise<{ generated: number; skipped: number; errors: string[]; total: number }> { + return fetchWithTimeout( + `${ACADEMY_API_BASE}/courses/generate-all`, + { method: 'POST' }, + 300000 // 5 min timeout ) } diff --git a/admin-compliance/lib/sdk/academy/types.ts b/admin-compliance/lib/sdk/academy/types.ts index a9d7317..13a7354 100644 --- a/admin-compliance/lib/sdk/academy/types.ts +++ b/admin-compliance/lib/sdk/academy/types.ts @@ -216,8 +216,9 @@ export interface CourseUpdateRequest { } export interface GenerateCourseRequest { - title: string - category: CourseCategory + moduleId: string + title?: string + category?: CourseCategory description?: string regulationArea?: string language?: string diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go index 6e7efa7..68d906c 100644 --- a/ai-compliance-sdk/cmd/server/main.go +++ b/ai-compliance-sdk/cmd/server/main.go @@ -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 diff --git a/ai-compliance-sdk/internal/api/handlers/academy_handlers.go b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go index 8f4126e..25c3ef0 100644 --- a/ai-compliance-sdk/internal/api/handlers/academy_handlers.go +++ b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/training/store.go b/ai-compliance-sdk/internal/training/store.go index 6d5c0b0..4e4890e 100644 --- a/ai-compliance-sdk/internal/training/store.go +++ b/ai-compliance-sdk/internal/training/store.go @@ -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 // ============================================================================