feat: Implement Compliance Academy E-Learning module (Phases 1-7)
Some checks failed
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
ci/woodpecker/push/integration Pipeline failed
ci/woodpecker/push/main Pipeline failed
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled

Add complete Academy backend (Go) and frontend (Next.js) for DSGVO/IT-Security/AI-Literacy compliance training:
- Go backend: Course CRUD, enrollments, quiz evaluation, PDF certificates (gofpdf), video generation pipeline (ElevenLabs + HeyGen)
- In-memory data store with PostgreSQL migration for future DB support
- Frontend: Course creation (AI + manual), lesson viewer, interactive quiz, certificate viewer with PDF download
- Fix existing compile errors in generate.go (SearchResult type mismatch), llm/service.go (unused var), rag/service.go (Unicode chars)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-14 21:18:51 +01:00
parent 71cde313d5
commit ac1bb1d97b
19 changed files with 4070 additions and 22 deletions

View File

@@ -0,0 +1,152 @@
package academy
import (
"bytes"
"fmt"
"time"
"github.com/jung-kurt/gofpdf"
)
// CertificateData holds all data needed to generate a certificate PDF
type CertificateData struct {
CertificateID string
UserName string
CourseName string
CompanyName string
Score int
IssuedAt time.Time
ValidUntil time.Time
}
// GenerateCertificatePDF generates a PDF certificate and returns the bytes
func GenerateCertificatePDF(data CertificateData) ([]byte, error) {
pdf := gofpdf.New("L", "mm", "A4", "") // Landscape A4
pdf.SetAutoPageBreak(false, 0)
pdf.AddPage()
pageWidth, pageHeight := pdf.GetPageSize()
// Background color - light gray
pdf.SetFillColor(250, 250, 252)
pdf.Rect(0, 0, pageWidth, pageHeight, "F")
// Border - decorative
pdf.SetDrawColor(79, 70, 229) // Purple/Indigo
pdf.SetLineWidth(3)
pdf.Rect(10, 10, pageWidth-20, pageHeight-20, "D")
pdf.SetLineWidth(1)
pdf.Rect(14, 14, pageWidth-28, pageHeight-28, "D")
// Header - Company/BreakPilot Logo area
companyName := data.CompanyName
if companyName == "" {
companyName = "BreakPilot Compliance"
}
pdf.SetFont("Helvetica", "", 12)
pdf.SetTextColor(120, 120, 120)
pdf.SetXY(0, 25)
pdf.CellFormat(pageWidth, 10, companyName, "", 0, "C", false, 0, "")
// Title
pdf.SetFont("Helvetica", "B", 32)
pdf.SetTextColor(30, 30, 30)
pdf.SetXY(0, 42)
pdf.CellFormat(pageWidth, 15, "SCHULUNGSZERTIFIKAT", "", 0, "C", false, 0, "")
// Decorative line
pdf.SetDrawColor(79, 70, 229)
pdf.SetLineWidth(1.5)
lineY := 62.0
pdf.Line(pageWidth/2-60, lineY, pageWidth/2+60, lineY)
// "Hiermit wird bescheinigt, dass"
pdf.SetFont("Helvetica", "", 13)
pdf.SetTextColor(80, 80, 80)
pdf.SetXY(0, 72)
pdf.CellFormat(pageWidth, 8, "Hiermit wird bescheinigt, dass", "", 0, "C", false, 0, "")
// Name
pdf.SetFont("Helvetica", "B", 26)
pdf.SetTextColor(30, 30, 30)
pdf.SetXY(0, 85)
pdf.CellFormat(pageWidth, 12, data.UserName, "", 0, "C", false, 0, "")
// "die folgende Compliance-Schulung erfolgreich abgeschlossen hat:"
pdf.SetFont("Helvetica", "", 13)
pdf.SetTextColor(80, 80, 80)
pdf.SetXY(0, 103)
pdf.CellFormat(pageWidth, 8, "die folgende Compliance-Schulung erfolgreich abgeschlossen hat:", "", 0, "C", false, 0, "")
// Course Name
pdf.SetFont("Helvetica", "B", 20)
pdf.SetTextColor(79, 70, 229)
pdf.SetXY(0, 116)
pdf.CellFormat(pageWidth, 10, data.CourseName, "", 0, "C", false, 0, "")
// Score
if data.Score > 0 {
pdf.SetFont("Helvetica", "", 12)
pdf.SetTextColor(80, 80, 80)
pdf.SetXY(0, 130)
pdf.CellFormat(pageWidth, 8, fmt.Sprintf("Testergebnis: %d%%", data.Score), "", 0, "C", false, 0, "")
}
// Bottom section - Dates and Signature
bottomY := 148.0
// Left: Issued Date
pdf.SetFont("Helvetica", "", 10)
pdf.SetTextColor(100, 100, 100)
pdf.SetXY(40, bottomY)
pdf.CellFormat(80, 6, fmt.Sprintf("Abschlussdatum: %s", data.IssuedAt.Format("02.01.2006")), "", 0, "L", false, 0, "")
// Center: Valid Until
pdf.SetXY(pageWidth/2-40, bottomY)
pdf.CellFormat(80, 6, fmt.Sprintf("Gueltig bis: %s", data.ValidUntil.Format("02.01.2006")), "", 0, "C", false, 0, "")
// Right: Certificate ID
pdf.SetXY(pageWidth-120, bottomY)
pdf.CellFormat(80, 6, fmt.Sprintf("Zertifikats-Nr.: %s", data.CertificateID[:min(12, len(data.CertificateID))]), "", 0, "R", false, 0, "")
// Signature line
sigY := 162.0
pdf.SetDrawColor(150, 150, 150)
pdf.SetLineWidth(0.5)
// Left signature
pdf.Line(50, sigY, 130, sigY)
pdf.SetFont("Helvetica", "", 9)
pdf.SetTextColor(120, 120, 120)
pdf.SetXY(50, sigY+2)
pdf.CellFormat(80, 5, "Datenschutzbeauftragter", "", 0, "C", false, 0, "")
// Right signature
pdf.Line(pageWidth-130, sigY, pageWidth-50, sigY)
pdf.SetXY(pageWidth-130, sigY+2)
pdf.CellFormat(80, 5, "Geschaeftsfuehrung", "", 0, "C", false, 0, "")
// Footer
pdf.SetFont("Helvetica", "", 8)
pdf.SetTextColor(160, 160, 160)
pdf.SetXY(0, pageHeight-22)
pdf.CellFormat(pageWidth, 5, "Dieses Zertifikat wurde elektronisch erstellt und ist ohne Unterschrift gueltig.", "", 0, "C", false, 0, "")
pdf.SetXY(0, pageHeight-17)
pdf.CellFormat(pageWidth, 5, fmt.Sprintf("Verifizierung unter: https://compliance.breakpilot.de/verify/%s", data.CertificateID), "", 0, "C", false, 0, "")
// Generate PDF bytes
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("failed to generate PDF: %w", err)
}
return buf.Bytes(), nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,105 @@
package academy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// ElevenLabsClient handles text-to-speech via the ElevenLabs API
type ElevenLabsClient struct {
apiKey string
voiceID string
client *http.Client
}
// NewElevenLabsClient creates a new ElevenLabs client
func NewElevenLabsClient() *ElevenLabsClient {
apiKey := os.Getenv("ELEVENLABS_API_KEY")
voiceID := os.Getenv("ELEVENLABS_VOICE_ID")
if voiceID == "" {
voiceID = "EXAVITQu4vr4xnSDxMaL" // Default: "Sarah" voice
}
return &ElevenLabsClient{
apiKey: apiKey,
voiceID: voiceID,
client: &http.Client{
Timeout: 120 * time.Second,
},
}
}
// IsConfigured returns true if API key is set
func (c *ElevenLabsClient) IsConfigured() bool {
return c.apiKey != ""
}
// TextToSpeechRequest represents the API request
type TextToSpeechRequest struct {
Text string `json:"text"`
ModelID string `json:"model_id"`
VoiceSettings VoiceSettings `json:"voice_settings"`
}
// VoiceSettings controls voice parameters
type VoiceSettings struct {
Stability float64 `json:"stability"`
SimilarityBoost float64 `json:"similarity_boost"`
Style float64 `json:"style"`
}
// TextToSpeech converts text to speech audio (MP3)
func (c *ElevenLabsClient) TextToSpeech(text string) ([]byte, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("ElevenLabs API key not configured")
}
url := fmt.Sprintf("https://api.elevenlabs.io/v1/text-to-speech/%s", c.voiceID)
reqBody := TextToSpeechRequest{
Text: text,
ModelID: "eleven_multilingual_v2",
VoiceSettings: VoiceSettings{
Stability: 0.5,
SimilarityBoost: 0.75,
Style: 0.5,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("xi-api-key", c.apiKey)
req.Header.Set("Accept", "audio/mpeg")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("ElevenLabs API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ElevenLabs API error %d: %s", resp.StatusCode, string(body))
}
audioData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read audio response: %w", err)
}
return audioData, nil
}

