- 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>
492 lines
15 KiB
Go
492 lines
15 KiB
Go
package training
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ContentGenerator generates training content and quiz questions via LLM
|
|
type ContentGenerator struct {
|
|
registry *llm.ProviderRegistry
|
|
piiDetector *llm.PIIDetector
|
|
store *Store
|
|
ttsClient *TTSClient
|
|
}
|
|
|
|
// NewContentGenerator creates a new content generator
|
|
func NewContentGenerator(registry *llm.ProviderRegistry, piiDetector *llm.PIIDetector, store *Store, ttsClient *TTSClient) *ContentGenerator {
|
|
return &ContentGenerator{
|
|
registry: registry,
|
|
piiDetector: piiDetector,
|
|
store: store,
|
|
ttsClient: ttsClient,
|
|
}
|
|
}
|
|
|
|
// GenerateModuleContent generates training content for a module via LLM
|
|
func (g *ContentGenerator) GenerateModuleContent(ctx context.Context, module TrainingModule, language string) (*ModuleContent, error) {
|
|
if language == "" {
|
|
language = "de"
|
|
}
|
|
|
|
prompt := buildContentPrompt(module, 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: 4096,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM content generation failed: %w", err)
|
|
}
|
|
|
|
contentBody := resp.Message.Content
|
|
|
|
// PII check on generated content
|
|
if g.piiDetector != nil && g.piiDetector.ContainsPII(contentBody) {
|
|
findings := g.piiDetector.FindPII(contentBody)
|
|
for _, f := range findings {
|
|
contentBody = strings.ReplaceAll(contentBody, f.Match, "[REDACTED]")
|
|
}
|
|
}
|
|
|
|
// Create summary (first 200 chars)
|
|
summary := contentBody
|
|
if len(summary) > 200 {
|
|
summary = summary[:200] + "..."
|
|
}
|
|
|
|
content := &ModuleContent{
|
|
ModuleID: module.ID,
|
|
ContentFormat: ContentFormatMarkdown,
|
|
ContentBody: contentBody,
|
|
Summary: summary,
|
|
GeneratedBy: "llm_" + resp.Provider,
|
|
LLMModel: resp.Model,
|
|
IsPublished: false,
|
|
}
|
|
|
|
if err := g.store.CreateModuleContent(ctx, content); err != nil {
|
|
return nil, fmt.Errorf("failed to save 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,
|
|
},
|
|
})
|
|
|
|
return content, nil
|
|
}
|
|
|
|
// GenerateQuizQuestions generates quiz questions for a module based on its content
|
|
func (g *ContentGenerator) GenerateQuizQuestions(ctx context.Context, module TrainingModule, count int) ([]QuizQuestion, error) {
|
|
if count <= 0 {
|
|
count = 5
|
|
}
|
|
|
|
// Get the published content for context
|
|
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contentContext := ""
|
|
if content != nil {
|
|
contentContext = content.ContentBody
|
|
}
|
|
|
|
prompt := buildQuizPrompt(module, contentContext, count)
|
|
|
|
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
|
Messages: []llm.Message{
|
|
{Role: "system", Content: getQuizSystemPrompt()},
|
|
{Role: "user", Content: prompt},
|
|
},
|
|
Temperature: 0.2,
|
|
MaxTokens: 4096,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("LLM quiz generation failed: %w", err)
|
|
}
|
|
|
|
// Parse the JSON response
|
|
questions, err := parseQuizResponse(resp.Message.Content, module.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse quiz response: %w", err)
|
|
}
|
|
|
|
// Save questions to store
|
|
for i := range questions {
|
|
questions[i].SortOrder = i + 1
|
|
if err := g.store.CreateQuizQuestion(ctx, &questions[i]); err != nil {
|
|
return nil, fmt.Errorf("failed to save question %d: %w", i+1, err)
|
|
}
|
|
}
|
|
|
|
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
|
|
// ============================================================================
|
|
|
|
func getContentSystemPrompt(language string) string {
|
|
if language == "en" {
|
|
return "You are a compliance training content expert. Generate professional, accurate training material in Markdown format. Focus on practical relevance and legal accuracy. Do not include any personal data or fictional names."
|
|
}
|
|
return "Du bist ein Experte fuer Compliance-Schulungsinhalte. Erstelle professionelle, praezise Schulungsmaterialien im Markdown-Format. Fokussiere dich auf praktische Relevanz und rechtliche Genauigkeit. Verwende keine personenbezogenen Daten oder fiktiven Namen."
|
|
}
|
|
|
|
func getQuizSystemPrompt() string {
|
|
return `Du bist ein Experte fuer Compliance-Pruefungsfragen. Erstelle Multiple-Choice-Fragen als JSON-Array.
|
|
Jede Frage hat genau 4 Antwortoptionen, davon genau eine richtige.
|
|
Antworte NUR mit dem JSON-Array, ohne zusaetzlichen Text.
|
|
|
|
Format:
|
|
[
|
|
{
|
|
"question": "Frage hier?",
|
|
"options": ["Option A", "Option B", "Option C", "Option D"],
|
|
"correct_index": 0,
|
|
"explanation": "Erklaerung warum Option A richtig ist.",
|
|
"difficulty": "medium"
|
|
}
|
|
]`
|
|
}
|
|
|
|
func buildContentPrompt(module TrainingModule, language string) string {
|
|
regulationLabels := map[RegulationArea]string{
|
|
RegulationDSGVO: "Datenschutz-Grundverordnung (DSGVO)",
|
|
RegulationNIS2: "NIS-2-Richtlinie",
|
|
RegulationISO27001: "ISO 27001 / ISMS",
|
|
RegulationAIAct: "EU AI Act / KI-Verordnung",
|
|
RegulationGeschGehG: "Geschaeftsgeheimnisgesetz (GeschGehG)",
|
|
RegulationHinSchG: "Hinweisgeberschutzgesetz (HinSchG)",
|
|
}
|
|
|
|
regulation := regulationLabels[module.RegulationArea]
|
|
if regulation == "" {
|
|
regulation = string(module.RegulationArea)
|
|
}
|
|
|
|
return fmt.Sprintf(`Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:
|
|
|
|
**Modulcode:** %s
|
|
**Titel:** %s
|
|
**Beschreibung:** %s
|
|
**Regulierungsbereich:** %s
|
|
**Dauer:** %d Minuten
|
|
**NIS2-relevant:** %v
|
|
|
|
Das Material soll:
|
|
1. Eine kurze Einfuehrung in das Thema geben
|
|
2. Die wichtigsten rechtlichen Grundlagen erklaeren
|
|
3. Praktische Handlungsanweisungen fuer den Arbeitsalltag enthalten
|
|
4. Typische Fehler und Risiken aufzeigen
|
|
5. Eine Zusammenfassung der Kernpunkte bieten
|
|
|
|
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
|
|
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`,
|
|
module.ModuleCode, module.Title, module.Description,
|
|
regulation, module.DurationMinutes, module.NIS2Relevant)
|
|
}
|
|
|
|
func buildQuizPrompt(module TrainingModule, contentContext string, count int) string {
|
|
prompt := fmt.Sprintf(`Erstelle %d Multiple-Choice-Pruefungsfragen fuer das Compliance-Modul:
|
|
|
|
**Modulcode:** %s
|
|
**Titel:** %s
|
|
**Regulierungsbereich:** %s`, count, module.ModuleCode, module.Title, string(module.RegulationArea))
|
|
|
|
if contentContext != "" {
|
|
// Truncate content to avoid token limit
|
|
if len(contentContext) > 3000 {
|
|
contentContext = contentContext[:3000] + "..."
|
|
}
|
|
prompt += fmt.Sprintf(`
|
|
|
|
**Schulungsinhalt als Kontext:**
|
|
%s`, contentContext)
|
|
}
|
|
|
|
prompt += fmt.Sprintf(`
|
|
|
|
Erstelle genau %d Fragen mit je 4 Antwortoptionen.
|
|
Verteile die Schwierigkeitsgrade: easy, medium, hard.
|
|
Antworte NUR mit dem JSON-Array.`, count)
|
|
|
|
return prompt
|
|
}
|
|
|
|
// buildBlockContentPrompt creates a prompt that incorporates canonical controls
|
|
func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string {
|
|
var sb strings.Builder
|
|
|
|
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
|
|
func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, error) {
|
|
// Try to extract JSON from the response (LLM might add text around it)
|
|
jsonStr := response
|
|
start := strings.Index(response, "[")
|
|
end := strings.LastIndex(response, "]")
|
|
if start >= 0 && end > start {
|
|
jsonStr = response[start : end+1]
|
|
}
|
|
|
|
type rawQuestion struct {
|
|
Question string `json:"question"`
|
|
Options []string `json:"options"`
|
|
CorrectIndex int `json:"correct_index"`
|
|
Explanation string `json:"explanation"`
|
|
Difficulty string `json:"difficulty"`
|
|
}
|
|
|
|
var rawQuestions []rawQuestion
|
|
if err := json.Unmarshal([]byte(jsonStr), &rawQuestions); err != nil {
|
|
return nil, fmt.Errorf("invalid JSON from LLM: %w", err)
|
|
}
|
|
|
|
var questions []QuizQuestion
|
|
for _, rq := range rawQuestions {
|
|
difficulty := Difficulty(rq.Difficulty)
|
|
if difficulty != DifficultyEasy && difficulty != DifficultyMedium && difficulty != DifficultyHard {
|
|
difficulty = DifficultyMedium
|
|
}
|
|
|
|
q := QuizQuestion{
|
|
ModuleID: moduleID,
|
|
Question: rq.Question,
|
|
Options: rq.Options,
|
|
CorrectIndex: rq.CorrectIndex,
|
|
Explanation: rq.Explanation,
|
|
Difficulty: difficulty,
|
|
IsActive: true,
|
|
}
|
|
|
|
if len(q.Options) != 4 {
|
|
continue // Skip malformed questions
|
|
}
|
|
if q.CorrectIndex < 0 || q.CorrectIndex >= len(q.Options) {
|
|
continue
|
|
}
|
|
|
|
questions = append(questions, q)
|
|
}
|
|
|
|
if questions == nil {
|
|
questions = []QuizQuestion{}
|
|
}
|
|
|
|
return questions, nil
|
|
}
|
|
|
|
func truncateText(text string, maxLen int) string {
|
|
if len(text) <= maxLen {
|
|
return text
|
|
}
|
|
return text[:maxLen] + "..."
|
|
}
|