View File

@@ -0,0 +1,184 @@
package academy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
// HeyGenClient handles avatar video generation via the HeyGen API
type HeyGenClient struct {
apiKey string
avatarID string
client *http.Client
}
// NewHeyGenClient creates a new HeyGen client
func NewHeyGenClient() *HeyGenClient {
apiKey := os.Getenv("HEYGEN_API_KEY")
avatarID := os.Getenv("HEYGEN_AVATAR_ID")
if avatarID == "" {
avatarID = "josh_lite3_20230714" // Default avatar
}
return &HeyGenClient{
apiKey: apiKey,
avatarID: avatarID,
client: &http.Client{
Timeout: 300 * time.Second, // Video generation can take time
},
}
}
// IsConfigured returns true if API key is set
func (c *HeyGenClient) IsConfigured() bool {
return c.apiKey != ""
}
// CreateVideoRequest represents the HeyGen API request
type CreateVideoRequest struct {
VideoInputs []VideoInput `json:"video_inputs"`
Dimension Dimension `json:"dimension"`
}
// VideoInput represents a single video segment
type VideoInput struct {
Character Character `json:"character"`
Voice VideoVoice `json:"voice"`
}
// Character represents the avatar
type Character struct {
Type string `json:"type"`
AvatarID string `json:"avatar_id"`
}
// VideoVoice represents the voice/audio source
type VideoVoice struct {
Type string `json:"type"` // "audio" for pre-generated audio
AudioURL string `json:"audio_url,omitempty"`
InputText string `json:"input_text,omitempty"`
}
// Dimension represents video dimensions
type Dimension struct {
Width int `json:"width"`
Height int `json:"height"`
}
// CreateVideoResponse represents the HeyGen API response
type CreateVideoResponse struct {
Data struct {
VideoID string `json:"video_id"`
} `json:"data"`
Error interface{} `json:"error"`
}
// HeyGenVideoStatus represents video status from HeyGen
type HeyGenVideoStatus struct {
Data struct {
Status string `json:"status"` // processing, completed, failed
VideoURL string `json:"video_url"`
} `json:"data"`
}
// CreateVideo creates a video with the avatar and audio
func (c *HeyGenClient) CreateVideo(audioURL string) (*CreateVideoResponse, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("HeyGen API key not configured")
}
url := "https://api.heygen.com/v2/video/generate"
reqBody := CreateVideoRequest{
VideoInputs: []VideoInput{
{
Character: Character{
Type: "avatar",
AvatarID: c.avatarID,
},
Voice: VideoVoice{
Type: "audio",
AudioURL: audioURL,
},
},
},
Dimension: Dimension{
Width: 1920,
Height: 1080,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("HeyGen API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("HeyGen API error %d: %s", resp.StatusCode, string(body))
}
var result CreateVideoResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
// GetVideoStatus checks the status of a video generation job
func (c *HeyGenClient) GetVideoStatus(videoID string) (*HeyGenVideoStatus, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("HeyGen API key not configured")
}
url := fmt.Sprintf("https://api.heygen.com/v1/video_status.get?video_id=%s", videoID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-Api-Key", c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("HeyGen API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var status HeyGenVideoStatus
if err := json.Unmarshal(body, &status); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &status, nil
}

View File

@@ -0,0 +1,91 @@
package academy
import (
"fmt"
"log"
)
// VideoGenerator orchestrates video generation with 3-tier fallback:
// 1. HeyGen + ElevenLabs -> Avatar video with voice
// 2. ElevenLabs only -> Audio podcast style
// 3. No external services -> Text + Quiz only
type VideoGenerator struct {
elevenLabs *ElevenLabsClient
heyGen *HeyGenClient
}
// NewVideoGenerator creates a new video generator
func NewVideoGenerator() *VideoGenerator {
return &VideoGenerator{
elevenLabs: NewElevenLabsClient(),
heyGen: NewHeyGenClient(),
}
}
// GenerationMode describes the available generation mode
type GenerationMode string
const (
ModeAvatarVideo GenerationMode = "avatar_video" // HeyGen + ElevenLabs
ModeAudioOnly GenerationMode = "audio_only" // ElevenLabs only
ModeTextOnly GenerationMode = "text_only" // No external services
)
// GetAvailableMode returns the best available generation mode
func (vg *VideoGenerator) GetAvailableMode() GenerationMode {
if vg.heyGen.IsConfigured() && vg.elevenLabs.IsConfigured() {
return ModeAvatarVideo
}
if vg.elevenLabs.IsConfigured() {
return ModeAudioOnly
}
return ModeTextOnly
}
// GenerateAudio generates audio from text using ElevenLabs
func (vg *VideoGenerator) GenerateAudio(text string) ([]byte, error) {
if !vg.elevenLabs.IsConfigured() {
return nil, fmt.Errorf("ElevenLabs not configured")
}
log.Printf("Generating audio for text (%d chars)...", len(text))
return vg.elevenLabs.TextToSpeech(text)
}
// GenerateVideo generates a video from audio using HeyGen
func (vg *VideoGenerator) GenerateVideo(audioURL string) (string, error) {
if !vg.heyGen.IsConfigured() {
return "", fmt.Errorf("HeyGen not configured")
}
log.Printf("Creating HeyGen video with audio: %s", audioURL)
resp, err := vg.heyGen.CreateVideo(audioURL)
if err != nil {
return "", err
}
return resp.Data.VideoID, nil
}
// CheckVideoStatus checks if a HeyGen video is ready
func (vg *VideoGenerator) CheckVideoStatus(videoID string) (string, string, error) {
if !vg.heyGen.IsConfigured() {
return "", "", fmt.Errorf("HeyGen not configured")
}
status, err := vg.heyGen.GetVideoStatus(videoID)
if err != nil {
return "", "", err
}
return status.Data.Status, status.Data.VideoURL, nil
}
// GetStatus returns the configuration status
func (vg *VideoGenerator) GetStatus() map[string]interface{} {
return map[string]interface{}{
"mode": string(vg.GetAvailableMode()),
"elevenLabsConfigured": vg.elevenLabs.IsConfigured(),
"heyGenConfigured": vg.heyGen.IsConfigured(),
}
}