From 375914e568ec1784da12a7264e416433ea2e360c Mon Sep 17 00:00:00 2001 From: Benjamin Boenisch Date: Mon, 16 Feb 2026 21:42:33 +0100 Subject: [PATCH] =?UTF-8?q?feat(training):=20add=20Media=20Pipeline=20?= =?UTF-8?q?=E2=80=94=20TTS=20Audio,=20Presentation=20Video,=20Bulk=20Gener?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A: 8 new IT-Security training modules (SEC-PWD, SEC-DESK, SEC-KIAI, SEC-BYOD, SEC-VIDEO, SEC-USB, SEC-INC, SEC-HOME) with CTM entries. Bulk content and quiz generation endpoints for all 28 modules. Phase B: Piper TTS service (Python/FastAPI) for local German speech synthesis. training_media table, TTSClient in Go backend, audio generation endpoints, AudioPlayer component in frontend. MinIO storage integration. Phase C: FFmpeg presentation video pipeline — LLM generates slide scripts, ImageMagick renders 1920x1080 slides, FFmpeg combines with audio to MP4. VideoPlayer and ScriptPreview components in frontend. New files: 15 created, 9 modified - compliance-tts-service/ (Dockerfile, main.py, tts_engine.py, storage.py, slide_renderer.py, video_generator.py) - migrations 014-016 (training engine, IT-security modules, media table) - training package (models, store, content_generator, media, handlers) - frontend (AudioPlayer, VideoPlayer, ScriptPreview, api, types, page) Co-Authored-By: Claude Opus 4.6 --- .../app/(sdk)/sdk/training/page.tsx | 607 ++++++++ .../api/sdk/v1/training/[[...path]]/route.ts | 105 ++ .../components/training/AudioPlayer.tsx | 130 ++ .../components/training/ScriptPreview.tsx | 86 ++ .../components/training/VideoPlayer.tsx | 129 ++ admin-compliance/lib/sdk/training/api.ts | 311 ++++ admin-compliance/lib/sdk/training/types.ts | 309 ++++ ai-compliance-sdk/cmd/server/main.go | 63 + .../api/handlers/training_handlers.go | 1113 ++++++++++++++ ai-compliance-sdk/internal/config/config.go | 3 + .../internal/training/assignment.go | 183 +++ .../internal/training/content_generator.go | 602 ++++++++ .../internal/training/escalation.go | 177 +++ ai-compliance-sdk/internal/training/matrix.go | 127 ++ ai-compliance-sdk/internal/training/media.go | 186 +++ ai-compliance-sdk/internal/training/models.go | 500 +++++++ ai-compliance-sdk/internal/training/store.go | 1277 +++++++++++++++++ .../migrations/014_training_engine.sql | 268 ++++ .../migrations/015_it_security_modules.sql | 84 ++ .../migrations/016_training_media.sql | 28 + compliance-tts-service/Dockerfile | 44 + compliance-tts-service/main.py | 175 +++ compliance-tts-service/requirements.txt | 6 + compliance-tts-service/slide_renderer.py | 132 ++ compliance-tts-service/storage.py | 56 + compliance-tts-service/tts_engine.py | 157 ++ compliance-tts-service/video_generator.py | 127 ++ docker-compose.yml | 30 + 28 files changed, 7015 insertions(+) create mode 100644 admin-compliance/app/(sdk)/sdk/training/page.tsx create mode 100644 admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts create mode 100644 admin-compliance/components/training/AudioPlayer.tsx create mode 100644 admin-compliance/components/training/ScriptPreview.tsx create mode 100644 admin-compliance/components/training/VideoPlayer.tsx create mode 100644 admin-compliance/lib/sdk/training/api.ts create mode 100644 admin-compliance/lib/sdk/training/types.ts create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers.go create mode 100644 ai-compliance-sdk/internal/training/assignment.go create mode 100644 ai-compliance-sdk/internal/training/content_generator.go create mode 100644 ai-compliance-sdk/internal/training/escalation.go create mode 100644 ai-compliance-sdk/internal/training/matrix.go create mode 100644 ai-compliance-sdk/internal/training/media.go create mode 100644 ai-compliance-sdk/internal/training/models.go create mode 100644 ai-compliance-sdk/internal/training/store.go create mode 100644 ai-compliance-sdk/migrations/014_training_engine.sql create mode 100644 ai-compliance-sdk/migrations/015_it_security_modules.sql create mode 100644 ai-compliance-sdk/migrations/016_training_media.sql create mode 100644 compliance-tts-service/Dockerfile create mode 100644 compliance-tts-service/main.py create mode 100644 compliance-tts-service/requirements.txt create mode 100644 compliance-tts-service/slide_renderer.py create mode 100644 compliance-tts-service/storage.py create mode 100644 compliance-tts-service/tts_engine.py create mode 100644 compliance-tts-service/video_generator.py diff --git a/admin-compliance/app/(sdk)/sdk/training/page.tsx b/admin-compliance/app/(sdk)/sdk/training/page.tsx new file mode 100644 index 0000000..f605b29 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/training/page.tsx @@ -0,0 +1,607 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + getModules, getMatrix, getAssignments, getStats, getDeadlines, getModuleMedia, + getAuditLog, generateContent, generateQuiz, + publishContent, checkEscalation, getContent, + generateAllContent, generateAllQuizzes, +} from '@/lib/sdk/training/api' +import type { + TrainingModule, TrainingAssignment, + MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia, VideoScript, +} from '@/lib/sdk/training/types' +import { + REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS, + STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, +} from '@/lib/sdk/training/types' +import AudioPlayer from '@/components/training/AudioPlayer' +import VideoPlayer from '@/components/training/VideoPlayer' +import ScriptPreview from '@/components/training/ScriptPreview' +type Tab = 'overview' | 'modules' | 'matrix' | 'assignments' | 'content' | 'audit' + +export default function TrainingPage() { + const [activeTab, setActiveTab] = useState('overview') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [stats, setStats] = useState(null) + const [modules, setModules] = useState([]) + const [matrix, setMatrix] = useState(null) + const [assignments, setAssignments] = useState([]) + const [deadlines, setDeadlines] = useState([]) + const [auditLog, setAuditLog] = useState([]) + + const [selectedModuleId, setSelectedModuleId] = useState('') + const [generatedContent, setGeneratedContent] = useState(null) + const [generating, setGenerating] = useState(false) + const [bulkGenerating, setBulkGenerating] = useState(false) + const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null) + const [moduleMedia, setModuleMedia] = useState([]) + + const [statusFilter, setStatusFilter] = useState('') + const [regulationFilter, setRegulationFilter] = useState('') + + useEffect(() => { + loadData() + }, []) + + async function loadData() { + setLoading(true) + setError(null) + try { + const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([ + getStats(), + getModules(), + getMatrix(), + getAssignments({ limit: 50 }), + getDeadlines(10), + getAuditLog({ limit: 30 }), + ]) + + if (statsRes.status === 'fulfilled') setStats(statsRes.value) + if (modulesRes.status === 'fulfilled') setModules(modulesRes.value.modules) + if (matrixRes.status === 'fulfilled') setMatrix(matrixRes.value) + if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments) + if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines) + if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden') + } finally { + setLoading(false) + } + } + + async function handleGenerateContent() { + if (!selectedModuleId) return + setGenerating(true) + try { + const content = await generateContent(selectedModuleId) + setGeneratedContent(content) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Content-Generierung') + } finally { + setGenerating(false) + } + } + + async function handleGenerateQuiz() { + if (!selectedModuleId) return + setGenerating(true) + try { + await generateQuiz(selectedModuleId, 5) + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Quiz-Generierung') + } finally { + setGenerating(false) + } + } + + async function handlePublishContent(contentId: string) { + try { + await publishContent(contentId) + setGeneratedContent(null) + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Veroeffentlichen') + } + } + + async function handleCheckEscalation() { + try { + const result = await checkEscalation() + alert(`Eskalation geprueft: ${result.total_checked} geprueft, ${result.escalated} eskaliert`) + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Eskalationspruefung') + } + } + + async function handleLoadContent(moduleId: string) { + try { + const content = await getContent(moduleId) + setGeneratedContent(content) + } catch { + setGeneratedContent(null) + } + } + + async function handleBulkContent() { + setBulkGenerating(true) + setBulkResult(null) + try { + const result = await generateAllContent('de') + setBulkResult(result) + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung') + } finally { + setBulkGenerating(false) + } + } + + async function loadModuleMedia(moduleId: string) { + try { + const result = await getModuleMedia(moduleId) + setModuleMedia(result.media) + } catch { + setModuleMedia([]) + } + } + + async function handleBulkQuiz() { + setBulkGenerating(true) + setBulkResult(null) + try { + const result = await generateAllQuizzes() + setBulkResult(result) + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung') + } finally { + setBulkGenerating(false) + } + } + + const tabs: { id: Tab; label: string }[] = [ + { id: 'overview', label: 'Uebersicht' }, + { id: 'modules', label: 'Modulkatalog' }, + { id: 'matrix', label: 'Training Matrix' }, + { id: 'assignments', label: 'Zuweisungen' }, + { id: 'content', label: 'Content-Generator' }, + { id: 'audit', label: 'Audit Trail' }, + ] + + const filteredModules = modules.filter(m => + (!regulationFilter || m.regulation_area === regulationFilter) + ) + + const filteredAssignments = assignments.filter(a => + (!statusFilter || a.status === statusFilter) + ) + + if (loading) { + return ( +
+
+
+
+ {[1,2,3,4].map(i =>
)} +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Compliance Training Engine

+

+ Training-Module, Zuweisungen und Compliance-Schulungen verwalten +

+
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {/* Tabs */} +
+ +
+ + {/* Tab Content */} + {activeTab === 'overview' && stats && ( +
+
+ + + = 80 ? 'green' : stats.completion_rate >= 50 ? 'yellow' : 'red'} /> + 0 ? 'red' : 'green'} /> +
+ +
+ + + + 5 ? 'yellow' : 'green'} /> +
+ + {/* Status Bar */} +
+

Status-Verteilung

+ {stats.total_assignments > 0 && ( +
+ {stats.completed_count > 0 &&
} + {stats.in_progress_count > 0 &&
} + {stats.pending_count > 0 &&
} + {stats.overdue_count > 0 &&
} +
+ )} +
+ Abgeschlossen + In Bearbeitung + Ausstehend + Ueberfaellig +
+
+ + {/* Deadlines */} + {deadlines.length > 0 && ( +
+

Naechste Deadlines

+
+ {deadlines.slice(0, 5).map(d => ( +
+
+ {d.module_title} + ({d.user_name}) +
+ + {d.days_left <= 0 ? `${Math.abs(d.days_left)} Tage ueberfaellig` : `${d.days_left} Tage`} + +
+ ))} +
+
+ )} +
+ )} + + {activeTab === 'modules' && ( +
+
+ +
+ +
+ {filteredModules.map(m => ( +
+
+
+ + {REGULATION_LABELS[m.regulation_area] || m.regulation_area} + +

{m.title}

+

{m.module_code}

+
+ {m.nis2_relevant && ( + NIS2 + )} +
+ {m.description &&

{m.description}

} +
+ {m.duration_minutes} Min. + {FREQUENCY_LABELS[m.frequency_type]} + Quiz: {m.pass_threshold}% +
+
+ ))} +
+ {filteredModules.length === 0 &&

Keine Module gefunden

} +
+ )} + + {activeTab === 'matrix' && matrix && ( +
+

Compliance Training Matrix (CTM): Welche Rollen benoetigen welche Schulungsmodule

+
+ + + + + + + + + + {ALL_ROLES.map(role => { + const entries = matrix.entries[role] || [] + return ( + + + + + + ) + })} + +
RolleModuleAnzahl
+ {role} + {ROLE_LABELS[role]} + +
+ {entries.map(e => ( + + {e.module_code} + + ))} + {entries.length === 0 && Keine Module} +
+
{entries.length}
+
+
+ )} + + {activeTab === 'assignments' && ( +
+
+ +
+ +
+ + + + + + + + + + + + + + + {filteredAssignments.map(a => ( + + + + + + + + + + + ))} + +
ModulMitarbeiterRolleFortschrittStatusQuizDeadlineEskalation
+
{a.module_title || a.module_code}
+
{a.module_code}
+
+
{a.user_name}
+
{a.user_email}
+
{a.role_code || '-'} +
+
+
+
+ {a.progress_percent}% +
+
+ + {STATUS_LABELS[a.status] || a.status} + + + {a.quiz_score != null ? ( + {a.quiz_score.toFixed(0)}% + ) : '-'} + {new Date(a.deadline).toLocaleDateString('de-DE')} + {a.escalation_level > 0 ? L{a.escalation_level} : '-'} +
+
+ {filteredAssignments.length === 0 &&

Keine Zuweisungen

} +
+ )} + + {activeTab === 'content' && ( +
+ {/* Bulk Generation */} +
+

Bulk-Generierung

+

Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal

+
+ + +
+ {bulkResult && ( +
+
+ Generiert: {bulkResult.generated} + Uebersprungen: {bulkResult.skipped} + {bulkResult.errors.length > 0 && ( + Fehler: {bulkResult.errors.length} + )} +
+ {bulkResult.errors.length > 0 && ( +
+ {bulkResult.errors.map((err, i) =>
{err}
)} +
+ )} +
+ )} +
+ +
+

LLM-Content-Generator

+

Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI

+
+
+ + +
+ + +
+
+ + {generatedContent && ( +
+
+
+

Generierter Inhalt (v{generatedContent.version})

+

Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})

+
+ {!generatedContent.is_published ? ( + + ) : ( + Veroeffentlicht + )} +
+
+
{generatedContent.content_body}
+
+
+ )} + + {/* Audio Player */} + {selectedModuleId && generatedContent?.is_published && ( + m.media_type === 'audio') || null} + onMediaUpdate={() => loadModuleMedia(selectedModuleId)} + /> + )} + + {/* Video Player */} + {selectedModuleId && generatedContent?.is_published && ( + m.media_type === 'video') || null} + onMediaUpdate={() => loadModuleMedia(selectedModuleId)} + /> + )} + + {/* Script Preview */} + {selectedModuleId && generatedContent?.is_published && ( + + )} +
+ )} + + {activeTab === 'audit' && ( +
+
+ + + + + + + + + + + {auditLog.map(entry => ( + + + + + + + ))} + +
ZeitpunktAktionEntitaetDetails
{new Date(entry.created_at).toLocaleString('de-DE')}{entry.action}{entry.entity_type}{JSON.stringify(entry.details).substring(0, 100)}
+
+ {auditLog.length === 0 &&

Keine Audit-Eintraege

} +
+ )} +
+ ) +} + +function KPICard({ label, value, color }: { label: string; value: string | number; color?: string }) { + const colorMap: Record = { + green: 'bg-green-50 border-green-200', + yellow: 'bg-yellow-50 border-yellow-200', + red: 'bg-red-50 border-red-200', + } + const textMap: Record = { + green: 'text-green-700', + yellow: 'text-yellow-700', + red: 'text-red-700', + } + return ( +
+

{label}

+

{value}

+
+ ) +} diff --git a/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts new file mode 100644 index 0000000..bc33fe8 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts @@ -0,0 +1,105 @@ +/** + * Training API Proxy - Catch-all route + * Proxies all /api/sdk/v1/training/* requests to ai-compliance-sdk backend + */ + +import { NextRequest, NextResponse } from 'next/server' + +const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090' + +async function proxyRequest( + request: NextRequest, + pathSegments: string[] | undefined, + method: string +) { + const pathStr = pathSegments?.join('/') || '' + const searchParams = request.nextUrl.searchParams.toString() + const basePath = `${SDK_BACKEND_URL}/sdk/v1/training` + const url = pathStr + ? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}` + : `${basePath}${searchParams ? `?${searchParams}` : ''}` + + try { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + } + + const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug'] + for (const name of headerNames) { + const value = request.headers.get(name) + if (value) { + headers[name] = value + } + } + + const fetchOptions: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(60000), + } + + if (method === 'POST' || method === 'PUT') { + const body = await request.text() + if (body) { + fetchOptions.body = body + } + } + + const response = await fetch(url, fetchOptions) + + if (!response.ok) { + const errorText = await response.text() + let errorJson + try { + errorJson = JSON.parse(errorText) + } catch { + errorJson = { error: errorText } + } + return NextResponse.json( + { error: `Backend Error: ${response.status}`, ...errorJson }, + { status: response.status } + ) + } + + const data = await response.json() + return NextResponse.json(data) + } catch (error) { + console.error('Training API proxy error:', error) + return NextResponse.json( + { error: 'Verbindung zum SDK Backend fehlgeschlagen' }, + { status: 503 } + ) + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const { path } = await params + return proxyRequest(request, path, 'GET') +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const { path } = await params + return proxyRequest(request, path, 'POST') +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const { path } = await params + return proxyRequest(request, path, 'PUT') +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const { path } = await params + return proxyRequest(request, path, 'DELETE') +} diff --git a/admin-compliance/components/training/AudioPlayer.tsx b/admin-compliance/components/training/AudioPlayer.tsx new file mode 100644 index 0000000..f4c8092 --- /dev/null +++ b/admin-compliance/components/training/AudioPlayer.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useState } from 'react' +import type { TrainingMedia } from '@/lib/sdk/training/types' +import { generateAudio, publishMedia } from '@/lib/sdk/training/api' + +interface AudioPlayerProps { + moduleId: string + audio: TrainingMedia | null + onMediaUpdate: () => void +} + +export default function AudioPlayer({ moduleId, audio, onMediaUpdate }: AudioPlayerProps) { + const [generating, setGenerating] = useState(false) + const [error, setError] = useState(null) + + async function handleGenerate() { + setGenerating(true) + setError(null) + try { + await generateAudio(moduleId) + onMediaUpdate() + } catch (e) { + setError(e instanceof Error ? e.message : 'Audio-Generierung fehlgeschlagen') + } finally { + setGenerating(false) + } + } + + async function handlePublishToggle() { + if (!audio) return + try { + await publishMedia(audio.id, !audio.is_published) + onMediaUpdate() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Aendern des Status') + } + } + + function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + + return ( +
+
+

Audio

+ {!audio && ( + + )} +
+ + {error && ( +
{error}
+ )} + + {generating && !audio && ( +
+
+ Audio wird generiert (kann einige Minuten dauern)... +
+ )} + + {audio && audio.status === 'processing' && ( +
+
+ Audio wird verarbeitet... +
+ )} + + {audio && audio.status === 'failed' && ( +
+
Generierung fehlgeschlagen: {audio.error_message}
+ +
+ )} + + {audio && audio.status === 'completed' && ( +
+ +
+
+ Dauer: {formatDuration(audio.duration_seconds)} + Groesse: {formatSize(audio.file_size_bytes)} + Stimme: {audio.voice_model} +
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/components/training/ScriptPreview.tsx b/admin-compliance/components/training/ScriptPreview.tsx new file mode 100644 index 0000000..2f080c6 --- /dev/null +++ b/admin-compliance/components/training/ScriptPreview.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useState } from 'react' +import type { VideoScript } from '@/lib/sdk/training/types' +import { previewVideoScript } from '@/lib/sdk/training/api' + +interface ScriptPreviewProps { + moduleId: string +} + +export default function ScriptPreview({ moduleId }: ScriptPreviewProps) { + const [script, setScript] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function handlePreview() { + setLoading(true) + setError(null) + try { + const result = await previewVideoScript(moduleId) + setScript(result as VideoScript) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Script-Vorschau') + } finally { + setLoading(false) + } + } + + return ( +
+
+

Folien-Vorschau

+ +
+ + {error && ( +
{error}
+ )} + + {loading && ( +
+
+ Folien-Script wird generiert... +
+ )} + + {script && ( +
+
{script.title}
+
+ {script.sections.map((section, i) => ( +
+
+ + {i + 1} + +
{section.heading}
+
+ {section.text && ( +

{section.text}

+ )} + {section.bullet_points && section.bullet_points.length > 0 && ( +
    + {section.bullet_points.map((bp, j) => ( +
  • + {String.fromCharCode(8226)} + {bp} +
  • + ))} +
+ )} +
+ ))} +
+

{script.sections.length} Folien

+
+ )} +
+ ) +} diff --git a/admin-compliance/components/training/VideoPlayer.tsx b/admin-compliance/components/training/VideoPlayer.tsx new file mode 100644 index 0000000..63452d8 --- /dev/null +++ b/admin-compliance/components/training/VideoPlayer.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState } from 'react' +import type { TrainingMedia } from '@/lib/sdk/training/types' +import { generateVideo, publishMedia } from '@/lib/sdk/training/api' + +interface VideoPlayerProps { + moduleId: string + video: TrainingMedia | null + onMediaUpdate: () => void +} + +export default function VideoPlayer({ moduleId, video, onMediaUpdate }: VideoPlayerProps) { + const [generating, setGenerating] = useState(false) + const [error, setError] = useState(null) + + async function handleGenerate() { + setGenerating(true) + setError(null) + try { + await generateVideo(moduleId) + onMediaUpdate() + } catch (e) { + setError(e instanceof Error ? e.message : 'Video-Generierung fehlgeschlagen') + } finally { + setGenerating(false) + } + } + + async function handlePublishToggle() { + if (!video) return + try { + await publishMedia(video.id, !video.is_published) + onMediaUpdate() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Aendern des Status') + } + } + + function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + + return ( +
+
+

Praesentationsvideo

+ {!video && ( + + )} +
+ + {error && ( +
{error}
+ )} + + {generating && !video && ( +
+
+ Video wird generiert (Folien + Audio, kann einige Minuten dauern)... +
+ )} + + {video && video.status === 'processing' && ( +
+
+ Video wird verarbeitet... +
+ )} + + {video && video.status === 'failed' && ( +
+
Generierung fehlgeschlagen: {video.error_message}
+ +
+ )} + + {video && video.status === 'completed' && ( +
+ +
+
+ Dauer: {formatDuration(video.duration_seconds)} + Groesse: {formatSize(video.file_size_bytes)} +
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/lib/sdk/training/api.ts b/admin-compliance/lib/sdk/training/api.ts new file mode 100644 index 0000000..2868d89 --- /dev/null +++ b/admin-compliance/lib/sdk/training/api.ts @@ -0,0 +1,311 @@ +/** + * Training Engine API Client + * Communicates with the Go backend via Next.js API proxy at /api/sdk/v1/training/* + */ + +import type { + ModuleListResponse, + AssignmentListResponse, + MatrixResponse, + AuditLogResponse, + EscalationResponse, + DeadlineListResponse, + TrainingModule, + TrainingAssignment, + ModuleContent, + QuizQuestion, + QuizAttempt, + QuizSubmitResponse, + TrainingStats, + TrainingMedia, +} from './types' + +const BASE_URL = '/api/sdk/v1/training' + +async function apiFetch(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-Tenant-ID': typeof window !== 'undefined' + ? (localStorage.getItem('bp-tenant-id') || 'default') + : 'default', + ...options?.headers, + }, + }) + + if (!res.ok) { + const error = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(error.error || `API Error: ${res.status}`) + } + + return res.json() +} + +// ============================================================================= +// MODULES +// ============================================================================= + +export async function getModules(filters?: { + regulation_area?: string + frequency_type?: string + search?: string +}): Promise { + const params = new URLSearchParams() + if (filters?.regulation_area) params.set('regulation_area', filters.regulation_area) + if (filters?.frequency_type) params.set('frequency_type', filters.frequency_type) + if (filters?.search) params.set('search', filters.search) + const qs = params.toString() + return apiFetch(`/modules${qs ? `?${qs}` : ''}`) +} + +export async function getModule(id: string): Promise<{ + module: TrainingModule + content: ModuleContent | null + questions: QuizQuestion[] +}> { + return apiFetch(`/modules/${id}`) +} + +export async function createModule(data: { + module_code: string + title: string + description?: string + regulation_area: string + frequency_type: string + duration_minutes?: number + pass_threshold?: number +}): Promise { + return apiFetch('/modules', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function updateModule(id: string, data: Record): Promise { + return apiFetch(`/modules/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +// ============================================================================= +// MATRIX +// ============================================================================= + +export async function getMatrix(): Promise { + return apiFetch('/matrix') +} + +export async function getMatrixForRole(role: string): Promise<{ + role: string + label: string + entries: Array<{ module_id: string; module_code: string; module_title: string; is_mandatory: boolean; priority: number }> + total: number +}> { + return apiFetch(`/matrix/${role}`) +} + +export async function setMatrixEntry(data: { + role_code: string + module_id: string + is_mandatory: boolean + priority: number +}): Promise { + return apiFetch('/matrix', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function deleteMatrixEntry(role: string, moduleId: string): Promise { + return apiFetch(`/matrix/${role}/${moduleId}`, { method: 'DELETE' }) +} + +// ============================================================================= +// ASSIGNMENTS +// ============================================================================= + +export async function computeAssignments(data: { + user_id: string + user_name: string + user_email: string + roles: string[] + trigger?: string +}): Promise<{ assignments: TrainingAssignment[]; created: number }> { + return apiFetch('/assignments/compute', { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function getAssignments(filters?: { + user_id?: string + module_id?: string + role?: string + status?: string + limit?: number + offset?: number +}): Promise { + const params = new URLSearchParams() + if (filters?.user_id) params.set('user_id', filters.user_id) + if (filters?.module_id) params.set('module_id', filters.module_id) + if (filters?.role) params.set('role', filters.role) + if (filters?.status) params.set('status', filters.status) + if (filters?.limit) params.set('limit', String(filters.limit)) + if (filters?.offset) params.set('offset', String(filters.offset)) + const qs = params.toString() + return apiFetch(`/assignments${qs ? `?${qs}` : ''}`) +} + +export async function getAssignment(id: string): Promise { + return apiFetch(`/assignments/${id}`) +} + +export async function startAssignment(id: string): Promise<{ status: string }> { + return apiFetch(`/assignments/${id}/start`, { method: 'POST' }) +} + +export async function updateAssignmentProgress(id: string, progress: number): Promise<{ status: string; progress: number }> { + return apiFetch(`/assignments/${id}/progress`, { + method: 'POST', + body: JSON.stringify({ progress }), + }) +} + +export async function completeAssignment(id: string): Promise<{ status: string }> { + return apiFetch(`/assignments/${id}/complete`, { method: 'POST' }) +} + +// ============================================================================= +// QUIZ +// ============================================================================= + +export async function getQuiz(moduleId: string): Promise<{ questions: QuizQuestion[]; total: number }> { + return apiFetch(`/quiz/${moduleId}`) +} + +export async function submitQuiz(moduleId: string, data: { + assignment_id: string + answers: Array<{ question_id: string; selected_index: number }> + duration_seconds?: number +}): Promise { + return apiFetch(`/quiz/${moduleId}/submit`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +export async function getQuizAttempts(assignmentId: string): Promise<{ attempts: QuizAttempt[]; total: number }> { + return apiFetch(`/quiz/attempts/${assignmentId}`) +} + +// ============================================================================= +// CONTENT GENERATION +// ============================================================================= + +export async function generateContent(moduleId: string, language?: string): Promise { + return apiFetch('/content/generate', { + method: 'POST', + body: JSON.stringify({ module_id: moduleId, language: language || 'de' }), + }) +} + +export async function generateQuiz(moduleId: string, count?: number): Promise<{ questions: QuizQuestion[]; total: number }> { + return apiFetch('/content/generate-quiz', { + method: 'POST', + body: JSON.stringify({ module_id: moduleId, count: count || 5 }), + }) +} + +export async function getContent(moduleId: string): Promise { + return apiFetch(`/content/${moduleId}`) +} + +export async function publishContent(contentId: string): Promise<{ status: string }> { + return apiFetch(`/content/${contentId}/publish`, { method: 'POST' }) +} + +// ============================================================================= +// DEADLINES & ESCALATION +// ============================================================================= + +export async function getDeadlines(limit?: number): Promise { + const qs = limit ? `?limit=${limit}` : '' + return apiFetch(`/deadlines${qs}`) +} + +export async function getOverdueDeadlines(): Promise { + return apiFetch('/deadlines/overdue') +} + +export async function checkEscalation(): Promise { + return apiFetch('/escalation/check', { method: 'POST' }) +} + +// ============================================================================= +// AUDIT & STATS +// ============================================================================= + +export async function getAuditLog(filters?: { + action?: string + entity_type?: string + limit?: number + offset?: number +}): Promise { + const params = new URLSearchParams() + if (filters?.action) params.set('action', filters.action) + if (filters?.entity_type) params.set('entity_type', filters.entity_type) + if (filters?.limit) params.set('limit', String(filters.limit)) + if (filters?.offset) params.set('offset', String(filters.offset)) + const qs = params.toString() + return apiFetch(`/audit-log${qs ? `?${qs}` : ''}`) +} + +export async function getStats(): Promise { + return apiFetch('/stats') +} + +// ============================================================================= +// BULK GENERATION +// ============================================================================= + +export async function generateAllContent(language?: string): Promise<{ generated: number; skipped: number; errors: string[] }> { + const qs = language ? `?language=${language}` : '' + return apiFetch(`/content/generate-all${qs}`, { method: 'POST' }) +} + +export async function generateAllQuizzes(): Promise<{ generated: number; skipped: number; errors: string[] }> { + return apiFetch('/content/generate-all-quiz', { method: 'POST' }) +} + +// ============================================================================= +// MEDIA (Audio/Video) +// ============================================================================= + +export async function generateAudio(moduleId: string): Promise { + return apiFetch(`/content/${moduleId}/generate-audio`, { method: 'POST' }) +} + +export async function getModuleMedia(moduleId: string): Promise<{ media: TrainingMedia[]; total: number }> { + return apiFetch(`/media/${moduleId}`) +} + +export async function getMediaURL(mediaId: string): Promise<{ bucket: string; object_key: string; mime_type: string }> { + return apiFetch(`/media/${mediaId}/url`) +} + +export async function publishMedia(mediaId: string, publish?: boolean): Promise<{ status: string; is_published: boolean }> { + return apiFetch(`/media/${mediaId}/publish`, { + method: 'POST', + body: JSON.stringify({ publish: publish !== false }), + }) +} + +export async function generateVideo(moduleId: string): Promise { + return apiFetch(`/content/${moduleId}/generate-video`, { method: 'POST' }) +} + +export async function previewVideoScript(moduleId: string): Promise<{ title: string; sections: Array<{ heading: string; text: string; bullet_points: string[] }> }> { + return apiFetch(`/content/${moduleId}/preview-script`, { method: 'POST' }) +} diff --git a/admin-compliance/lib/sdk/training/types.ts b/admin-compliance/lib/sdk/training/types.ts new file mode 100644 index 0000000..a1fa4fe --- /dev/null +++ b/admin-compliance/lib/sdk/training/types.ts @@ -0,0 +1,309 @@ +/** + * Compliance Training Engine Types + * TypeScript definitions for the Training Matrix, Assignments, Quiz, and Content + */ + +// ============================================================================= +// ENUMS / CONSTANTS +// ============================================================================= + +export type RegulationArea = 'dsgvo' | 'nis2' | 'iso27001' | 'ai_act' | 'geschgehg' | 'hinschg' +export type FrequencyType = 'onboarding' | 'annual' | 'event_trigger' | 'micro' +export type AssignmentStatus = 'pending' | 'in_progress' | 'completed' | 'overdue' | 'expired' +export type TriggerType = 'onboarding' | 'annual' | 'event' | 'manual' +export type Difficulty = 'easy' | 'medium' | 'hard' +export type AuditAction = 'assigned' | 'started' | 'completed' | 'quiz_submitted' | 'escalated' | 'certificate_issued' | 'content_generated' + +export const REGULATION_LABELS: Record = { + dsgvo: 'DSGVO', + nis2: 'NIS-2', + iso27001: 'ISO 27001', + ai_act: 'AI Act', + geschgehg: 'GeschGehG', + hinschg: 'HinSchG', +} + +export const REGULATION_COLORS: Record = { + dsgvo: { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' }, + nis2: { bg: 'bg-purple-100', text: 'text-purple-700', border: 'border-purple-300' }, + iso27001: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300' }, + ai_act: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' }, + geschgehg: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300' }, + hinschg: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' }, +} + +export const FREQUENCY_LABELS: Record = { + onboarding: 'Onboarding', + annual: 'Jaehrlich', + event_trigger: 'Ereignisbasiert', + micro: 'Micro-Training', +} + +export const STATUS_LABELS: Record = { + pending: 'Ausstehend', + in_progress: 'In Bearbeitung', + completed: 'Abgeschlossen', + overdue: 'Ueberfaellig', + expired: 'Abgelaufen', +} + +export const STATUS_COLORS: Record = { + pending: { bg: 'bg-gray-100', text: 'text-gray-700' }, + in_progress: { bg: 'bg-blue-100', text: 'text-blue-700' }, + completed: { bg: 'bg-green-100', text: 'text-green-700' }, + overdue: { bg: 'bg-red-100', text: 'text-red-700' }, + expired: { bg: 'bg-gray-200', text: 'text-gray-500' }, +} + +export const ROLE_LABELS: Record = { + R1: 'Geschaeftsfuehrung', + R2: 'IT-Leitung', + R3: 'Datenschutzbeauftragter', + R4: 'Informationssicherheitsbeauftragter', + R5: 'HR / Personal', + R6: 'Einkauf / Beschaffung', + R7: 'Fachabteilung', + R8: 'IT-Administration', + R9: 'Alle Mitarbeiter', +} + +export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'] as const + +// ============================================================================= +// MAIN ENTITIES +// ============================================================================= + +export interface TrainingModule { + id: string + tenant_id: string + academy_course_id?: string + module_code: string + title: string + description?: string + regulation_area: RegulationArea + nis2_relevant: boolean + iso_controls: string[] + frequency_type: FrequencyType + validity_days: number + risk_weight: number + content_type: string + duration_minutes: number + pass_threshold: number + is_active: boolean + sort_order: number + created_at: string + updated_at: string +} + +export interface TrainingMatrixEntry { + id: string + tenant_id: string + role_code: string + module_id: string + is_mandatory: boolean + priority: number + created_at: string + module_code?: string + module_title?: string +} + +export interface TrainingAssignment { + id: string + tenant_id: string + module_id: string + user_id: string + user_name: string + user_email: string + role_code?: string + trigger_type: TriggerType + trigger_event?: string + status: AssignmentStatus + progress_percent: number + quiz_score?: number + quiz_passed?: boolean + quiz_attempts: number + started_at?: string + completed_at?: string + deadline: string + certificate_id?: string + escalation_level: number + last_escalation_at?: string + enrollment_id?: string + created_at: string + updated_at: string + module_code?: string + module_title?: string +} + +export interface QuizQuestion { + id: string + question: string + options: string[] + difficulty: Difficulty +} + +export interface QuizAttempt { + id: string + assignment_id: string + user_id: string + answers: QuizAnswer[] + score: number + passed: boolean + correct_count: number + total_count: number + duration_seconds?: number + attempted_at: string +} + +export interface QuizAnswer { + question_id: string + selected_index: number + correct: boolean +} + +export interface ModuleContent { + id: string + module_id: string + version: number + content_format: string + content_body: string + summary?: string + generated_by?: string + llm_model?: string + is_published: boolean + reviewed_by?: string + reviewed_at?: string + created_at: string + updated_at: string +} + +export interface AuditLogEntry { + id: string + tenant_id: string + user_id?: string + action: AuditAction + entity_type: string + entity_id?: string + details: Record + ip_address?: string + created_at: string +} + +export interface DeadlineInfo { + assignment_id: string + module_code: string + module_title: string + user_id: string + user_name: string + deadline: string + days_left: number + status: AssignmentStatus +} + +export interface EscalationResult { + assignment_id: string + user_id: string + user_name: string + user_email: string + module_title: string + previous_level: number + new_level: number + days_overdue: number + escalation_label: string +} + +export interface TrainingStats { + total_modules: number + total_assignments: number + completion_rate: number + overdue_count: number + pending_count: number + in_progress_count: number + completed_count: number + avg_quiz_score: number + avg_completion_days: number + upcoming_deadlines: number +} + +// ============================================================================= +// API RESPONSES +// ============================================================================= + +export interface ModuleListResponse { + modules: TrainingModule[] + total: number +} + +export interface AssignmentListResponse { + assignments: TrainingAssignment[] + total: number +} + +export interface MatrixResponse { + entries: Record + roles: Record +} + +export interface AuditLogResponse { + entries: AuditLogEntry[] + total: number +} + +export interface EscalationResponse { + results: EscalationResult[] + total_checked: number + escalated: number +} + +export interface DeadlineListResponse { + deadlines: DeadlineInfo[] + total: number +} + +export interface QuizSubmitResponse { + attempt_id: string + score: number + passed: boolean + correct_count: number + total_count: number + threshold: number +} + +// ============================================================================= +// MEDIA (Audio/Video) +// ============================================================================= + +export type MediaType = 'audio' | 'video' +export type MediaStatus = 'processing' | 'completed' | 'failed' + +export interface TrainingMedia { + id: string + module_id: string + content_id?: string + media_type: MediaType + status: MediaStatus + bucket: string + object_key: string + file_size_bytes: number + duration_seconds: number + mime_type: string + voice_model: string + language: string + metadata: Record + error_message?: string + generated_by: string + is_published: boolean + created_at: string + updated_at: string +} + +export interface VideoScript { + title: string + sections: VideoScriptSection[] +} + +export interface VideoScriptSection { + heading: string + text: string + bullet_points: string[] +} diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go index 46068d7..31aaef6 100644 --- a/ai-compliance-sdk/cmd/server/main.go +++ b/ai-compliance-sdk/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "github.com/breakpilot/ai-compliance-sdk/internal/workshop" "github.com/breakpilot/ai-compliance-sdk/internal/portfolio" "github.com/breakpilot/ai-compliance-sdk/internal/gci" + "github.com/breakpilot/ai-compliance-sdk/internal/training" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" @@ -129,6 +130,11 @@ func main() { gciEngine := gci.NewEngine() gciHandlers := handlers.NewGCIHandlers(gciEngine) + // Initialize Training Engine + trainingStore := training.NewStore(pool) + ttsClient := training.NewTTSClient(cfg.TTSServiceURL) + contentGenerator := training.NewContentGenerator(providerRegistry, piiDetector, trainingStore, ttsClient) + trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator) // Initialize middleware rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine) @@ -680,6 +686,63 @@ func main() { gciRoutes.GET("/iso/mappings/:controlId", gciHandlers.GetISOMapping) } + + // Training Engine routes - Compliance Training Management + trainingRoutes := v1.Group("/training") + { + // Modules + trainingRoutes.GET("/modules", trainingHandlers.ListModules) + trainingRoutes.GET("/modules/:id", trainingHandlers.GetModule) + trainingRoutes.POST("/modules", trainingHandlers.CreateModule) + trainingRoutes.PUT("/modules/:id", trainingHandlers.UpdateModule) + + // Training Matrix (CTM) + trainingRoutes.GET("/matrix", trainingHandlers.GetMatrix) + trainingRoutes.GET("/matrix/:role", trainingHandlers.GetMatrixForRole) + trainingRoutes.POST("/matrix", trainingHandlers.SetMatrixEntry) + trainingRoutes.DELETE("/matrix/:role/:moduleId", trainingHandlers.DeleteMatrixEntry) + + // Assignments + trainingRoutes.POST("/assignments/compute", trainingHandlers.ComputeAssignments) + trainingRoutes.GET("/assignments", trainingHandlers.ListAssignments) + trainingRoutes.GET("/assignments/:id", trainingHandlers.GetAssignment) + trainingRoutes.POST("/assignments/:id/start", trainingHandlers.StartAssignment) + trainingRoutes.POST("/assignments/:id/progress", trainingHandlers.UpdateAssignmentProgress) + trainingRoutes.POST("/assignments/:id/complete", trainingHandlers.CompleteAssignment) + + // Quiz + trainingRoutes.GET("/quiz/:moduleId", trainingHandlers.GetQuiz) + trainingRoutes.POST("/quiz/:moduleId/submit", trainingHandlers.SubmitQuiz) + trainingRoutes.GET("/quiz/attempts/:assignmentId", trainingHandlers.GetQuizAttempts) + + // Content Generation + trainingRoutes.POST("/content/generate", trainingHandlers.GenerateContent) + trainingRoutes.POST("/content/generate-quiz", trainingHandlers.GenerateQuiz) + trainingRoutes.POST("/content/generate-all", trainingHandlers.GenerateAllContent) + trainingRoutes.POST("/content/generate-all-quiz", trainingHandlers.GenerateAllQuizzes) + trainingRoutes.GET("/content/:moduleId", trainingHandlers.GetContent) + trainingRoutes.POST("/content/:id/publish", trainingHandlers.PublishContent) + + // Audio/Media + trainingRoutes.POST("/content/:moduleId/generate-audio", trainingHandlers.GenerateAudio) + trainingRoutes.GET("/media/:moduleId", trainingHandlers.GetModuleMedia) + trainingRoutes.GET("/media/:id/url", trainingHandlers.GetMediaURL) + trainingRoutes.POST("/media/:id/publish", trainingHandlers.PublishMedia) + + // Video + trainingRoutes.POST("/content/:moduleId/generate-video", trainingHandlers.GenerateVideo) + trainingRoutes.POST("/content/:moduleId/preview-script", trainingHandlers.PreviewVideoScript) + + // Deadlines and Escalation + trainingRoutes.GET("/deadlines", trainingHandlers.GetDeadlines) + trainingRoutes.GET("/deadlines/overdue", trainingHandlers.GetOverdueDeadlines) + trainingRoutes.POST("/escalation/check", trainingHandlers.CheckEscalation) + + // Audit and Stats + trainingRoutes.GET("/audit-log", trainingHandlers.GetAuditLog) + trainingRoutes.GET("/stats", trainingHandlers.GetStats) + trainingRoutes.GET("/certificates/:id/verify", trainingHandlers.VerifyCertificate) + } } // Create HTTP server diff --git a/ai-compliance-sdk/internal/api/handlers/training_handlers.go b/ai-compliance-sdk/internal/api/handlers/training_handlers.go new file mode 100644 index 0000000..5b58e2a --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/training_handlers.go @@ -0,0 +1,1113 @@ +package handlers + +import ( + "net/http" + "strconv" + + "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" +) + +// TrainingHandlers handles training-related API requests +type TrainingHandlers struct { + store *training.Store + contentGenerator *training.ContentGenerator +} + +// NewTrainingHandlers creates new training handlers +func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator) *TrainingHandlers { + return &TrainingHandlers{ + store: store, + contentGenerator: contentGenerator, + } +} + +// ============================================================================ +// Module Endpoints +// ============================================================================ + +// ListModules returns all training modules for the tenant +// GET /sdk/v1/training/modules +func (h *TrainingHandlers) ListModules(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + filters := &training.ModuleFilters{ + Limit: 50, + Offset: 0, + } + + if v := c.Query("regulation_area"); v != "" { + filters.RegulationArea = training.RegulationArea(v) + } + if v := c.Query("frequency_type"); v != "" { + filters.FrequencyType = training.FrequencyType(v) + } + if v := c.Query("search"); v != "" { + filters.Search = v + } + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filters.Limit = n + } + } + if v := c.Query("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filters.Offset = n + } + } + + modules, total, err := h.store.ListModules(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, training.ModuleListResponse{ + Modules: modules, + Total: total, + }) +} + +// GetModule returns a single training module with content and quiz +// GET /sdk/v1/training/modules/:id +func (h *TrainingHandlers) GetModule(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + module, err := h.store.GetModule(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if module == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + return + } + + // Include content and quiz questions + content, _ := h.store.GetPublishedContent(c.Request.Context(), id) + questions, _ := h.store.ListQuizQuestions(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "module": module, + "content": content, + "questions": questions, + }) +} + +// CreateModule creates a new training module +// POST /sdk/v1/training/modules +func (h *TrainingHandlers) CreateModule(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + var req training.CreateModuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + module := &training.TrainingModule{ + TenantID: tenantID, + ModuleCode: req.ModuleCode, + Title: req.Title, + Description: req.Description, + RegulationArea: req.RegulationArea, + NIS2Relevant: req.NIS2Relevant, + ISOControls: req.ISOControls, + FrequencyType: req.FrequencyType, + ValidityDays: req.ValidityDays, + RiskWeight: req.RiskWeight, + ContentType: req.ContentType, + DurationMinutes: req.DurationMinutes, + PassThreshold: req.PassThreshold, + } + + if module.ValidityDays == 0 { + module.ValidityDays = 365 + } + if module.RiskWeight == 0 { + module.RiskWeight = 2.0 + } + if module.ContentType == "" { + module.ContentType = "text" + } + if module.PassThreshold == 0 { + module.PassThreshold = 70 + } + if module.ISOControls == nil { + module.ISOControls = []string{} + } + + if err := h.store.CreateModule(c.Request.Context(), module); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, module) +} + +// UpdateModule updates a training module +// PUT /sdk/v1/training/modules/:id +func (h *TrainingHandlers) UpdateModule(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + module, err := h.store.GetModule(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if module == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + return + } + + var req training.UpdateModuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Title != nil { + module.Title = *req.Title + } + if req.Description != nil { + module.Description = *req.Description + } + if req.NIS2Relevant != nil { + module.NIS2Relevant = *req.NIS2Relevant + } + if req.ISOControls != nil { + module.ISOControls = req.ISOControls + } + if req.ValidityDays != nil { + module.ValidityDays = *req.ValidityDays + } + if req.RiskWeight != nil { + module.RiskWeight = *req.RiskWeight + } + if req.DurationMinutes != nil { + module.DurationMinutes = *req.DurationMinutes + } + if req.PassThreshold != nil { + module.PassThreshold = *req.PassThreshold + } + if req.IsActive != nil { + module.IsActive = *req.IsActive + } + + if err := h.store.UpdateModule(c.Request.Context(), module); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, module) +} + +// ============================================================================ +// Matrix Endpoints +// ============================================================================ + +// GetMatrix returns the full CTM for the tenant +// GET /sdk/v1/training/matrix +func (h *TrainingHandlers) GetMatrix(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + entries, err := h.store.GetMatrixForTenant(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + resp := training.BuildMatrixResponse(entries) + c.JSON(http.StatusOK, resp) +} + +// GetMatrixForRole returns matrix entries for a specific role +// GET /sdk/v1/training/matrix/:role +func (h *TrainingHandlers) GetMatrixForRole(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + role := c.Param("role") + + entries, err := h.store.GetMatrixForRole(c.Request.Context(), tenantID, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "role": role, + "label": training.RoleLabels[role], + "entries": entries, + "total": len(entries), + }) +} + +// SetMatrixEntry creates or updates a CTM entry +// POST /sdk/v1/training/matrix +func (h *TrainingHandlers) SetMatrixEntry(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + var req training.SetMatrixEntryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + entry := &training.TrainingMatrixEntry{ + TenantID: tenantID, + RoleCode: req.RoleCode, + ModuleID: req.ModuleID, + IsMandatory: req.IsMandatory, + Priority: req.Priority, + } + + if err := h.store.SetMatrixEntry(c.Request.Context(), entry); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, entry) +} + +// DeleteMatrixEntry removes a CTM entry +// DELETE /sdk/v1/training/matrix/:role/:moduleId +func (h *TrainingHandlers) DeleteMatrixEntry(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + role := c.Param("role") + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + if err := h.store.DeleteMatrixEntry(c.Request.Context(), tenantID, role, moduleID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} + +// ============================================================================ +// Assignment Endpoints +// ============================================================================ + +// ComputeAssignments computes assignments for a user based on roles +// POST /sdk/v1/training/assignments/compute +func (h *TrainingHandlers) ComputeAssignments(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + var req training.ComputeAssignmentsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + trigger := req.Trigger + if trigger == "" { + trigger = "manual" + } + + assignments, err := training.ComputeAssignments( + c.Request.Context(), h.store, tenantID, + req.UserID, req.UserName, req.UserEmail, req.Roles, trigger, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "assignments": assignments, + "created": len(assignments), + }) +} + +// ListAssignments returns assignments for the tenant +// GET /sdk/v1/training/assignments +func (h *TrainingHandlers) ListAssignments(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + filters := &training.AssignmentFilters{ + Limit: 50, + Offset: 0, + } + + if v := c.Query("user_id"); v != "" { + if uid, err := uuid.Parse(v); err == nil { + filters.UserID = &uid + } + } + if v := c.Query("module_id"); v != "" { + if mid, err := uuid.Parse(v); err == nil { + filters.ModuleID = &mid + } + } + if v := c.Query("role"); v != "" { + filters.RoleCode = v + } + if v := c.Query("status"); v != "" { + filters.Status = training.AssignmentStatus(v) + } + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filters.Limit = n + } + } + if v := c.Query("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filters.Offset = n + } + } + + assignments, total, err := h.store.ListAssignments(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, training.AssignmentListResponse{ + Assignments: assignments, + Total: total, + }) +} + +// GetAssignment returns a single assignment +// GET /sdk/v1/training/assignments/:id +func (h *TrainingHandlers) GetAssignment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) + return + } + + assignment, err := h.store.GetAssignment(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if assignment == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) + return + } + + c.JSON(http.StatusOK, assignment) +} + +// StartAssignment marks an assignment as started +// POST /sdk/v1/training/assignments/:id/start +func (h *TrainingHandlers) StartAssignment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) + return + } + tenantID := rbac.GetTenantID(c) + + if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, training.AssignmentStatusInProgress, 0); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Audit log + userID := rbac.GetUserID(c) + h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{ + TenantID: tenantID, + UserID: &userID, + Action: training.AuditActionStarted, + EntityType: training.AuditEntityAssignment, + EntityID: &id, + }) + + c.JSON(http.StatusOK, gin.H{"status": "in_progress"}) +} + +// UpdateAssignmentProgress updates progress on an assignment +// POST /sdk/v1/training/assignments/:id/progress +func (h *TrainingHandlers) UpdateAssignmentProgress(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) + return + } + + var req training.UpdateAssignmentProgressRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + status := training.AssignmentStatusInProgress + if req.Progress >= 100 { + status = training.AssignmentStatusCompleted + } + + if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, status, req.Progress); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": string(status), "progress": req.Progress}) +} + +// CompleteAssignment marks an assignment as completed +// POST /sdk/v1/training/assignments/:id/complete +func (h *TrainingHandlers) CompleteAssignment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) + return + } + tenantID := rbac.GetTenantID(c) + + if err := h.store.UpdateAssignmentStatus(c.Request.Context(), id, training.AssignmentStatusCompleted, 100); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{ + TenantID: tenantID, + UserID: &userID, + Action: training.AuditActionCompleted, + EntityType: training.AuditEntityAssignment, + EntityID: &id, + }) + + c.JSON(http.StatusOK, gin.H{"status": "completed"}) +} + +// ============================================================================ +// Quiz Endpoints +// ============================================================================ + +// GetQuiz returns quiz questions for a module +// GET /sdk/v1/training/quiz/:moduleId +func (h *TrainingHandlers) GetQuiz(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + questions, err := h.store.ListQuizQuestions(c.Request.Context(), moduleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Strip correct_index for the student-facing response + type safeQuestion struct { + ID uuid.UUID `json:"id"` + Question string `json:"question"` + Options []string `json:"options"` + Difficulty string `json:"difficulty"` + } + + safe := make([]safeQuestion, len(questions)) + for i, q := range questions { + safe[i] = safeQuestion{ + ID: q.ID, + Question: q.Question, + Options: q.Options, + Difficulty: string(q.Difficulty), + } + } + + c.JSON(http.StatusOK, gin.H{ + "questions": safe, + "total": len(safe), + }) +} + +// SubmitQuiz submits quiz answers and returns the score +// POST /sdk/v1/training/quiz/:moduleId/submit +func (h *TrainingHandlers) SubmitQuiz(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + tenantID := rbac.GetTenantID(c) + + var req training.SubmitTrainingQuizRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get the correct answers + questions, err := h.store.ListQuizQuestions(c.Request.Context(), moduleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Build answer map + questionMap := make(map[uuid.UUID]training.QuizQuestion) + for _, q := range questions { + questionMap[q.ID] = q + } + + // Score the answers + correctCount := 0 + totalCount := len(req.Answers) + scoredAnswers := make([]training.QuizAnswer, len(req.Answers)) + + for i, answer := range req.Answers { + q, exists := questionMap[answer.QuestionID] + correct := exists && answer.SelectedIndex == q.CorrectIndex + + scoredAnswers[i] = training.QuizAnswer{ + QuestionID: answer.QuestionID, + SelectedIndex: answer.SelectedIndex, + Correct: correct, + } + + if correct { + correctCount++ + } + } + + score := float64(0) + if totalCount > 0 { + score = float64(correctCount) / float64(totalCount) * 100 + } + + // Get module for pass threshold + module, _ := h.store.GetModule(c.Request.Context(), moduleID) + threshold := 70 + if module != nil { + threshold = module.PassThreshold + } + passed := score >= float64(threshold) + + // Record the attempt + userID := rbac.GetUserID(c) + attempt := &training.QuizAttempt{ + AssignmentID: req.AssignmentID, + UserID: userID, + Answers: scoredAnswers, + Score: score, + Passed: passed, + CorrectCount: correctCount, + TotalCount: totalCount, + DurationSeconds: req.DurationSeconds, + } + + if err := h.store.CreateQuizAttempt(c.Request.Context(), attempt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update assignment quiz result + // Count total attempts + attempts, _ := h.store.ListQuizAttempts(c.Request.Context(), req.AssignmentID) + h.store.UpdateAssignmentQuizResult(c.Request.Context(), req.AssignmentID, score, passed, len(attempts)) + + // Audit log + h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{ + TenantID: tenantID, + UserID: &userID, + Action: training.AuditActionQuizSubmitted, + EntityType: training.AuditEntityQuiz, + EntityID: &attempt.ID, + Details: map[string]interface{}{ + "module_id": moduleID.String(), + "score": score, + "passed": passed, + "correct_count": correctCount, + "total_count": totalCount, + }, + }) + + c.JSON(http.StatusOK, training.SubmitTrainingQuizResponse{ + AttemptID: attempt.ID, + Score: score, + Passed: passed, + CorrectCount: correctCount, + TotalCount: totalCount, + Threshold: threshold, + }) +} + +// GetQuizAttempts returns quiz attempts for an assignment +// GET /sdk/v1/training/quiz/attempts/:assignmentId +func (h *TrainingHandlers) GetQuizAttempts(c *gin.Context) { + assignmentID, err := uuid.Parse(c.Param("assignmentId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"}) + return + } + + attempts, err := h.store.ListQuizAttempts(c.Request.Context(), assignmentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "attempts": attempts, + "total": len(attempts), + }) +} + +// ============================================================================ +// Content Endpoints +// ============================================================================ + +// GenerateContent generates module content via LLM +// POST /sdk/v1/training/content/generate +func (h *TrainingHandlers) GenerateContent(c *gin.Context) { + var req training.GenerateContentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + module, err := h.store.GetModule(c.Request.Context(), req.ModuleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if module == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + return + } + + content, err := h.contentGenerator.GenerateModuleContent(c.Request.Context(), *module, req.Language) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, content) +} + +// GenerateQuiz generates quiz questions via LLM +// POST /sdk/v1/training/content/generate-quiz +func (h *TrainingHandlers) GenerateQuiz(c *gin.Context) { + var req training.GenerateQuizRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + module, err := h.store.GetModule(c.Request.Context(), req.ModuleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if module == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "module not found"}) + return + } + + count := req.Count + if count <= 0 { + count = 5 + } + + questions, err := h.contentGenerator.GenerateQuizQuestions(c.Request.Context(), *module, count) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "questions": questions, + "total": len(questions), + }) +} + +// GetContent returns published content for a module +// GET /sdk/v1/training/content/:moduleId +func (h *TrainingHandlers) GetContent(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + content, err := h.store.GetPublishedContent(c.Request.Context(), moduleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if content == nil { + // Try latest unpublished + content, err = h.store.GetLatestContent(c.Request.Context(), moduleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + if content == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no content found for this module"}) + return + } + + c.JSON(http.StatusOK, content) +} + +// PublishContent publishes a content version +// POST /sdk/v1/training/content/:id/publish +func (h *TrainingHandlers) PublishContent(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content ID"}) + return + } + + reviewedBy := rbac.GetUserID(c) + + if err := h.store.PublishContent(c.Request.Context(), id, reviewedBy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "published"}) +} + +// ============================================================================ +// Deadline / Escalation Endpoints +// ============================================================================ + +// GetDeadlines returns upcoming deadlines +// GET /sdk/v1/training/deadlines +func (h *TrainingHandlers) GetDeadlines(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + limit := 20 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + + deadlines, err := h.store.GetDeadlines(c.Request.Context(), tenantID, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, training.DeadlineListResponse{ + Deadlines: deadlines, + Total: len(deadlines), + }) +} + +// GetOverdueDeadlines returns overdue assignments +// GET /sdk/v1/training/deadlines/overdue +func (h *TrainingHandlers) GetOverdueDeadlines(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + deadlines, err := training.GetOverdueDeadlines(c.Request.Context(), h.store, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, training.DeadlineListResponse{ + Deadlines: deadlines, + Total: len(deadlines), + }) +} + +// CheckEscalation runs the escalation check +// POST /sdk/v1/training/escalation/check +func (h *TrainingHandlers) CheckEscalation(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + results, err := training.CheckEscalations(c.Request.Context(), h.store, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + overdueAll, _ := h.store.ListOverdueAssignments(c.Request.Context(), tenantID) + + c.JSON(http.StatusOK, training.EscalationResponse{ + Results: results, + TotalChecked: len(overdueAll), + Escalated: len(results), + }) +} + +// ============================================================================ +// Audit / Stats Endpoints +// ============================================================================ + +// GetAuditLog returns the training audit trail +// GET /sdk/v1/training/audit-log +func (h *TrainingHandlers) GetAuditLog(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + filters := &training.AuditLogFilters{ + Limit: 50, + Offset: 0, + } + + if v := c.Query("action"); v != "" { + filters.Action = training.AuditAction(v) + } + if v := c.Query("entity_type"); v != "" { + filters.EntityType = training.AuditEntityType(v) + } + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filters.Limit = n + } + } + if v := c.Query("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filters.Offset = n + } + } + + entries, total, err := h.store.ListAuditLog(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, training.AuditLogResponse{ + Entries: entries, + Total: total, + }) +} + +// GetStats returns training dashboard statistics +// GET /sdk/v1/training/stats +func (h *TrainingHandlers) GetStats(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + stats, err := h.store.GetTrainingStats(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// VerifyCertificate verifies a certificate +// GET /sdk/v1/training/certificates/:id/verify +func (h *TrainingHandlers) VerifyCertificate(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"}) + return + } + + valid, assignment, err := training.VerifyCertificate(c.Request.Context(), h.store, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "valid": valid, + "assignment": assignment, + }) +} + +// GenerateAllContent generates content for all modules that don't have content yet +// POST /sdk/v1/training/content/generate-all +func (h *TrainingHandlers) GenerateAllContent(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + language := "de" + if v := c.Query("language"); v != "" { + language = v + } + + result, err := h.contentGenerator.GenerateAllModuleContent(c.Request.Context(), tenantID, language) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GenerateAllQuizzes generates quiz questions for all modules that don't have questions yet +// POST /sdk/v1/training/content/generate-all-quiz +func (h *TrainingHandlers) GenerateAllQuizzes(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + count := 5 + + result, err := h.contentGenerator.GenerateAllQuizQuestions(c.Request.Context(), tenantID, count) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GenerateAudio generates audio for a module via TTS service +// POST /sdk/v1/training/content/:moduleId/generate-audio +func (h *TrainingHandlers) GenerateAudio(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + module, err := h.store.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": "module not found"}) + return + } + + media, err := h.contentGenerator.GenerateAudio(c.Request.Context(), *module) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, media) +} + +// GetModuleMedia returns all media files for a module +// GET /sdk/v1/training/media/:moduleId +func (h *TrainingHandlers) GetModuleMedia(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "media": mediaList, + "total": len(mediaList), + }) +} + +// GetMediaURL returns a presigned URL for a media file +// GET /sdk/v1/training/media/:id/url +func (h *TrainingHandlers) GetMediaURL(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"}) + return + } + + media, err := h.store.GetMedia(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if media == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) + return + } + + // Return the object info for the frontend to construct the URL + c.JSON(http.StatusOK, gin.H{ + "bucket": media.Bucket, + "object_key": media.ObjectKey, + "mime_type": media.MimeType, + }) +} + +// PublishMedia publishes or unpublishes a media file +// POST /sdk/v1/training/media/:id/publish +func (h *TrainingHandlers) PublishMedia(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"}) + return + } + + var req struct { + Publish bool `json:"publish"` + } + if err := c.ShouldBindJSON(&req); err != nil { + req.Publish = true // Default to publish + } + + if err := h.store.PublishMedia(c.Request.Context(), id, req.Publish); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "is_published": req.Publish}) +} + +// GenerateVideo generates a presentation video for a module +// POST /sdk/v1/training/content/:moduleId/generate-video +func (h *TrainingHandlers) GenerateVideo(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + module, err := h.store.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": "module not found"}) + return + } + + media, err := h.contentGenerator.GenerateVideo(c.Request.Context(), *module) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, media) +} + +// PreviewVideoScript generates and returns a video script preview without creating the video +// POST /sdk/v1/training/content/:moduleId/preview-script +func (h *TrainingHandlers) PreviewVideoScript(c *gin.Context) { + moduleID, err := uuid.Parse(c.Param("moduleId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"}) + return + } + + module, err := h.store.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": "module not found"}) + return + } + + script, err := h.contentGenerator.GenerateVideoScript(c.Request.Context(), *module) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, script) +} diff --git a/ai-compliance-sdk/internal/config/config.go b/ai-compliance-sdk/internal/config/config.go index 5d5a747..3d43036 100644 --- a/ai-compliance-sdk/internal/config/config.go +++ b/ai-compliance-sdk/internal/config/config.go @@ -59,6 +59,8 @@ type Config struct { // Frontend URLs AdminFrontendURL string + // TTS Service + TTSServiceURL string } // Load loads configuration from environment variables @@ -105,6 +107,7 @@ func Load() (*Config, error) { // Integration ConsentServiceURL: getEnv("CONSENT_SERVICE_URL", "http://localhost:8081"), AdminFrontendURL: getEnv("ADMIN_FRONTEND_URL", "http://localhost:3002"), + TTSServiceURL: getEnv("TTS_SERVICE_URL", "http://compliance-tts-service:8095"), } // Parse allowed origins diff --git a/ai-compliance-sdk/internal/training/assignment.go b/ai-compliance-sdk/internal/training/assignment.go new file mode 100644 index 0000000..8fdc061 --- /dev/null +++ b/ai-compliance-sdk/internal/training/assignment.go @@ -0,0 +1,183 @@ +package training + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// timeNow is a package-level function for testing +var timeNow = time.Now + +// ComputeAssignments calculates all necessary assignments for a user based on +// their roles, existing assignments, and deadlines. Returns new assignments +// that need to be created. +func ComputeAssignments(ctx context.Context, store *Store, tenantID uuid.UUID, + userID uuid.UUID, userName, userEmail string, roleCodes []string, trigger string) ([]TrainingAssignment, error) { + + if trigger == "" { + trigger = string(TriggerManual) + } + + // Get all required modules for the user's roles + requiredModules, err := ComputeRequiredModules(ctx, store, tenantID, roleCodes) + if err != nil { + return nil, err + } + + // Get existing active assignments for this user + existingAssignments, _, err := store.ListAssignments(ctx, tenantID, &AssignmentFilters{ + UserID: &userID, + Limit: 1000, + }) + if err != nil { + return nil, err + } + + // Build a map of existing assignments by module_id for quick lookup + existingByModule := make(map[uuid.UUID]*TrainingAssignment) + for i := range existingAssignments { + a := &existingAssignments[i] + // Only consider non-expired, non-completed-and-expired assignments + if a.Status != AssignmentStatusExpired { + existingByModule[a.ModuleID] = a + } + } + + var newAssignments []TrainingAssignment + now := timeNow().UTC() + + for _, module := range requiredModules { + existing, hasExisting := existingByModule[module.ID] + + // Skip if there's an active, valid assignment + if hasExisting { + switch existing.Status { + case AssignmentStatusCompleted: + // Check if the completed assignment is still valid + if existing.CompletedAt != nil { + validUntil := existing.CompletedAt.AddDate(0, 0, module.ValidityDays) + if validUntil.After(now) { + continue // Still valid, skip + } + } + case AssignmentStatusPending, AssignmentStatusInProgress: + continue // Assignment exists and is active + case AssignmentStatusOverdue: + continue // Already tracked as overdue + } + } + + // Determine the role code for this assignment + roleCode := "" + for _, role := range roleCodes { + entries, err := store.GetMatrixForRole(ctx, tenantID, role) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.ModuleID == module.ID { + roleCode = role + break + } + } + if roleCode != "" { + break + } + } + + // Calculate deadline based on frequency + var deadline time.Time + switch module.FrequencyType { + case FrequencyOnboarding: + deadline = now.AddDate(0, 0, 30) // 30 days for onboarding + case FrequencyMicro: + deadline = now.AddDate(0, 0, 14) // 14 days for micro + default: + deadline = now.AddDate(0, 0, 90) // 90 days default + } + + assignment := TrainingAssignment{ + TenantID: tenantID, + ModuleID: module.ID, + UserID: userID, + UserName: userName, + UserEmail: userEmail, + RoleCode: roleCode, + TriggerType: TriggerType(trigger), + Status: AssignmentStatusPending, + Deadline: deadline, + ModuleCode: module.ModuleCode, + ModuleTitle: module.Title, + } + + // Create the assignment in the store + if err := store.CreateAssignment(ctx, &assignment); err != nil { + return nil, err + } + + // Log the assignment + store.LogAction(ctx, &AuditLogEntry{ + TenantID: tenantID, + UserID: &userID, + Action: AuditActionAssigned, + EntityType: AuditEntityAssignment, + EntityID: &assignment.ID, + Details: map[string]interface{}{ + "module_code": module.ModuleCode, + "trigger": trigger, + "role_code": roleCode, + "deadline": deadline.Format(time.RFC3339), + }, + }) + + newAssignments = append(newAssignments, assignment) + } + + if newAssignments == nil { + newAssignments = []TrainingAssignment{} + } + + return newAssignments, nil +} + +// BulkAssign assigns a module to all users with specific roles +// Returns the number of assignments created +func BulkAssign(ctx context.Context, store *Store, tenantID uuid.UUID, + moduleID uuid.UUID, users []UserInfo, trigger string, deadline time.Time) (int, error) { + + if trigger == "" { + trigger = string(TriggerManual) + } + + count := 0 + for _, user := range users { + assignment := TrainingAssignment{ + TenantID: tenantID, + ModuleID: moduleID, + UserID: user.UserID, + UserName: user.UserName, + UserEmail: user.UserEmail, + RoleCode: user.RoleCode, + TriggerType: TriggerType(trigger), + Status: AssignmentStatusPending, + Deadline: deadline, + } + + if err := store.CreateAssignment(ctx, &assignment); err != nil { + return count, err + } + count++ + } + + return count, nil +} + +// UserInfo contains basic user information for bulk operations +type UserInfo struct { + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + RoleCode string `json:"role_code"` +} diff --git a/ai-compliance-sdk/internal/training/content_generator.go b/ai-compliance-sdk/internal/training/content_generator.go new file mode 100644 index 0000000..8c577ec --- /dev/null +++ b/ai-compliance-sdk/internal/training/content_generator.go @@ -0,0 +1,602 @@ +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 +} + +// ============================================================================ +// 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 +} + +// 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 +} + +// 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 { + if len(text) <= maxLen { + return text + } + return text[:maxLen] + "..." +} diff --git a/ai-compliance-sdk/internal/training/escalation.go b/ai-compliance-sdk/internal/training/escalation.go new file mode 100644 index 0000000..60e1229 --- /dev/null +++ b/ai-compliance-sdk/internal/training/escalation.go @@ -0,0 +1,177 @@ +package training + +import ( + "context" + "math" + + "github.com/google/uuid" +) + +// Escalation level thresholds (days overdue) +const ( + EscalationThresholdL1 = 7 // Reminder to user + EscalationThresholdL2 = 14 // Notify team lead + EscalationThresholdL3 = 30 // Notify management + EscalationThresholdL4 = 45 // Notify compliance officer +) + +// EscalationLabels maps levels to human-readable labels +var EscalationLabels = map[int]string{ + 0: "Keine Eskalation", + 1: "Erinnerung an Mitarbeiter", + 2: "Benachrichtigung Teamleitung", + 3: "Benachrichtigung Management", + 4: "Benachrichtigung Compliance Officer", +} + +// CheckEscalations checks all overdue assignments and escalates as needed +func CheckEscalations(ctx context.Context, store *Store, tenantID uuid.UUID) ([]EscalationResult, error) { + overdueAssignments, err := store.ListOverdueAssignments(ctx, tenantID) + if err != nil { + return nil, err + } + + var results []EscalationResult + now := timeNow().UTC() + + for _, assignment := range overdueAssignments { + daysOverdue := int(math.Floor(now.Sub(assignment.Deadline).Hours() / 24)) + if daysOverdue < 0 { + continue + } + + // Determine new escalation level + newLevel := 0 + if daysOverdue >= EscalationThresholdL4 { + newLevel = 4 + } else if daysOverdue >= EscalationThresholdL3 { + newLevel = 3 + } else if daysOverdue >= EscalationThresholdL2 { + newLevel = 2 + } else if daysOverdue >= EscalationThresholdL1 { + newLevel = 1 + } + + // Only escalate if the level has increased + if newLevel <= assignment.EscalationLevel { + continue + } + + previousLevel := assignment.EscalationLevel + + // Update the assignment + nowTime := now + _, err := store.pool.Exec(ctx, ` + UPDATE training_assignments SET + escalation_level = $2, + last_escalation_at = $3, + status = 'overdue', + updated_at = $3 + WHERE id = $1 + `, assignment.ID, newLevel, nowTime) + if err != nil { + return nil, err + } + + // Log the escalation + assignmentID := assignment.ID + store.LogAction(ctx, &AuditLogEntry{ + TenantID: tenantID, + UserID: &assignment.UserID, + Action: AuditActionEscalated, + EntityType: AuditEntityAssignment, + EntityID: &assignmentID, + Details: map[string]interface{}{ + "previous_level": previousLevel, + "new_level": newLevel, + "days_overdue": daysOverdue, + "label": EscalationLabels[newLevel], + }, + }) + + results = append(results, EscalationResult{ + AssignmentID: assignment.ID, + UserID: assignment.UserID, + UserName: assignment.UserName, + UserEmail: assignment.UserEmail, + ModuleTitle: assignment.ModuleTitle, + PreviousLevel: previousLevel, + NewLevel: newLevel, + DaysOverdue: daysOverdue, + EscalationLabel: EscalationLabels[newLevel], + }) + } + + if results == nil { + results = []EscalationResult{} + } + + return results, nil +} + +// GetOverdueDeadlines returns all overdue assignments with deadline info +func GetOverdueDeadlines(ctx context.Context, store *Store, tenantID uuid.UUID) ([]DeadlineInfo, error) { + rows, err := store.pool.Query(ctx, ` + SELECT + ta.id, m.module_code, m.title, + ta.user_id, ta.user_name, ta.deadline, ta.status, + EXTRACT(DAY FROM (NOW() - ta.deadline))::INT AS days_overdue + FROM training_assignments ta + JOIN training_modules m ON m.id = ta.module_id + WHERE ta.tenant_id = $1 + AND ta.status IN ('pending', 'in_progress', 'overdue') + AND ta.deadline < NOW() + ORDER BY ta.deadline ASC + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var deadlines []DeadlineInfo + for rows.Next() { + var d DeadlineInfo + var status string + + err := rows.Scan( + &d.AssignmentID, &d.ModuleCode, &d.ModuleTitle, + &d.UserID, &d.UserName, &d.Deadline, &status, + &d.DaysLeft, + ) + if err != nil { + return nil, err + } + + d.Status = AssignmentStatus(status) + d.DaysLeft = -d.DaysLeft // Negative means overdue + deadlines = append(deadlines, d) + } + + if deadlines == nil { + deadlines = []DeadlineInfo{} + } + + return deadlines, nil +} + +// VerifyCertificate verifies a certificate by checking the assignment status +func VerifyCertificate(ctx context.Context, store *Store, certificateID uuid.UUID) (bool, *TrainingAssignment, error) { + // Find assignment with this certificate + var assignmentID uuid.UUID + err := store.pool.QueryRow(ctx, + "SELECT id FROM training_assignments WHERE certificate_id = $1", + certificateID).Scan(&assignmentID) + if err != nil { + return false, nil, err + } + + assignment, err := store.GetAssignment(ctx, assignmentID) + if err != nil { + return false, nil, err + } + if assignment == nil { + return false, nil, nil + } + + return assignment.Status == AssignmentStatusCompleted, assignment, nil +} diff --git a/ai-compliance-sdk/internal/training/matrix.go b/ai-compliance-sdk/internal/training/matrix.go new file mode 100644 index 0000000..44cc8c1 --- /dev/null +++ b/ai-compliance-sdk/internal/training/matrix.go @@ -0,0 +1,127 @@ +package training + +import ( + "context" + + "github.com/google/uuid" +) + +// ComputeRequiredModules returns all required training modules for a user +// based on their assigned roles. Deduplicates modules across roles. +func ComputeRequiredModules(ctx context.Context, store *Store, tenantID uuid.UUID, roleCodes []string) ([]TrainingModule, error) { + seen := make(map[uuid.UUID]bool) + var modules []TrainingModule + + for _, role := range roleCodes { + entries, err := store.GetMatrixForRole(ctx, tenantID, role) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if seen[entry.ModuleID] { + continue + } + seen[entry.ModuleID] = true + + module, err := store.GetModule(ctx, entry.ModuleID) + if err != nil { + return nil, err + } + if module != nil && module.IsActive { + modules = append(modules, *module) + } + } + } + + if modules == nil { + modules = []TrainingModule{} + } + + return modules, nil +} + +// GetComplianceGaps finds modules that are required but not completed for a user +func GetComplianceGaps(ctx context.Context, store *Store, tenantID uuid.UUID, userID uuid.UUID, roleCodes []string) ([]ComplianceGap, error) { + var gaps []ComplianceGap + + for _, role := range roleCodes { + entries, err := store.GetMatrixForRole(ctx, tenantID, role) + if err != nil { + return nil, err + } + + for _, entry := range entries { + // Check if there's an active, completed assignment for this module + assignments, _, err := store.ListAssignments(ctx, tenantID, &AssignmentFilters{ + ModuleID: &entry.ModuleID, + UserID: &userID, + Limit: 1, + }) + if err != nil { + return nil, err + } + + gap := ComplianceGap{ + ModuleID: entry.ModuleID, + ModuleCode: entry.ModuleCode, + ModuleTitle: entry.ModuleTitle, + RoleCode: role, + IsMandatory: entry.IsMandatory, + } + + // Determine regulation area from module + module, err := store.GetModule(ctx, entry.ModuleID) + if err != nil { + return nil, err + } + if module != nil { + gap.RegulationArea = module.RegulationArea + } + + if len(assignments) == 0 { + gap.Status = "missing" + gaps = append(gaps, gap) + } else { + a := assignments[0] + gap.AssignmentID = &a.ID + gap.Deadline = &a.Deadline + + switch a.Status { + case AssignmentStatusCompleted: + // No gap + continue + case AssignmentStatusOverdue, AssignmentStatusExpired: + gap.Status = string(a.Status) + gaps = append(gaps, gap) + default: + // Check if overdue + if a.Deadline.Before(timeNow()) { + gap.Status = "overdue" + gaps = append(gaps, gap) + } + } + } + } + } + + if gaps == nil { + gaps = []ComplianceGap{} + } + + return gaps, nil +} + +// BuildMatrixResponse builds the full CTM response grouped by role +func BuildMatrixResponse(entries []TrainingMatrixEntry) *MatrixResponse { + resp := &MatrixResponse{ + Entries: make(map[string][]TrainingMatrixEntry), + Roles: RoleLabels, + } + + for _, entry := range entries { + resp.Entries[entry.RoleCode] = append(resp.Entries[entry.RoleCode], entry) + } + + return resp +} diff --git a/ai-compliance-sdk/internal/training/media.go b/ai-compliance-sdk/internal/training/media.go new file mode 100644 index 0000000..ac9eb85 --- /dev/null +++ b/ai-compliance-sdk/internal/training/media.go @@ -0,0 +1,186 @@ +package training + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" +) + +// MediaType represents audio or video +type MediaType string + +const ( + MediaTypeAudio MediaType = "audio" + MediaTypeVideo MediaType = "video" +) + +// MediaStatus represents the processing status +type MediaStatus string + +const ( + MediaStatusProcessing MediaStatus = "processing" + MediaStatusCompleted MediaStatus = "completed" + MediaStatusFailed MediaStatus = "failed" +) + +// TrainingMedia represents a generated media file +type TrainingMedia struct { + ID uuid.UUID `json:"id"` + ModuleID uuid.UUID `json:"module_id"` + ContentID *uuid.UUID `json:"content_id,omitempty"` + MediaType MediaType `json:"media_type"` + Status MediaStatus `json:"status"` + Bucket string `json:"bucket"` + ObjectKey string `json:"object_key"` + FileSizeBytes int64 `json:"file_size_bytes"` + DurationSeconds float64 `json:"duration_seconds"` + MimeType string `json:"mime_type"` + VoiceModel string `json:"voice_model"` + Language string `json:"language"` + Metadata json.RawMessage `json:"metadata"` + ErrorMessage string `json:"error_message,omitempty"` + GeneratedBy string `json:"generated_by"` + IsPublished bool `json:"is_published"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ============================================================================ +// TTS Client +// ============================================================================ + +// TTSClient communicates with the compliance-tts-service +type TTSClient struct { + baseURL string + httpClient *http.Client +} + +// NewTTSClient creates a new TTS service client +func NewTTSClient(baseURL string) *TTSClient { + return &TTSClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// TTSSynthesizeRequest is the request to synthesize audio +type TTSSynthesizeRequest struct { + Text string `json:"text"` + Language string `json:"language"` + Voice string `json:"voice"` + ModuleID string `json:"module_id"` + ContentID string `json:"content_id,omitempty"` +} + +// TTSSynthesizeResponse is the response from audio synthesis +type TTSSynthesizeResponse struct { + AudioID string `json:"audio_id"` + Bucket string `json:"bucket"` + ObjectKey string `json:"object_key"` + DurationSeconds float64 `json:"duration_seconds"` + SizeBytes int64 `json:"size_bytes"` +} + +// TTSGenerateVideoRequest is the request to generate a video +type TTSGenerateVideoRequest struct { + Script map[string]interface{} `json:"script"` + AudioObjectKey string `json:"audio_object_key"` + ModuleID string `json:"module_id"` +} + +// TTSGenerateVideoResponse is the response from video generation +type TTSGenerateVideoResponse struct { + VideoID string `json:"video_id"` + Bucket string `json:"bucket"` + ObjectKey string `json:"object_key"` + DurationSeconds float64 `json:"duration_seconds"` + SizeBytes int64 `json:"size_bytes"` +} + +// Synthesize calls the TTS service to create audio +func (c *TTSClient) Synthesize(ctx context.Context, req *TTSSynthesizeRequest) (*TTSSynthesizeResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/synthesize", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("TTS service request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result TTSSynthesizeResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse TTS response: %w", err) + } + + return &result, nil +} + +// GenerateVideo calls the TTS service to create a presentation video +func (c *TTSClient) GenerateVideo(ctx context.Context, req *TTSGenerateVideoRequest) (*TTSGenerateVideoResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/generate-video", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("TTS service request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody)) + } + + var result TTSGenerateVideoResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("parse TTS response: %w", err) + } + + return &result, nil +} + +// IsHealthy checks if the TTS service is responsive +func (c *TTSClient) IsHealthy(ctx context.Context) bool { + httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil) + if err != nil { + return false + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} diff --git a/ai-compliance-sdk/internal/training/models.go b/ai-compliance-sdk/internal/training/models.go new file mode 100644 index 0000000..82344b3 --- /dev/null +++ b/ai-compliance-sdk/internal/training/models.go @@ -0,0 +1,500 @@ +package training + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// RegulationArea represents a compliance regulation area +type RegulationArea string + +const ( + RegulationDSGVO RegulationArea = "dsgvo" + RegulationNIS2 RegulationArea = "nis2" + RegulationISO27001 RegulationArea = "iso27001" + RegulationAIAct RegulationArea = "ai_act" + RegulationGeschGehG RegulationArea = "geschgehg" + RegulationHinSchG RegulationArea = "hinschg" +) + +// FrequencyType represents the training frequency +type FrequencyType string + +const ( + FrequencyOnboarding FrequencyType = "onboarding" + FrequencyAnnual FrequencyType = "annual" + FrequencyEventTrigger FrequencyType = "event_trigger" + FrequencyMicro FrequencyType = "micro" +) + +// AssignmentStatus represents the status of a training assignment +type AssignmentStatus string + +const ( + AssignmentStatusPending AssignmentStatus = "pending" + AssignmentStatusInProgress AssignmentStatus = "in_progress" + AssignmentStatusCompleted AssignmentStatus = "completed" + AssignmentStatusOverdue AssignmentStatus = "overdue" + AssignmentStatusExpired AssignmentStatus = "expired" +) + +// TriggerType represents how a training was assigned +type TriggerType string + +const ( + TriggerOnboarding TriggerType = "onboarding" + TriggerAnnual TriggerType = "annual" + TriggerEvent TriggerType = "event" + TriggerManual TriggerType = "manual" +) + +// ContentFormat represents the format of module content +type ContentFormat string + +const ( + ContentFormatMarkdown ContentFormat = "markdown" + ContentFormatHTML ContentFormat = "html" +) + +// Difficulty represents the difficulty level of a quiz question +type Difficulty string + +const ( + DifficultyEasy Difficulty = "easy" + DifficultyMedium Difficulty = "medium" + DifficultyHard Difficulty = "hard" +) + +// AuditAction represents an action in the audit trail +type AuditAction string + +const ( + AuditActionAssigned AuditAction = "assigned" + AuditActionStarted AuditAction = "started" + AuditActionCompleted AuditAction = "completed" + AuditActionQuizSubmitted AuditAction = "quiz_submitted" + AuditActionEscalated AuditAction = "escalated" + AuditActionCertificateIssued AuditAction = "certificate_issued" + AuditActionContentGenerated AuditAction = "content_generated" +) + +// AuditEntityType represents the type of entity in audit log +type AuditEntityType string + +const ( + AuditEntityAssignment AuditEntityType = "assignment" + AuditEntityModule AuditEntityType = "module" + AuditEntityQuiz AuditEntityType = "quiz" + AuditEntityCertificate AuditEntityType = "certificate" +) + +// ============================================================================ +// Role Constants +// ============================================================================ + +const ( + RoleR1 = "R1" // Geschaeftsfuehrung + RoleR2 = "R2" // IT-Leitung + RoleR3 = "R3" // DSB + RoleR4 = "R4" // ISB + RoleR5 = "R5" // HR + RoleR6 = "R6" // Einkauf + RoleR7 = "R7" // Fachabteilung + RoleR8 = "R8" // IT-Admin + RoleR9 = "R9" // Alle Mitarbeiter +) + +// RoleLabels maps role codes to human-readable labels +var RoleLabels = map[string]string{ + RoleR1: "Geschaeftsfuehrung", + RoleR2: "IT-Leitung", + RoleR3: "Datenschutzbeauftragter", + RoleR4: "Informationssicherheitsbeauftragter", + RoleR5: "HR / Personal", + RoleR6: "Einkauf / Beschaffung", + RoleR7: "Fachabteilung", + RoleR8: "IT-Administration", + RoleR9: "Alle Mitarbeiter", +} + +// NIS2RoleMapping maps internal roles to NIS2 levels +var NIS2RoleMapping = map[string]string{ + RoleR1: "N1", // Geschaeftsfuehrung + RoleR2: "N2", // IT-Leitung + RoleR3: "N3", // DSB + RoleR4: "N3", // ISB + RoleR5: "N4", // HR + RoleR6: "N4", // Einkauf + RoleR7: "N5", // Fachabteilung + RoleR8: "N2", // IT-Admin + RoleR9: "N5", // Alle Mitarbeiter +} + +// ============================================================================ +// Main Entities +// ============================================================================ + +// TrainingModule represents a compliance training module +type TrainingModule struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + AcademyCourseID *uuid.UUID `json:"academy_course_id,omitempty"` + ModuleCode string `json:"module_code"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + RegulationArea RegulationArea `json:"regulation_area"` + NIS2Relevant bool `json:"nis2_relevant"` + ISOControls []string `json:"iso_controls"` // JSONB + FrequencyType FrequencyType `json:"frequency_type"` + ValidityDays int `json:"validity_days"` + RiskWeight float64 `json:"risk_weight"` + ContentType string `json:"content_type"` + DurationMinutes int `json:"duration_minutes"` + PassThreshold int `json:"pass_threshold"` + IsActive bool `json:"is_active"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TrainingMatrixEntry represents a role-to-module mapping in the CTM +type TrainingMatrixEntry struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + RoleCode string `json:"role_code"` + ModuleID uuid.UUID `json:"module_id"` + IsMandatory bool `json:"is_mandatory"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"created_at"` + // Joined fields (optional, populated in queries) + ModuleCode string `json:"module_code,omitempty"` + ModuleTitle string `json:"module_title,omitempty"` +} + +// TrainingAssignment represents a user's training assignment +type TrainingAssignment struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + ModuleID uuid.UUID `json:"module_id"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + RoleCode string `json:"role_code,omitempty"` + TriggerType TriggerType `json:"trigger_type"` + TriggerEvent string `json:"trigger_event,omitempty"` + Status AssignmentStatus `json:"status"` + ProgressPercent int `json:"progress_percent"` + QuizScore *float64 `json:"quiz_score,omitempty"` + QuizPassed *bool `json:"quiz_passed,omitempty"` + QuizAttempts int `json:"quiz_attempts"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Deadline time.Time `json:"deadline"` + CertificateID *uuid.UUID `json:"certificate_id,omitempty"` + EscalationLevel int `json:"escalation_level"` + LastEscalationAt *time.Time `json:"last_escalation_at,omitempty"` + EnrollmentID *uuid.UUID `json:"enrollment_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // Joined fields + ModuleCode string `json:"module_code,omitempty"` + ModuleTitle string `json:"module_title,omitempty"` +} + +// QuizQuestion represents a persistent quiz question for a module +type QuizQuestion struct { + ID uuid.UUID `json:"id"` + ModuleID uuid.UUID `json:"module_id"` + Question string `json:"question"` + Options []string `json:"options"` // JSONB + CorrectIndex int `json:"correct_index"` + Explanation string `json:"explanation,omitempty"` + Difficulty Difficulty `json:"difficulty"` + IsActive bool `json:"is_active"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// QuizAttempt represents a single quiz attempt by a user +type QuizAttempt struct { + ID uuid.UUID `json:"id"` + AssignmentID uuid.UUID `json:"assignment_id"` + UserID uuid.UUID `json:"user_id"` + Answers []QuizAnswer `json:"answers"` // JSONB + Score float64 `json:"score"` + Passed bool `json:"passed"` + CorrectCount int `json:"correct_count"` + TotalCount int `json:"total_count"` + DurationSeconds *int `json:"duration_seconds,omitempty"` + AttemptedAt time.Time `json:"attempted_at"` +} + +// QuizAnswer represents a single answer within a quiz attempt +type QuizAnswer struct { + QuestionID uuid.UUID `json:"question_id"` + SelectedIndex int `json:"selected_index"` + Correct bool `json:"correct"` +} + +// AuditLogEntry represents an entry in the training audit trail +type AuditLogEntry struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + UserID *uuid.UUID `json:"user_id,omitempty"` + Action AuditAction `json:"action"` + EntityType AuditEntityType `json:"entity_type"` + EntityID *uuid.UUID `json:"entity_id,omitempty"` + Details map[string]interface{} `json:"details"` // JSONB + IPAddress string `json:"ip_address,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ModuleContent represents LLM-generated or manual content for a module +type ModuleContent struct { + ID uuid.UUID `json:"id"` + ModuleID uuid.UUID `json:"module_id"` + Version int `json:"version"` + ContentFormat ContentFormat `json:"content_format"` + ContentBody string `json:"content_body"` + Summary string `json:"summary,omitempty"` + GeneratedBy string `json:"generated_by,omitempty"` + LLMModel string `json:"llm_model,omitempty"` + IsPublished bool `json:"is_published"` + ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TrainingStats contains aggregated training metrics +type TrainingStats struct { + TotalModules int `json:"total_modules"` + TotalAssignments int `json:"total_assignments"` + CompletionRate float64 `json:"completion_rate"` + OverdueCount int `json:"overdue_count"` + PendingCount int `json:"pending_count"` + InProgressCount int `json:"in_progress_count"` + CompletedCount int `json:"completed_count"` + AvgQuizScore float64 `json:"avg_quiz_score"` + AvgCompletionDays float64 `json:"avg_completion_days"` + UpcomingDeadlines int `json:"upcoming_deadlines"` // within 7 days +} + +// ComplianceGap represents a missing or overdue training requirement +type ComplianceGap struct { + ModuleID uuid.UUID `json:"module_id"` + ModuleCode string `json:"module_code"` + ModuleTitle string `json:"module_title"` + RegulationArea RegulationArea `json:"regulation_area"` + RoleCode string `json:"role_code"` + IsMandatory bool `json:"is_mandatory"` + AssignmentID *uuid.UUID `json:"assignment_id,omitempty"` + Status string `json:"status"` // "missing", "overdue", "expired" + Deadline *time.Time `json:"deadline,omitempty"` +} + +// EscalationResult represents the result of an escalation check +type EscalationResult struct { + AssignmentID uuid.UUID `json:"assignment_id"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + ModuleTitle string `json:"module_title"` + PreviousLevel int `json:"previous_level"` + NewLevel int `json:"new_level"` + DaysOverdue int `json:"days_overdue"` + EscalationLabel string `json:"escalation_label"` +} + +// DeadlineInfo represents upcoming deadline information +type DeadlineInfo struct { + AssignmentID uuid.UUID `json:"assignment_id"` + ModuleCode string `json:"module_code"` + ModuleTitle string `json:"module_title"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + Deadline time.Time `json:"deadline"` + DaysLeft int `json:"days_left"` + Status AssignmentStatus `json:"status"` +} + +// ============================================================================ +// Filter Types +// ============================================================================ + +// ModuleFilters defines filters for listing modules +type ModuleFilters struct { + RegulationArea RegulationArea + FrequencyType FrequencyType + IsActive *bool + NIS2Relevant *bool + Search string + Limit int + Offset int +} + +// AssignmentFilters defines filters for listing assignments +type AssignmentFilters struct { + ModuleID *uuid.UUID + UserID *uuid.UUID + RoleCode string + Status AssignmentStatus + Overdue *bool + Limit int + Offset int +} + +// AuditLogFilters defines filters for listing audit log entries +type AuditLogFilters struct { + UserID *uuid.UUID + Action AuditAction + EntityType AuditEntityType + Limit int + Offset int +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// CreateModuleRequest is the API request for creating a training module +type CreateModuleRequest struct { + ModuleCode string `json:"module_code" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description,omitempty"` + RegulationArea RegulationArea `json:"regulation_area" binding:"required"` + NIS2Relevant bool `json:"nis2_relevant"` + ISOControls []string `json:"iso_controls,omitempty"` + FrequencyType FrequencyType `json:"frequency_type" binding:"required"` + ValidityDays int `json:"validity_days"` + RiskWeight float64 `json:"risk_weight"` + ContentType string `json:"content_type"` + DurationMinutes int `json:"duration_minutes"` + PassThreshold int `json:"pass_threshold"` +} + +// UpdateModuleRequest is the API request for updating a training module +type UpdateModuleRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + NIS2Relevant *bool `json:"nis2_relevant,omitempty"` + ISOControls []string `json:"iso_controls,omitempty"` + ValidityDays *int `json:"validity_days,omitempty"` + RiskWeight *float64 `json:"risk_weight,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` + PassThreshold *int `json:"pass_threshold,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +// SetMatrixEntryRequest is the API request for setting a CTM entry +type SetMatrixEntryRequest struct { + RoleCode string `json:"role_code" binding:"required"` + ModuleID uuid.UUID `json:"module_id" binding:"required"` + IsMandatory bool `json:"is_mandatory"` + Priority int `json:"priority"` +} + +// ComputeAssignmentsRequest is the API request for computing assignments +type ComputeAssignmentsRequest struct { + UserID uuid.UUID `json:"user_id" binding:"required"` + UserName string `json:"user_name" binding:"required"` + UserEmail string `json:"user_email" binding:"required"` + Roles []string `json:"roles" binding:"required"` + Trigger string `json:"trigger"` +} + +// UpdateAssignmentProgressRequest updates progress on an assignment +type UpdateAssignmentProgressRequest struct { + Progress int `json:"progress" binding:"required"` +} + +// SubmitTrainingQuizRequest is the API request for submitting a quiz +type SubmitTrainingQuizRequest struct { + AssignmentID uuid.UUID `json:"assignment_id" binding:"required"` + Answers []QuizAnswer `json:"answers" binding:"required"` + DurationSeconds *int `json:"duration_seconds,omitempty"` +} + +// SubmitTrainingQuizResponse is the API response for quiz submission +type SubmitTrainingQuizResponse struct { + AttemptID uuid.UUID `json:"attempt_id"` + Score float64 `json:"score"` + Passed bool `json:"passed"` + CorrectCount int `json:"correct_count"` + TotalCount int `json:"total_count"` + Threshold int `json:"threshold"` +} + +// GenerateContentRequest is the API request for LLM content generation +type GenerateContentRequest struct { + ModuleID uuid.UUID `json:"module_id" binding:"required"` + Language string `json:"language"` +} + +// GenerateQuizRequest is the API request for LLM quiz generation +type GenerateQuizRequest struct { + ModuleID uuid.UUID `json:"module_id" binding:"required"` + Count int `json:"count"` +} + +// PublishContentRequest is the API request for publishing content +type PublishContentRequest struct { + ReviewedBy uuid.UUID `json:"reviewed_by"` +} + +// BulkAssignRequest is the API request for bulk assigning a module +type BulkAssignRequest struct { + ModuleID uuid.UUID `json:"module_id" binding:"required"` + RoleCodes []string `json:"role_codes" binding:"required"` + Trigger string `json:"trigger"` + Deadline time.Time `json:"deadline" binding:"required"` +} + +// ModuleListResponse is the API response for listing modules +type ModuleListResponse struct { + Modules []TrainingModule `json:"modules"` + Total int `json:"total"` +} + +// AssignmentListResponse is the API response for listing assignments +type AssignmentListResponse struct { + Assignments []TrainingAssignment `json:"assignments"` + Total int `json:"total"` +} + +// MatrixResponse is the API response for the full training matrix +type MatrixResponse struct { + Entries map[string][]TrainingMatrixEntry `json:"entries"` // role_code -> entries + Roles map[string]string `json:"roles"` // role_code -> label +} + +// AuditLogResponse is the API response for listing audit log entries +type AuditLogResponse struct { + Entries []AuditLogEntry `json:"entries"` + Total int `json:"total"` +} + +// EscalationResponse is the API response for escalation check +type EscalationResponse struct { + Results []EscalationResult `json:"results"` + TotalChecked int `json:"total_checked"` + Escalated int `json:"escalated"` +} + +// DeadlineListResponse is the API response for listing deadlines +type DeadlineListResponse struct { + Deadlines []DeadlineInfo `json:"deadlines"` + Total int `json:"total"` +} + +// BulkResult holds the result of a bulk generation operation +type BulkResult struct { + Generated int `json:"generated"` + Skipped int `json:"skipped"` + Errors []string `json:"errors"` +} diff --git a/ai-compliance-sdk/internal/training/store.go b/ai-compliance-sdk/internal/training/store.go new file mode 100644 index 0000000..6d5c0b0 --- /dev/null +++ b/ai-compliance-sdk/internal/training/store.go @@ -0,0 +1,1277 @@ +package training + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles training data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new training store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Module CRUD Operations +// ============================================================================ + +// CreateModule creates a new training module +func (s *Store) CreateModule(ctx context.Context, module *TrainingModule) error { + module.ID = uuid.New() + module.CreatedAt = time.Now().UTC() + module.UpdatedAt = module.CreatedAt + if !module.IsActive { + module.IsActive = true + } + + isoControls, _ := json.Marshal(module.ISOControls) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_modules ( + id, tenant_id, academy_course_id, module_code, title, description, + regulation_area, nis2_relevant, iso_controls, frequency_type, + validity_days, risk_weight, content_type, duration_minutes, + pass_threshold, is_active, sort_order, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17, $18, $19 + ) + `, + module.ID, module.TenantID, module.AcademyCourseID, module.ModuleCode, module.Title, module.Description, + string(module.RegulationArea), module.NIS2Relevant, isoControls, string(module.FrequencyType), + module.ValidityDays, module.RiskWeight, module.ContentType, module.DurationMinutes, + module.PassThreshold, module.IsActive, module.SortOrder, module.CreatedAt, module.UpdatedAt, + ) + + return err +} + +// GetModule retrieves a module by ID +func (s *Store) GetModule(ctx context.Context, id uuid.UUID) (*TrainingModule, error) { + var module TrainingModule + var regulationArea, frequencyType string + var isoControls []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, academy_course_id, module_code, title, description, + regulation_area, nis2_relevant, iso_controls, frequency_type, + validity_days, risk_weight, content_type, duration_minutes, + pass_threshold, is_active, sort_order, created_at, updated_at + FROM training_modules WHERE id = $1 + `, id).Scan( + &module.ID, &module.TenantID, &module.AcademyCourseID, &module.ModuleCode, &module.Title, &module.Description, + ®ulationArea, &module.NIS2Relevant, &isoControls, &frequencyType, + &module.ValidityDays, &module.RiskWeight, &module.ContentType, &module.DurationMinutes, + &module.PassThreshold, &module.IsActive, &module.SortOrder, &module.CreatedAt, &module.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + module.RegulationArea = RegulationArea(regulationArea) + module.FrequencyType = FrequencyType(frequencyType) + json.Unmarshal(isoControls, &module.ISOControls) + if module.ISOControls == nil { + module.ISOControls = []string{} + } + + return &module, nil +} + +// ListModules lists training modules for a tenant with optional filters +func (s *Store) ListModules(ctx context.Context, tenantID uuid.UUID, filters *ModuleFilters) ([]TrainingModule, int, error) { + countQuery := "SELECT COUNT(*) FROM training_modules WHERE tenant_id = $1" + countArgs := []interface{}{tenantID} + countArgIdx := 2 + + query := ` + SELECT + id, tenant_id, academy_course_id, module_code, title, description, + regulation_area, nis2_relevant, iso_controls, frequency_type, + validity_days, risk_weight, content_type, duration_minutes, + pass_threshold, is_active, sort_order, created_at, updated_at + FROM training_modules WHERE tenant_id = $1` + + args := []interface{}{tenantID} + argIdx := 2 + + if filters != nil { + if filters.RegulationArea != "" { + query += fmt.Sprintf(" AND regulation_area = $%d", argIdx) + args = append(args, string(filters.RegulationArea)) + argIdx++ + countQuery += fmt.Sprintf(" AND regulation_area = $%d", countArgIdx) + countArgs = append(countArgs, string(filters.RegulationArea)) + countArgIdx++ + } + if filters.FrequencyType != "" { + query += fmt.Sprintf(" AND frequency_type = $%d", argIdx) + args = append(args, string(filters.FrequencyType)) + argIdx++ + countQuery += fmt.Sprintf(" AND frequency_type = $%d", countArgIdx) + countArgs = append(countArgs, string(filters.FrequencyType)) + countArgIdx++ + } + if filters.IsActive != nil { + query += fmt.Sprintf(" AND is_active = $%d", argIdx) + args = append(args, *filters.IsActive) + argIdx++ + countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx) + countArgs = append(countArgs, *filters.IsActive) + countArgIdx++ + } + if filters.NIS2Relevant != nil { + query += fmt.Sprintf(" AND nis2_relevant = $%d", argIdx) + args = append(args, *filters.NIS2Relevant) + argIdx++ + countQuery += fmt.Sprintf(" AND nis2_relevant = $%d", countArgIdx) + countArgs = append(countArgs, *filters.NIS2Relevant) + countArgIdx++ + } + if filters.Search != "" { + query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d OR module_code ILIKE $%d)", argIdx, argIdx, argIdx) + args = append(args, "%"+filters.Search+"%") + argIdx++ + countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d OR module_code ILIKE $%d)", countArgIdx, countArgIdx, countArgIdx) + countArgs = append(countArgs, "%"+filters.Search+"%") + countArgIdx++ + } + } + + var total int + err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) + if err != nil { + return nil, 0, err + } + + query += " ORDER BY sort_order ASC, created_at DESC" + + if filters != nil && filters.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argIdx) + args = append(args, filters.Limit) + argIdx++ + if filters.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIdx) + args = append(args, filters.Offset) + argIdx++ + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var modules []TrainingModule + for rows.Next() { + var module TrainingModule + var regulationArea, frequencyType string + var isoControls []byte + + err := rows.Scan( + &module.ID, &module.TenantID, &module.AcademyCourseID, &module.ModuleCode, &module.Title, &module.Description, + ®ulationArea, &module.NIS2Relevant, &isoControls, &frequencyType, + &module.ValidityDays, &module.RiskWeight, &module.ContentType, &module.DurationMinutes, + &module.PassThreshold, &module.IsActive, &module.SortOrder, &module.CreatedAt, &module.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + + module.RegulationArea = RegulationArea(regulationArea) + module.FrequencyType = FrequencyType(frequencyType) + json.Unmarshal(isoControls, &module.ISOControls) + if module.ISOControls == nil { + module.ISOControls = []string{} + } + + modules = append(modules, module) + } + + if modules == nil { + modules = []TrainingModule{} + } + + return modules, total, nil +} + +// UpdateModule updates a training module +func (s *Store) UpdateModule(ctx context.Context, module *TrainingModule) error { + module.UpdatedAt = time.Now().UTC() + isoControls, _ := json.Marshal(module.ISOControls) + + _, err := s.pool.Exec(ctx, ` + UPDATE training_modules SET + title = $2, description = $3, nis2_relevant = $4, + iso_controls = $5, validity_days = $6, risk_weight = $7, + duration_minutes = $8, pass_threshold = $9, is_active = $10, + sort_order = $11, updated_at = $12 + WHERE id = $1 + `, + module.ID, module.Title, module.Description, module.NIS2Relevant, + isoControls, module.ValidityDays, module.RiskWeight, + module.DurationMinutes, module.PassThreshold, module.IsActive, + module.SortOrder, module.UpdatedAt, + ) + + return err +} + +// ============================================================================ +// Matrix Operations +// ============================================================================ + +// GetMatrixForRole returns all matrix entries for a given role +func (s *Store) GetMatrixForRole(ctx context.Context, tenantID uuid.UUID, roleCode string) ([]TrainingMatrixEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + tm.id, tm.tenant_id, tm.role_code, tm.module_id, + tm.is_mandatory, tm.priority, tm.created_at, + m.module_code, m.title + FROM training_matrix tm + JOIN training_modules m ON m.id = tm.module_id + WHERE tm.tenant_id = $1 AND tm.role_code = $2 + ORDER BY tm.priority ASC + `, tenantID, roleCode) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []TrainingMatrixEntry + for rows.Next() { + var entry TrainingMatrixEntry + err := rows.Scan( + &entry.ID, &entry.TenantID, &entry.RoleCode, &entry.ModuleID, + &entry.IsMandatory, &entry.Priority, &entry.CreatedAt, + &entry.ModuleCode, &entry.ModuleTitle, + ) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + + if entries == nil { + entries = []TrainingMatrixEntry{} + } + + return entries, nil +} + +// GetMatrixForTenant returns the full CTM for a tenant +func (s *Store) GetMatrixForTenant(ctx context.Context, tenantID uuid.UUID) ([]TrainingMatrixEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + tm.id, tm.tenant_id, tm.role_code, tm.module_id, + tm.is_mandatory, tm.priority, tm.created_at, + m.module_code, m.title + FROM training_matrix tm + JOIN training_modules m ON m.id = tm.module_id + WHERE tm.tenant_id = $1 + ORDER BY tm.role_code ASC, tm.priority ASC + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []TrainingMatrixEntry + for rows.Next() { + var entry TrainingMatrixEntry + err := rows.Scan( + &entry.ID, &entry.TenantID, &entry.RoleCode, &entry.ModuleID, + &entry.IsMandatory, &entry.Priority, &entry.CreatedAt, + &entry.ModuleCode, &entry.ModuleTitle, + ) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + + if entries == nil { + entries = []TrainingMatrixEntry{} + } + + return entries, nil +} + +// SetMatrixEntry creates or updates a CTM entry +func (s *Store) SetMatrixEntry(ctx context.Context, entry *TrainingMatrixEntry) error { + entry.ID = uuid.New() + entry.CreatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_matrix ( + id, tenant_id, role_code, module_id, is_mandatory, priority, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (tenant_id, role_code, module_id) + DO UPDATE SET is_mandatory = EXCLUDED.is_mandatory, priority = EXCLUDED.priority + `, + entry.ID, entry.TenantID, entry.RoleCode, entry.ModuleID, + entry.IsMandatory, entry.Priority, entry.CreatedAt, + ) + + return err +} + +// DeleteMatrixEntry removes a CTM entry +func (s *Store) DeleteMatrixEntry(ctx context.Context, tenantID uuid.UUID, roleCode string, moduleID uuid.UUID) error { + _, err := s.pool.Exec(ctx, + "DELETE FROM training_matrix WHERE tenant_id = $1 AND role_code = $2 AND module_id = $3", + tenantID, roleCode, moduleID, + ) + return err +} + +// ============================================================================ +// Assignment Operations +// ============================================================================ + +// CreateAssignment creates a new training assignment +func (s *Store) CreateAssignment(ctx context.Context, assignment *TrainingAssignment) error { + assignment.ID = uuid.New() + assignment.CreatedAt = time.Now().UTC() + assignment.UpdatedAt = assignment.CreatedAt + if assignment.Status == "" { + assignment.Status = AssignmentStatusPending + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_assignments ( + id, tenant_id, module_id, user_id, user_name, user_email, + role_code, trigger_type, trigger_event, status, progress_percent, + quiz_score, quiz_passed, quiz_attempts, + started_at, completed_at, deadline, certificate_id, + escalation_level, last_escalation_at, enrollment_id, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, + $15, $16, $17, $18, + $19, $20, $21, + $22, $23 + ) + `, + assignment.ID, assignment.TenantID, assignment.ModuleID, assignment.UserID, assignment.UserName, assignment.UserEmail, + assignment.RoleCode, string(assignment.TriggerType), assignment.TriggerEvent, string(assignment.Status), assignment.ProgressPercent, + assignment.QuizScore, assignment.QuizPassed, assignment.QuizAttempts, + assignment.StartedAt, assignment.CompletedAt, assignment.Deadline, assignment.CertificateID, + assignment.EscalationLevel, assignment.LastEscalationAt, assignment.EnrollmentID, + assignment.CreatedAt, assignment.UpdatedAt, + ) + + return err +} + +// GetAssignment retrieves an assignment by ID +func (s *Store) GetAssignment(ctx context.Context, id uuid.UUID) (*TrainingAssignment, error) { + var a TrainingAssignment + var status, triggerType string + + err := s.pool.QueryRow(ctx, ` + SELECT + ta.id, ta.tenant_id, ta.module_id, ta.user_id, ta.user_name, ta.user_email, + ta.role_code, ta.trigger_type, ta.trigger_event, ta.status, ta.progress_percent, + ta.quiz_score, ta.quiz_passed, ta.quiz_attempts, + ta.started_at, ta.completed_at, ta.deadline, ta.certificate_id, + ta.escalation_level, ta.last_escalation_at, ta.enrollment_id, + ta.created_at, ta.updated_at, + m.module_code, m.title + FROM training_assignments ta + JOIN training_modules m ON m.id = ta.module_id + WHERE ta.id = $1 + `, id).Scan( + &a.ID, &a.TenantID, &a.ModuleID, &a.UserID, &a.UserName, &a.UserEmail, + &a.RoleCode, &triggerType, &a.TriggerEvent, &status, &a.ProgressPercent, + &a.QuizScore, &a.QuizPassed, &a.QuizAttempts, + &a.StartedAt, &a.CompletedAt, &a.Deadline, &a.CertificateID, + &a.EscalationLevel, &a.LastEscalationAt, &a.EnrollmentID, + &a.CreatedAt, &a.UpdatedAt, + &a.ModuleCode, &a.ModuleTitle, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + a.Status = AssignmentStatus(status) + a.TriggerType = TriggerType(triggerType) + return &a, nil +} + +// ListAssignments lists assignments for a tenant with optional filters +func (s *Store) ListAssignments(ctx context.Context, tenantID uuid.UUID, filters *AssignmentFilters) ([]TrainingAssignment, int, error) { + countQuery := "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1" + countArgs := []interface{}{tenantID} + countArgIdx := 2 + + query := ` + SELECT + ta.id, ta.tenant_id, ta.module_id, ta.user_id, ta.user_name, ta.user_email, + ta.role_code, ta.trigger_type, ta.trigger_event, ta.status, ta.progress_percent, + ta.quiz_score, ta.quiz_passed, ta.quiz_attempts, + ta.started_at, ta.completed_at, ta.deadline, ta.certificate_id, + ta.escalation_level, ta.last_escalation_at, ta.enrollment_id, + ta.created_at, ta.updated_at, + m.module_code, m.title + FROM training_assignments ta + JOIN training_modules m ON m.id = ta.module_id + WHERE ta.tenant_id = $1` + + args := []interface{}{tenantID} + argIdx := 2 + + if filters != nil { + if filters.ModuleID != nil { + query += fmt.Sprintf(" AND ta.module_id = $%d", argIdx) + args = append(args, *filters.ModuleID) + argIdx++ + countQuery += fmt.Sprintf(" AND module_id = $%d", countArgIdx) + countArgs = append(countArgs, *filters.ModuleID) + countArgIdx++ + } + if filters.UserID != nil { + query += fmt.Sprintf(" AND ta.user_id = $%d", argIdx) + args = append(args, *filters.UserID) + argIdx++ + countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx) + countArgs = append(countArgs, *filters.UserID) + countArgIdx++ + } + if filters.RoleCode != "" { + query += fmt.Sprintf(" AND ta.role_code = $%d", argIdx) + args = append(args, filters.RoleCode) + argIdx++ + countQuery += fmt.Sprintf(" AND role_code = $%d", countArgIdx) + countArgs = append(countArgs, filters.RoleCode) + countArgIdx++ + } + if filters.Status != "" { + query += fmt.Sprintf(" AND ta.status = $%d", argIdx) + args = append(args, string(filters.Status)) + argIdx++ + countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx) + countArgs = append(countArgs, string(filters.Status)) + countArgIdx++ + } + if filters.Overdue != nil && *filters.Overdue { + query += " AND ta.deadline < NOW() AND ta.status IN ('pending', 'in_progress')" + countQuery += " AND deadline < NOW() AND status IN ('pending', 'in_progress')" + } + } + + var total int + err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) + if err != nil { + return nil, 0, err + } + + query += " ORDER BY ta.deadline ASC" + + if filters != nil && filters.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argIdx) + args = append(args, filters.Limit) + argIdx++ + if filters.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIdx) + args = append(args, filters.Offset) + argIdx++ + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var assignments []TrainingAssignment + for rows.Next() { + var a TrainingAssignment + var status, triggerType string + + err := rows.Scan( + &a.ID, &a.TenantID, &a.ModuleID, &a.UserID, &a.UserName, &a.UserEmail, + &a.RoleCode, &triggerType, &a.TriggerEvent, &status, &a.ProgressPercent, + &a.QuizScore, &a.QuizPassed, &a.QuizAttempts, + &a.StartedAt, &a.CompletedAt, &a.Deadline, &a.CertificateID, + &a.EscalationLevel, &a.LastEscalationAt, &a.EnrollmentID, + &a.CreatedAt, &a.UpdatedAt, + &a.ModuleCode, &a.ModuleTitle, + ) + if err != nil { + return nil, 0, err + } + + a.Status = AssignmentStatus(status) + a.TriggerType = TriggerType(triggerType) + assignments = append(assignments, a) + } + + if assignments == nil { + assignments = []TrainingAssignment{} + } + + return assignments, total, nil +} + +// UpdateAssignmentStatus updates the status and related fields +func (s *Store) UpdateAssignmentStatus(ctx context.Context, id uuid.UUID, status AssignmentStatus, progress int) error { + now := time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + UPDATE training_assignments SET + status = $2, + progress_percent = $3, + started_at = CASE + WHEN started_at IS NULL AND $2 IN ('in_progress', 'completed') THEN $4 + ELSE started_at + END, + completed_at = CASE + WHEN $2 = 'completed' THEN $4 + ELSE completed_at + END, + updated_at = $4 + WHERE id = $1 + `, id, string(status), progress, now) + + return err +} + +// UpdateAssignmentQuizResult updates quiz-related fields on an assignment +func (s *Store) UpdateAssignmentQuizResult(ctx context.Context, id uuid.UUID, score float64, passed bool, attempts int) error { + now := time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + UPDATE training_assignments SET + quiz_score = $2, + quiz_passed = $3, + quiz_attempts = $4, + status = CASE WHEN $3 = true THEN 'completed' ELSE status END, + completed_at = CASE WHEN $3 = true THEN $5 ELSE completed_at END, + progress_percent = CASE WHEN $3 = true THEN 100 ELSE progress_percent END, + updated_at = $5 + WHERE id = $1 + `, id, score, passed, attempts, now) + + return err +} + +// ListOverdueAssignments returns assignments past their deadline +func (s *Store) ListOverdueAssignments(ctx context.Context, tenantID uuid.UUID) ([]TrainingAssignment, error) { + overdue := true + assignments, _, err := s.ListAssignments(ctx, tenantID, &AssignmentFilters{ + Overdue: &overdue, + Limit: 1000, + }) + return assignments, err +} + +// ============================================================================ +// Quiz Operations +// ============================================================================ + +// CreateQuizQuestion creates a new quiz question +func (s *Store) CreateQuizQuestion(ctx context.Context, q *QuizQuestion) error { + q.ID = uuid.New() + q.CreatedAt = time.Now().UTC() + if !q.IsActive { + q.IsActive = true + } + + options, _ := json.Marshal(q.Options) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_quiz_questions ( + id, module_id, question, options, correct_index, + explanation, difficulty, is_active, sort_order, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, + q.ID, q.ModuleID, q.Question, options, q.CorrectIndex, + q.Explanation, string(q.Difficulty), q.IsActive, q.SortOrder, q.CreatedAt, + ) + + return err +} + +// ListQuizQuestions lists quiz questions for a module +func (s *Store) ListQuizQuestions(ctx context.Context, moduleID uuid.UUID) ([]QuizQuestion, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, module_id, question, options, correct_index, + explanation, difficulty, is_active, sort_order, created_at + FROM training_quiz_questions + WHERE module_id = $1 AND is_active = true + ORDER BY sort_order ASC, created_at ASC + `, moduleID) + if err != nil { + return nil, err + } + defer rows.Close() + + var questions []QuizQuestion + for rows.Next() { + var q QuizQuestion + var options []byte + var difficulty string + + err := rows.Scan( + &q.ID, &q.ModuleID, &q.Question, &options, &q.CorrectIndex, + &q.Explanation, &difficulty, &q.IsActive, &q.SortOrder, &q.CreatedAt, + ) + if err != nil { + return nil, err + } + + q.Difficulty = Difficulty(difficulty) + json.Unmarshal(options, &q.Options) + if q.Options == nil { + q.Options = []string{} + } + + questions = append(questions, q) + } + + if questions == nil { + questions = []QuizQuestion{} + } + + return questions, nil +} + +// CreateQuizAttempt records a quiz attempt +func (s *Store) CreateQuizAttempt(ctx context.Context, attempt *QuizAttempt) error { + attempt.ID = uuid.New() + attempt.AttemptedAt = time.Now().UTC() + + answers, _ := json.Marshal(attempt.Answers) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_quiz_attempts ( + id, assignment_id, user_id, answers, score, + passed, correct_count, total_count, duration_seconds, attempted_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, + attempt.ID, attempt.AssignmentID, attempt.UserID, answers, attempt.Score, + attempt.Passed, attempt.CorrectCount, attempt.TotalCount, attempt.DurationSeconds, attempt.AttemptedAt, + ) + + return err +} + +// ListQuizAttempts lists quiz attempts for an assignment +func (s *Store) ListQuizAttempts(ctx context.Context, assignmentID uuid.UUID) ([]QuizAttempt, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, assignment_id, user_id, answers, score, + passed, correct_count, total_count, duration_seconds, attempted_at + FROM training_quiz_attempts + WHERE assignment_id = $1 + ORDER BY attempted_at DESC + `, assignmentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var attempts []QuizAttempt + for rows.Next() { + var a QuizAttempt + var answers []byte + + err := rows.Scan( + &a.ID, &a.AssignmentID, &a.UserID, &answers, &a.Score, + &a.Passed, &a.CorrectCount, &a.TotalCount, &a.DurationSeconds, &a.AttemptedAt, + ) + if err != nil { + return nil, err + } + + json.Unmarshal(answers, &a.Answers) + if a.Answers == nil { + a.Answers = []QuizAnswer{} + } + + attempts = append(attempts, a) + } + + if attempts == nil { + attempts = []QuizAttempt{} + } + + return attempts, nil +} + +// ============================================================================ +// Content Operations +// ============================================================================ + +// CreateModuleContent creates new content for a module +func (s *Store) CreateModuleContent(ctx context.Context, content *ModuleContent) error { + content.ID = uuid.New() + content.CreatedAt = time.Now().UTC() + content.UpdatedAt = content.CreatedAt + + // Auto-increment version + var maxVersion int + s.pool.QueryRow(ctx, + "SELECT COALESCE(MAX(version), 0) FROM training_module_content WHERE module_id = $1", + content.ModuleID).Scan(&maxVersion) + content.Version = maxVersion + 1 + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_module_content ( + id, module_id, version, content_format, content_body, + summary, generated_by, llm_model, is_published, + reviewed_by, reviewed_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + `, + content.ID, content.ModuleID, content.Version, string(content.ContentFormat), content.ContentBody, + content.Summary, content.GeneratedBy, content.LLMModel, content.IsPublished, + content.ReviewedBy, content.ReviewedAt, content.CreatedAt, content.UpdatedAt, + ) + + return err +} + +// GetPublishedContent retrieves the published content for a module +func (s *Store) GetPublishedContent(ctx context.Context, moduleID uuid.UUID) (*ModuleContent, error) { + var content ModuleContent + var contentFormat string + + err := s.pool.QueryRow(ctx, ` + SELECT + id, module_id, version, content_format, content_body, + summary, generated_by, llm_model, is_published, + reviewed_by, reviewed_at, created_at, updated_at + FROM training_module_content + WHERE module_id = $1 AND is_published = true + ORDER BY version DESC + LIMIT 1 + `, moduleID).Scan( + &content.ID, &content.ModuleID, &content.Version, &contentFormat, &content.ContentBody, + &content.Summary, &content.GeneratedBy, &content.LLMModel, &content.IsPublished, + &content.ReviewedBy, &content.ReviewedAt, &content.CreatedAt, &content.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + content.ContentFormat = ContentFormat(contentFormat) + return &content, nil +} + +// GetLatestContent retrieves the latest content (published or not) for a module +func (s *Store) GetLatestContent(ctx context.Context, moduleID uuid.UUID) (*ModuleContent, error) { + var content ModuleContent + var contentFormat string + + err := s.pool.QueryRow(ctx, ` + SELECT + id, module_id, version, content_format, content_body, + summary, generated_by, llm_model, is_published, + reviewed_by, reviewed_at, created_at, updated_at + FROM training_module_content + WHERE module_id = $1 + ORDER BY version DESC + LIMIT 1 + `, moduleID).Scan( + &content.ID, &content.ModuleID, &content.Version, &contentFormat, &content.ContentBody, + &content.Summary, &content.GeneratedBy, &content.LLMModel, &content.IsPublished, + &content.ReviewedBy, &content.ReviewedAt, &content.CreatedAt, &content.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + content.ContentFormat = ContentFormat(contentFormat) + return &content, nil +} + +// PublishContent marks a content version as published (unpublishes all others for that module) +func (s *Store) PublishContent(ctx context.Context, contentID uuid.UUID, reviewedBy uuid.UUID) error { + now := time.Now().UTC() + + // Get module_id for this content + var moduleID uuid.UUID + err := s.pool.QueryRow(ctx, + "SELECT module_id FROM training_module_content WHERE id = $1", + contentID).Scan(&moduleID) + if err != nil { + return err + } + + // Unpublish all existing content for this module + _, err = s.pool.Exec(ctx, + "UPDATE training_module_content SET is_published = false WHERE module_id = $1", + moduleID) + if err != nil { + return err + } + + // Publish the specified content + _, err = s.pool.Exec(ctx, ` + UPDATE training_module_content SET + is_published = true, reviewed_by = $2, reviewed_at = $3, updated_at = $3 + WHERE id = $1 + `, contentID, reviewedBy, now) + + return err +} + +// ============================================================================ +// Audit Log Operations +// ============================================================================ + +// LogAction creates an audit log entry +func (s *Store) LogAction(ctx context.Context, entry *AuditLogEntry) error { + entry.ID = uuid.New() + entry.CreatedAt = time.Now().UTC() + + details, _ := json.Marshal(entry.Details) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_audit_log ( + id, tenant_id, user_id, action, entity_type, + entity_id, details, ip_address, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + entry.ID, entry.TenantID, entry.UserID, string(entry.Action), string(entry.EntityType), + entry.EntityID, details, entry.IPAddress, entry.CreatedAt, + ) + + return err +} + +// ListAuditLog lists audit log entries for a tenant +func (s *Store) ListAuditLog(ctx context.Context, tenantID uuid.UUID, filters *AuditLogFilters) ([]AuditLogEntry, int, error) { + countQuery := "SELECT COUNT(*) FROM training_audit_log WHERE tenant_id = $1" + countArgs := []interface{}{tenantID} + countArgIdx := 2 + + query := ` + SELECT + id, tenant_id, user_id, action, entity_type, + entity_id, details, ip_address, created_at + FROM training_audit_log WHERE tenant_id = $1` + + args := []interface{}{tenantID} + argIdx := 2 + + if filters != nil { + if filters.UserID != nil { + query += fmt.Sprintf(" AND user_id = $%d", argIdx) + args = append(args, *filters.UserID) + argIdx++ + countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx) + countArgs = append(countArgs, *filters.UserID) + countArgIdx++ + } + if filters.Action != "" { + query += fmt.Sprintf(" AND action = $%d", argIdx) + args = append(args, string(filters.Action)) + argIdx++ + countQuery += fmt.Sprintf(" AND action = $%d", countArgIdx) + countArgs = append(countArgs, string(filters.Action)) + countArgIdx++ + } + if filters.EntityType != "" { + query += fmt.Sprintf(" AND entity_type = $%d", argIdx) + args = append(args, string(filters.EntityType)) + argIdx++ + countQuery += fmt.Sprintf(" AND entity_type = $%d", countArgIdx) + countArgs = append(countArgs, string(filters.EntityType)) + countArgIdx++ + } + } + + var total int + err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total) + if err != nil { + return nil, 0, err + } + + query += " ORDER BY created_at DESC" + + if filters != nil && filters.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argIdx) + args = append(args, filters.Limit) + argIdx++ + if filters.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIdx) + args = append(args, filters.Offset) + argIdx++ + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var entries []AuditLogEntry + for rows.Next() { + var entry AuditLogEntry + var action, entityType string + var details []byte + + err := rows.Scan( + &entry.ID, &entry.TenantID, &entry.UserID, &action, &entityType, + &entry.EntityID, &details, &entry.IPAddress, &entry.CreatedAt, + ) + if err != nil { + return nil, 0, err + } + + entry.Action = AuditAction(action) + entry.EntityType = AuditEntityType(entityType) + json.Unmarshal(details, &entry.Details) + if entry.Details == nil { + entry.Details = map[string]interface{}{} + } + + entries = append(entries, entry) + } + + if entries == nil { + entries = []AuditLogEntry{} + } + + return entries, total, nil +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetTrainingStats returns aggregated training statistics for a tenant +func (s *Store) GetTrainingStats(ctx context.Context, tenantID uuid.UUID) (*TrainingStats, error) { + stats := &TrainingStats{} + + // Total active modules + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM training_modules WHERE tenant_id = $1 AND is_active = true", + tenantID).Scan(&stats.TotalModules) + + // Total assignments + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1", + tenantID).Scan(&stats.TotalAssignments) + + // Status counts + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status = 'pending'", + tenantID).Scan(&stats.PendingCount) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status = 'in_progress'", + tenantID).Scan(&stats.InProgressCount) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM training_assignments WHERE tenant_id = $1 AND status = 'completed'", + tenantID).Scan(&stats.CompletedCount) + + // Completion rate + if stats.TotalAssignments > 0 { + stats.CompletionRate = float64(stats.CompletedCount) / float64(stats.TotalAssignments) * 100 + } + + // Overdue count + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM training_assignments + WHERE tenant_id = $1 + AND status IN ('pending', 'in_progress') + AND deadline < NOW() + `, tenantID).Scan(&stats.OverdueCount) + + // Average quiz score + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(quiz_score), 0) FROM training_assignments + WHERE tenant_id = $1 AND quiz_score IS NOT NULL + `, tenantID).Scan(&stats.AvgQuizScore) + + // Average completion days + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0) + FROM training_assignments + WHERE tenant_id = $1 AND status = 'completed' + AND started_at IS NOT NULL AND completed_at IS NOT NULL + `, tenantID).Scan(&stats.AvgCompletionDays) + + // Upcoming deadlines (within 7 days) + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM training_assignments + WHERE tenant_id = $1 + AND status IN ('pending', 'in_progress') + AND deadline BETWEEN NOW() AND NOW() + INTERVAL '7 days' + `, tenantID).Scan(&stats.UpcomingDeadlines) + + return stats, nil +} + +// GetDeadlines returns upcoming deadlines for a tenant +func (s *Store) GetDeadlines(ctx context.Context, tenantID uuid.UUID, limit int) ([]DeadlineInfo, error) { + if limit <= 0 { + limit = 20 + } + + rows, err := s.pool.Query(ctx, ` + SELECT + ta.id, m.module_code, m.title, + ta.user_id, ta.user_name, ta.deadline, ta.status, + EXTRACT(DAY FROM (ta.deadline - NOW()))::INT AS days_left + FROM training_assignments ta + JOIN training_modules m ON m.id = ta.module_id + WHERE ta.tenant_id = $1 + AND ta.status IN ('pending', 'in_progress') + ORDER BY ta.deadline ASC + LIMIT $2 + `, tenantID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var deadlines []DeadlineInfo + for rows.Next() { + var d DeadlineInfo + var status string + + err := rows.Scan( + &d.AssignmentID, &d.ModuleCode, &d.ModuleTitle, + &d.UserID, &d.UserName, &d.Deadline, &status, + &d.DaysLeft, + ) + if err != nil { + return nil, err + } + + d.Status = AssignmentStatus(status) + deadlines = append(deadlines, d) + } + + if deadlines == nil { + deadlines = []DeadlineInfo{} + } + + return deadlines, nil +} + +// ============================================================================ +// Media CRUD Operations +// ============================================================================ + +// CreateMedia creates a new media record +func (s *Store) CreateMedia(ctx context.Context, media *TrainingMedia) error { + media.ID = uuid.New() + media.CreatedAt = time.Now().UTC() + media.UpdatedAt = media.CreatedAt + if media.Metadata == nil { + media.Metadata = json.RawMessage("{}") + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO training_media ( + id, module_id, content_id, media_type, status, + bucket, object_key, file_size_bytes, duration_seconds, + mime_type, voice_model, language, metadata, + error_message, generated_by, is_published, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13, + $14, $15, $16, $17, $18 + ) + `, + media.ID, media.ModuleID, media.ContentID, string(media.MediaType), string(media.Status), + media.Bucket, media.ObjectKey, media.FileSizeBytes, media.DurationSeconds, + media.MimeType, media.VoiceModel, media.Language, media.Metadata, + media.ErrorMessage, media.GeneratedBy, media.IsPublished, media.CreatedAt, media.UpdatedAt, + ) + + return err +} + +// GetMedia retrieves a media record by ID +func (s *Store) GetMedia(ctx context.Context, id uuid.UUID) (*TrainingMedia, error) { + var media TrainingMedia + var mediaType, status string + + err := s.pool.QueryRow(ctx, ` + SELECT id, module_id, content_id, media_type, status, + bucket, object_key, file_size_bytes, duration_seconds, + mime_type, voice_model, language, metadata, + error_message, generated_by, is_published, created_at, updated_at + FROM training_media WHERE id = $1 + `, id).Scan( + &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, + &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, + &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, + &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + media.MediaType = MediaType(mediaType) + media.Status = MediaStatus(status) + return &media, nil +} + +// GetMediaForModule retrieves all media for a module +func (s *Store) GetMediaForModule(ctx context.Context, moduleID uuid.UUID) ([]TrainingMedia, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, module_id, content_id, media_type, status, + bucket, object_key, file_size_bytes, duration_seconds, + mime_type, voice_model, language, metadata, + error_message, generated_by, is_published, created_at, updated_at + FROM training_media WHERE module_id = $1 + ORDER BY media_type, created_at DESC + `, moduleID) + if err != nil { + return nil, err + } + defer rows.Close() + + var mediaList []TrainingMedia + for rows.Next() { + var media TrainingMedia + var mediaType, status string + if err := rows.Scan( + &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, + &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, + &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, + &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, + ); err != nil { + return nil, err + } + media.MediaType = MediaType(mediaType) + media.Status = MediaStatus(status) + mediaList = append(mediaList, media) + } + + if mediaList == nil { + mediaList = []TrainingMedia{} + } + return mediaList, nil +} + +// UpdateMediaStatus updates the status and related fields of a media record +func (s *Store) UpdateMediaStatus(ctx context.Context, id uuid.UUID, status MediaStatus, sizeBytes int64, duration float64, errMsg string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE training_media + SET status = $2, file_size_bytes = $3, duration_seconds = $4, + error_message = $5, updated_at = NOW() + WHERE id = $1 + `, id, string(status), sizeBytes, duration, errMsg) + return err +} + +// PublishMedia publishes or unpublishes a media record +func (s *Store) PublishMedia(ctx context.Context, id uuid.UUID, publish bool) error { + _, err := s.pool.Exec(ctx, ` + UPDATE training_media SET is_published = $2, updated_at = NOW() WHERE id = $1 + `, id, publish) + return err +} + +// GetPublishedAudio gets the published audio for a module +func (s *Store) GetPublishedAudio(ctx context.Context, moduleID uuid.UUID) (*TrainingMedia, error) { + var media TrainingMedia + var mediaType, status string + + err := s.pool.QueryRow(ctx, ` + SELECT id, module_id, content_id, media_type, status, + bucket, object_key, file_size_bytes, duration_seconds, + mime_type, voice_model, language, metadata, + error_message, generated_by, is_published, created_at, updated_at + FROM training_media + WHERE module_id = $1 AND media_type = 'audio' AND is_published = true + ORDER BY created_at DESC LIMIT 1 + `, moduleID).Scan( + &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, + &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, + &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, + &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + media.MediaType = MediaType(mediaType) + media.Status = MediaStatus(status) + return &media, nil +} + +// GetPublishedVideo gets the published video for a module +func (s *Store) GetPublishedVideo(ctx context.Context, moduleID uuid.UUID) (*TrainingMedia, error) { + var media TrainingMedia + var mediaType, status string + + err := s.pool.QueryRow(ctx, ` + SELECT id, module_id, content_id, media_type, status, + bucket, object_key, file_size_bytes, duration_seconds, + mime_type, voice_model, language, metadata, + error_message, generated_by, is_published, created_at, updated_at + FROM training_media + WHERE module_id = $1 AND media_type = 'video' AND is_published = true + ORDER BY created_at DESC LIMIT 1 + `, moduleID).Scan( + &media.ID, &media.ModuleID, &media.ContentID, &mediaType, &status, + &media.Bucket, &media.ObjectKey, &media.FileSizeBytes, &media.DurationSeconds, + &media.MimeType, &media.VoiceModel, &media.Language, &media.Metadata, + &media.ErrorMessage, &media.GeneratedBy, &media.IsPublished, &media.CreatedAt, &media.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + media.MediaType = MediaType(mediaType) + media.Status = MediaStatus(status) + return &media, nil +} diff --git a/ai-compliance-sdk/migrations/014_training_engine.sql b/ai-compliance-sdk/migrations/014_training_engine.sql new file mode 100644 index 0000000..208b2e2 --- /dev/null +++ b/ai-compliance-sdk/migrations/014_training_engine.sql @@ -0,0 +1,268 @@ +-- ========================================================= +-- Migration 014: Compliance Training Engine +-- ========================================================= +-- Training Module Catalog, Compliance Training Matrix (CTM), +-- Assignments, Quiz Engine, Content Pipeline, Audit Trail +-- ========================================================= + +-- Training-Module-Katalog (erweiterte Kurs-Metadaten) +CREATE TABLE IF NOT EXISTS training_modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE, + academy_course_id UUID REFERENCES academy_courses(id), + module_code VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + regulation_area VARCHAR(20) NOT NULL, + nis2_relevant BOOLEAN DEFAULT FALSE, + iso_controls JSONB DEFAULT '[]', + frequency_type VARCHAR(20) NOT NULL DEFAULT 'annual', + validity_days INT DEFAULT 365, + risk_weight FLOAT DEFAULT 2.0, + content_type VARCHAR(20) DEFAULT 'text', + duration_minutes INT DEFAULT 30, + pass_threshold INT DEFAULT 70, + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, module_code) +); + +-- Compliance Training Matrix: welche Rollen brauchen welche Module +CREATE TABLE IF NOT EXISTS training_matrix ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE, + role_code VARCHAR(10) NOT NULL, + module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE, + is_mandatory BOOLEAN DEFAULT TRUE, + priority INT DEFAULT 5, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, role_code, module_id) +); + +-- Training-Zuweisungen (automatisch oder manuell) +CREATE TABLE IF NOT EXISTS training_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE, + module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + user_name VARCHAR(255) NOT NULL, + user_email VARCHAR(255) NOT NULL, + role_code VARCHAR(10), + trigger_type VARCHAR(20) NOT NULL, + trigger_event VARCHAR(100), + status VARCHAR(20) DEFAULT 'pending', + progress_percent INT DEFAULT 0, + quiz_score FLOAT, + quiz_passed BOOLEAN, + quiz_attempts INT DEFAULT 0, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + deadline TIMESTAMPTZ NOT NULL, + certificate_id UUID, + escalation_level INT DEFAULT 0, + last_escalation_at TIMESTAMPTZ, + enrollment_id UUID REFERENCES academy_enrollments(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Quiz-Fragenbank (persistent, nicht nur JSONB) +CREATE TABLE IF NOT EXISTS training_quiz_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options JSONB NOT NULL, + correct_index INT NOT NULL, + explanation TEXT, + difficulty VARCHAR(10) DEFAULT 'medium', + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Quiz-Versuche (jeder Versuch einzeln getracked) +CREATE TABLE IF NOT EXISTS training_quiz_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id UUID NOT NULL REFERENCES training_assignments(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + answers JSONB NOT NULL, + score FLOAT NOT NULL, + passed BOOLEAN NOT NULL, + correct_count INT NOT NULL, + total_count INT NOT NULL, + duration_seconds INT, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Audit Trail fuer Training-Aktionen +CREATE TABLE IF NOT EXISTS training_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES compliance_tenants(id) ON DELETE CASCADE, + user_id UUID, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(30) NOT NULL, + entity_id UUID, + details JSONB DEFAULT '{}', + ip_address VARCHAR(45), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Module-Inhalte (LLM-generiert oder manuell) +CREATE TABLE IF NOT EXISTS training_module_content ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE, + version INT DEFAULT 1, + content_format VARCHAR(20) DEFAULT 'markdown', + content_body TEXT NOT NULL, + summary TEXT, + generated_by VARCHAR(50), + llm_model VARCHAR(100), + is_published BOOLEAN DEFAULT FALSE, + reviewed_by UUID, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ========================================================= +-- INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_training_modules_tenant ON training_modules(tenant_id); +CREATE INDEX IF NOT EXISTS idx_training_modules_regulation ON training_modules(tenant_id, regulation_area); +CREATE INDEX IF NOT EXISTS idx_training_matrix_tenant_role ON training_matrix(tenant_id, role_code); +CREATE INDEX IF NOT EXISTS idx_training_matrix_module ON training_matrix(module_id); +CREATE INDEX IF NOT EXISTS idx_training_assignments_tenant ON training_assignments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_training_assignments_user ON training_assignments(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS idx_training_assignments_status ON training_assignments(tenant_id, status); +CREATE INDEX IF NOT EXISTS idx_training_assignments_deadline ON training_assignments(deadline) + WHERE status NOT IN ('completed', 'expired'); +CREATE INDEX IF NOT EXISTS idx_training_assignments_overdue ON training_assignments(tenant_id, deadline, status) + WHERE status IN ('pending', 'in_progress') AND deadline < NOW(); +CREATE INDEX IF NOT EXISTS idx_training_quiz_questions_module ON training_quiz_questions(module_id); +CREATE INDEX IF NOT EXISTS idx_training_quiz_attempts_assignment ON training_quiz_attempts(assignment_id); +CREATE INDEX IF NOT EXISTS idx_training_audit_log_tenant ON training_audit_log(tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_training_module_content_module ON training_module_content(module_id, is_published); + +-- ========================================================= +-- SEED DATA: Template Modules (tenant_id = 00000000-...) +-- ========================================================= + +DO $$ +DECLARE + t_id UUID := '00000000-0000-0000-0000-000000000000'; + m_gdpr_bas UUID; m_gdpr_adv UUID; m_gdpr_art UUID; m_gdpr_dpia UUID; + m_nis2_m1 UUID; m_nis2_m2 UUID; m_nis2_m3 UUID; m_nis2_m4 UUID; m_nis2_m5 UUID; + m_isms_bas UUID; m_isms_int UUID; m_isms_aud UUID; + m_ai_bas UUID; m_ai_adv UUID; m_ai_risk UUID; + m_gesch_bas UUID; m_hin_bas UUID; + m_mail_sec UUID; m_soc_eng UUID; m_phish UUID; +BEGIN + -- Skip if seed data already exists + IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = t_id LIMIT 1) THEN + RAISE NOTICE 'Seed data already exists, skipping'; + RETURN; + END IF; + + -- Insert modules and capture IDs + INSERT INTO training_modules (id, tenant_id, module_code, title, description, regulation_area, nis2_relevant, frequency_type, validity_days, risk_weight, duration_minutes, pass_threshold, sort_order) + VALUES + (gen_random_uuid(), t_id, 'GDPR-BAS', 'DSGVO Grundlagen', 'Grundlegende Datenschutzprinzipien, Rechtsgrundlagen, Verarbeitungsgrundsaetze', 'dsgvo', false, 'annual', 365, 2.0, 30, 70, 1), + (gen_random_uuid(), t_id, 'GDPR-ADV', 'DSGVO Vertiefung', 'Auftragsverarbeitung, Drittlandtransfer, Datenschutz-Management', 'dsgvo', false, 'annual', 365, 2.5, 45, 70, 2), + (gen_random_uuid(), t_id, 'GDPR-ART', 'Betroffenenrechte', 'Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenportabilitaet', 'dsgvo', false, 'annual', 365, 2.0, 30, 70, 3), + (gen_random_uuid(), t_id, 'GDPR-DPIA', 'Datenschutz-Folgenabschaetzung', 'DSFA-Durchfuehrung, Schwellwertanalyse, Risikobewertung', 'dsgvo', false, 'event_trigger', 365, 3.0, 60, 80, 4), + (gen_random_uuid(), t_id, 'NIS2-M1', 'NIS2 Geschaeftsleitungspflicht', 'Haftung, Schulungspflicht, Risikomanagement-Governance', 'nis2', true, 'annual', 365, 3.0, 45, 80, 5), + (gen_random_uuid(), t_id, 'NIS2-M2', 'NIS2 Incident Response', 'Meldepflichten, 24h/72h-Fristen, Incident-Response-Plan', 'nis2', true, 'annual', 365, 3.0, 60, 80, 6), + (gen_random_uuid(), t_id, 'NIS2-M3', 'NIS2 Lieferkettensicherheit', 'Supply-Chain-Risiken, Dienstleisterbewertung, Vertragsklauseln', 'nis2', true, 'annual', 365, 2.5, 45, 70, 7), + (gen_random_uuid(), t_id, 'NIS2-M4', 'NIS2 BCM', 'Business Continuity, Notfallplanung, Wiederherstellung', 'nis2', true, 'annual', 365, 2.5, 45, 70, 8), + (gen_random_uuid(), t_id, 'NIS2-M5', 'NIS2 Risikomanagement', 'Risikoanalyse, Massnahmenplanung, Wirksamkeitspruefung', 'nis2', true, 'annual', 365, 3.0, 60, 80, 9), + (gen_random_uuid(), t_id, 'ISMS-BAS', 'ISMS Grundlagen', 'ISO 27001 Anforderungen, PDCA-Zyklus, Informationssicherheitspolitik', 'iso27001', false, 'annual', 365, 2.0, 30, 70, 10), + (gen_random_uuid(), t_id, 'ISMS-INT', 'ISMS Interne Audits', 'Audit-Planung, Durchfuehrung, Berichterstattung, Massnahmenverfolgung', 'iso27001', false, 'annual', 365, 2.5, 45, 70, 11), + (gen_random_uuid(), t_id, 'ISMS-AUD', 'ISMS Audit-Vorbereitung', 'Zertifizierungsaudit, Dokumentation, Nachweisfuehrung', 'iso27001', false, 'event_trigger', 365, 2.5, 60, 70, 12), + (gen_random_uuid(), t_id, 'AI-BAS', 'KI-Kompetenz Grundlagen', 'EU AI Act Ueberblick, KI-Risikokategorien, Transparenzpflichten', 'ai_act', false, 'annual', 365, 2.0, 30, 70, 13), + (gen_random_uuid(), t_id, 'AI-ADV', 'KI-Risikomanagement', 'Hochrisiko-KI, Konformitaetsbewertung, Dokumentationspflichten', 'ai_act', false, 'annual', 365, 2.5, 45, 70, 14), + (gen_random_uuid(), t_id, 'AI-RISK', 'Hochrisiko-KI-Systeme', 'Risikomanagementsystem, Qualitaet der Trainingsdaten, Human Oversight', 'ai_act', false, 'event_trigger', 365, 3.0, 60, 80, 15), + (gen_random_uuid(), t_id, 'GESCH-BAS', 'Geschaeftsgeheimnisschutz', 'GeschGehG, Schutzkonzept, NDAs, technische Massnahmen', 'geschgehg', false, 'annual', 365, 2.0, 30, 70, 16), + (gen_random_uuid(), t_id, 'HIN-BAS', 'Hinweisgeberschutz', 'HinSchG, Meldekanal, Vertraulichkeit, Repressalienverbot', 'hinschg', false, 'annual', 365, 2.0, 30, 70, 17), + (gen_random_uuid(), t_id, 'MAIL-SEC', 'E-Mail-Sicherheit', 'Phishing, Spam, Verschluesselung, Sichere Kommunikation', 'iso27001', false, 'micro', 180, 1.5, 15, 70, 18), + (gen_random_uuid(), t_id, 'SOC-ENG', 'Social Engineering Abwehr', 'Manipulationstechniken, Pretexting, Baiting, Tailgating', 'iso27001', false, 'micro', 180, 1.5, 15, 70, 19), + (gen_random_uuid(), t_id, 'PHISH', 'Phishing-Erkennung', 'Phishing-Merkmale, Pruefschritte, Meldeprozess', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 20); + + -- Get module IDs for CTM + SELECT id INTO m_gdpr_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-BAS'; + SELECT id INTO m_gdpr_adv FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-ADV'; + SELECT id INTO m_gdpr_art FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-ART'; + SELECT id INTO m_gdpr_dpia FROM training_modules WHERE tenant_id = t_id AND module_code = 'GDPR-DPIA'; + SELECT id INTO m_nis2_m1 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M1'; + SELECT id INTO m_nis2_m2 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M2'; + SELECT id INTO m_nis2_m3 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M3'; + SELECT id INTO m_nis2_m4 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M4'; + SELECT id INTO m_nis2_m5 FROM training_modules WHERE tenant_id = t_id AND module_code = 'NIS2-M5'; + SELECT id INTO m_isms_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-BAS'; + SELECT id INTO m_isms_int FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-INT'; + SELECT id INTO m_isms_aud FROM training_modules WHERE tenant_id = t_id AND module_code = 'ISMS-AUD'; + SELECT id INTO m_ai_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-BAS'; + SELECT id INTO m_ai_adv FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-ADV'; + SELECT id INTO m_ai_risk FROM training_modules WHERE tenant_id = t_id AND module_code = 'AI-RISK'; + SELECT id INTO m_gesch_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'GESCH-BAS'; + SELECT id INTO m_hin_bas FROM training_modules WHERE tenant_id = t_id AND module_code = 'HIN-BAS'; + SELECT id INTO m_mail_sec FROM training_modules WHERE tenant_id = t_id AND module_code = 'MAIL-SEC'; + SELECT id INTO m_soc_eng FROM training_modules WHERE tenant_id = t_id AND module_code = 'SOC-ENG'; + SELECT id INTO m_phish FROM training_modules WHERE tenant_id = t_id AND module_code = 'PHISH'; + + -- CTM: R1 Geschaeftsfuehrung + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R1', m_gdpr_bas, true, 1), (t_id, 'R1', m_nis2_m1, true, 1), + (t_id, 'R1', m_nis2_m5, true, 2), (t_id, 'R1', m_isms_bas, true, 2), + (t_id, 'R1', m_ai_bas, true, 3), (t_id, 'R1', m_gesch_bas, true, 3); + + -- CTM: R2 IT-Leitung + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R2', m_nis2_m2, true, 1), (t_id, 'R2', m_nis2_m5, true, 1), + (t_id, 'R2', m_isms_bas, true, 2), (t_id, 'R2', m_isms_int, true, 2), + (t_id, 'R2', m_mail_sec, true, 3), (t_id, 'R2', m_soc_eng, true, 3), + (t_id, 'R2', m_phish, true, 3); + + -- CTM: R3 DSB + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R3', m_gdpr_bas, true, 1), (t_id, 'R3', m_gdpr_adv, true, 1), + (t_id, 'R3', m_gdpr_art, true, 1), (t_id, 'R3', m_gdpr_dpia, true, 2), + (t_id, 'R3', m_isms_bas, true, 3); + + -- CTM: R4 ISB + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R4', m_nis2_m2, true, 1), (t_id, 'R4', m_nis2_m3, true, 1), + (t_id, 'R4', m_nis2_m4, true, 1), (t_id, 'R4', m_nis2_m5, true, 1), + (t_id, 'R4', m_isms_bas, true, 2), (t_id, 'R4', m_isms_int, true, 2), + (t_id, 'R4', m_isms_aud, true, 2); + + -- CTM: R5 HR + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R5', m_gdpr_bas, true, 1), (t_id, 'R5', m_gdpr_art, true, 1), + (t_id, 'R5', m_hin_bas, true, 2); + + -- CTM: R6 Einkauf + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R6', m_gdpr_bas, true, 1), (t_id, 'R6', m_nis2_m3, true, 1), + (t_id, 'R6', m_gesch_bas, true, 2); + + -- CTM: R7 Fachabteilung + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R7', m_gdpr_bas, true, 1), (t_id, 'R7', m_ai_bas, true, 2), + (t_id, 'R7', m_mail_sec, true, 3), (t_id, 'R7', m_soc_eng, true, 3); + + -- CTM: R8 IT-Admin + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R8', m_nis2_m2, true, 1), (t_id, 'R8', m_nis2_m5, true, 1), + (t_id, 'R8', m_isms_bas, true, 2), (t_id, 'R8', m_mail_sec, true, 3), + (t_id, 'R8', m_soc_eng, true, 3), (t_id, 'R8', m_phish, true, 3); + + -- CTM: R9 Alle Mitarbeiter + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (t_id, 'R9', m_gdpr_bas, true, 1), (t_id, 'R9', m_mail_sec, true, 2), + (t_id, 'R9', m_soc_eng, true, 2), (t_id, 'R9', m_phish, true, 2); + + RAISE NOTICE 'Training Engine seed data inserted successfully'; +END $$; diff --git a/ai-compliance-sdk/migrations/015_it_security_modules.sql b/ai-compliance-sdk/migrations/015_it_security_modules.sql new file mode 100644 index 0000000..0849322 --- /dev/null +++ b/ai-compliance-sdk/migrations/015_it_security_modules.sql @@ -0,0 +1,84 @@ +-- ========================================================= +-- Migration 015: IT-Security Training Modules +-- ========================================================= +-- 8 neue IT-Security Micro-/Annual-Trainingsmodule +-- fuer Breakpilot-Tenant +-- ========================================================= + +DO $$ +DECLARE + bp_id UUID := '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'; + b_sec_pwd UUID; + b_sec_desk UUID; + b_sec_kiai UUID; + b_sec_byod UUID; + b_sec_video UUID; + b_sec_usb UUID; + b_sec_inc UUID; + b_sec_home UUID; +BEGIN + -- Skip if already exists + IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-PWD' LIMIT 1) THEN + RAISE NOTICE 'IT-Security modules already exist for Breakpilot tenant, skipping'; + RETURN; + END IF; + + -- Insert 8 IT-Security modules + INSERT INTO training_modules (id, tenant_id, module_code, title, description, regulation_area, nis2_relevant, frequency_type, validity_days, risk_weight, duration_minutes, pass_threshold, sort_order) + VALUES + (gen_random_uuid(), bp_id, 'SEC-PWD', 'Passwortsicherheit & MFA', 'Sichere Passwoerter, Multi-Faktor-Authentifizierung, Passwort-Manager', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 21), + (gen_random_uuid(), bp_id, 'SEC-DESK', 'Sichere Datenablage & Clean Desk', 'Clean-Desk-Policy, sichere Ablage, Bildschirmsperre, Dokumentenvernichtung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 22), + (gen_random_uuid(), bp_id, 'SEC-KIAI', 'Personenbezogene Daten in KI-Tools', 'DSGVO-konforme Nutzung von KI, ChatGPT und Co., Datenweitergabe-Risiken', 'dsgvo', false, 'annual', 365, 2.5, 30, 70, 23), + (gen_random_uuid(), bp_id, 'SEC-BYOD', 'BYOD & Mobile Security', 'Bring Your Own Device, Mobile Device Management, Geraetetrennung', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 24), + (gen_random_uuid(), bp_id, 'SEC-VIDEO', 'Sichere Videokonferenzen', 'Datenschutz in Videokonferenzen, Screensharing-Risiken, Aufzeichnungsregeln', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 25), + (gen_random_uuid(), bp_id, 'SEC-USB', 'USB & Externe Medien', 'Risiken externer Datentraeger, USB-Richtlinien, Verschluesselung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 26), + (gen_random_uuid(), bp_id, 'SEC-INC', 'Sicherheitsvorfall melden', 'Erkennung von Sicherheitsvorfaellen, Meldewege, Sofortmassnahmen, Dokumentation', 'iso27001', true, 'micro', 180, 1.5, 10, 70, 27), + (gen_random_uuid(), bp_id, 'SEC-HOME', 'Homeoffice-Sicherheit', 'Sicheres Arbeiten von zuhause, VPN, WLAN-Sicherheit, physische Sicherheit', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 28); + + -- Lookup module IDs + SELECT id INTO b_sec_pwd FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-PWD'; + SELECT id INTO b_sec_desk FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-DESK'; + SELECT id INTO b_sec_kiai FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-KIAI'; + SELECT id INTO b_sec_byod FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-BYOD'; + SELECT id INTO b_sec_video FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-VIDEO'; + SELECT id INTO b_sec_usb FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-USB'; + SELECT id INTO b_sec_inc FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-INC'; + SELECT id INTO b_sec_home FROM training_modules WHERE tenant_id = bp_id AND module_code = 'SEC-HOME'; + + -- CTM: R2 IT-Leitung + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (bp_id, 'R2', b_sec_byod, true, 3), + (bp_id, 'R2', b_sec_usb, true, 3), + (bp_id, 'R2', b_sec_inc, true, 2); + + -- CTM: R3 DSB + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (bp_id, 'R3', b_sec_kiai, true, 2); + + -- CTM: R7 Fachabteilung + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (bp_id, 'R7', b_sec_pwd, true, 3), + (bp_id, 'R7', b_sec_kiai, true, 3), + (bp_id, 'R7', b_sec_inc, true, 2); + + -- CTM: R8 IT-Admin + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (bp_id, 'R8', b_sec_pwd, true, 3), + (bp_id, 'R8', b_sec_byod, true, 3), + (bp_id, 'R8', b_sec_usb, true, 3), + (bp_id, 'R8', b_sec_inc, true, 2), + (bp_id, 'R8', b_sec_home, true, 3); + + -- CTM: R9 Alle Mitarbeiter + INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES + (bp_id, 'R9', b_sec_pwd, true, 3), + (bp_id, 'R9', b_sec_desk, true, 3), + (bp_id, 'R9', b_sec_kiai, true, 3), + (bp_id, 'R9', b_sec_byod, true, 3), + (bp_id, 'R9', b_sec_video, false, 5), + (bp_id, 'R9', b_sec_usb, true, 3), + (bp_id, 'R9', b_sec_inc, true, 2), + (bp_id, 'R9', b_sec_home, true, 3); + + RAISE NOTICE 'IT-Security modules inserted for Breakpilot tenant'; +END $$; diff --git a/ai-compliance-sdk/migrations/016_training_media.sql b/ai-compliance-sdk/migrations/016_training_media.sql new file mode 100644 index 0000000..88a643d --- /dev/null +++ b/ai-compliance-sdk/migrations/016_training_media.sql @@ -0,0 +1,28 @@ +-- ========================================================= +-- Migration 016: Training Media (Audio/Video) +-- ========================================================= + +CREATE TABLE IF NOT EXISTS training_media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE, + content_id UUID REFERENCES training_module_content(id), + media_type VARCHAR(10) NOT NULL CHECK (media_type IN ('audio', 'video')), + status VARCHAR(20) NOT NULL DEFAULT 'processing', + bucket VARCHAR(100) NOT NULL, + object_key VARCHAR(500) NOT NULL, + file_size_bytes BIGINT, + duration_seconds FLOAT, + mime_type VARCHAR(50), + voice_model VARCHAR(100), + language VARCHAR(10) DEFAULT 'de', + metadata JSONB DEFAULT '{}'::jsonb, + error_message TEXT, + generated_by VARCHAR(50), + is_published BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_training_media_module ON training_media(module_id); +CREATE INDEX IF NOT EXISTS idx_training_media_type ON training_media(module_id, media_type); +CREATE INDEX IF NOT EXISTS idx_training_media_published ON training_media(module_id, media_type, is_published) WHERE is_published = true; diff --git a/compliance-tts-service/Dockerfile b/compliance-tts-service/Dockerfile new file mode 100644 index 0000000..364a5e8 --- /dev/null +++ b/compliance-tts-service/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.12-slim + +# System dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libsndfile1 \ + imagemagick \ + fonts-dejavu-core \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 ttsuser + +WORKDIR /app + +# Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Download Piper model (German, thorsten, high quality) +RUN mkdir -p /app/models && \ + wget -q -O /app/models/de_DE-thorsten-high.onnx \ + "https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/de_DE-thorsten-high.onnx" && \ + wget -q -O /app/models/de_DE-thorsten-high.onnx.json \ + "https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/de_DE-thorsten-high.onnx.json" + +# Copy application +COPY . . + +# Fix ImageMagick policy for PDF/text rendering +RUN if [ -f /etc/ImageMagick-6/policy.xml ]; then \ + sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml; \ + fi + +RUN chown -R ttsuser:ttsuser /app +USER ttsuser + +EXPOSE 8095 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8095/health')" + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8095"] diff --git a/compliance-tts-service/main.py b/compliance-tts-service/main.py new file mode 100644 index 0000000..ee42fc1 --- /dev/null +++ b/compliance-tts-service/main.py @@ -0,0 +1,175 @@ +"""Compliance TTS Service — Piper TTS + FFmpeg Audio/Video Pipeline.""" +import logging +import os +import tempfile +import uuid + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +from storage import StorageClient +from tts_engine import PiperTTS + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Compliance TTS Service", version="1.0.0") + +# Configuration +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "bp-core-minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123") +PIPER_MODEL_PATH = os.getenv("PIPER_MODEL_PATH", "/app/models/de_DE-thorsten-high.onnx") + +AUDIO_BUCKET = "compliance-training-audio" +VIDEO_BUCKET = "compliance-training-video" + +# Initialize services +storage = StorageClient(MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY) +tts = PiperTTS(PIPER_MODEL_PATH) + + +@app.on_event("startup") +async def startup(): + """Ensure buckets exist on startup.""" + storage.ensure_bucket(AUDIO_BUCKET) + storage.ensure_bucket(VIDEO_BUCKET) + logger.info("TTS Service started") + + +# --- Models --- + +class SynthesizeRequest(BaseModel): + text: str + language: str = "de" + voice: str = "thorsten-high" + module_id: str + content_id: str | None = None + + +class SynthesizeResponse(BaseModel): + audio_id: str + bucket: str + object_key: str + duration_seconds: float + size_bytes: int + + +class GenerateVideoRequest(BaseModel): + script: dict + audio_object_key: str + module_id: str + + +class GenerateVideoResponse(BaseModel): + video_id: str + bucket: str + object_key: str + duration_seconds: float + size_bytes: int + + +class VoiceInfo(BaseModel): + id: str + language: str + name: str + quality: str + + +# --- Endpoints --- + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "healthy", + "piper_available": tts.is_available, + "ffmpeg_available": _check_ffmpeg(), + "minio_connected": storage.is_connected(), + } + + +@app.get("/voices") +async def list_voices(): + """List available TTS voices.""" + return { + "voices": [ + VoiceInfo( + id="de_DE-thorsten-high", + language="de", + name="Thorsten (High Quality)", + quality="high", + ), + ], + } + + +@app.post("/synthesize", response_model=SynthesizeResponse) +async def synthesize(req: SynthesizeRequest): + """Synthesize text to audio and upload to storage.""" + if not req.text.strip(): + raise HTTPException(status_code=400, detail="Text is empty") + + audio_id = str(uuid.uuid4()) + content_suffix = req.content_id or "full" + object_key = f"audio/{req.module_id}/{content_suffix}.mp3" + + with tempfile.TemporaryDirectory() as tmpdir: + try: + mp3_path, duration = tts.synthesize_to_mp3(req.text, tmpdir) + size_bytes = storage.upload_file(AUDIO_BUCKET, object_key, mp3_path, "audio/mpeg") + except Exception as e: + logger.error(f"Synthesis failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return SynthesizeResponse( + audio_id=audio_id, + bucket=AUDIO_BUCKET, + object_key=object_key, + duration_seconds=round(duration, 2), + size_bytes=size_bytes, + ) + + +@app.post("/generate-video", response_model=GenerateVideoResponse) +async def generate_video(req: GenerateVideoRequest): + """Generate a presentation video from slides + audio.""" + try: + from video_generator import generate_presentation_video + except ImportError: + raise HTTPException(status_code=501, detail="Video generation not available yet") + + video_id = str(uuid.uuid4()) + object_key = f"video/{req.module_id}/presentation.mp4" + + with tempfile.TemporaryDirectory() as tmpdir: + try: + mp4_path, duration = generate_presentation_video( + script=req.script, + audio_object_key=req.audio_object_key, + output_dir=tmpdir, + storage=storage, + audio_bucket=AUDIO_BUCKET, + ) + size_bytes = storage.upload_file(VIDEO_BUCKET, object_key, mp4_path, "video/mp4") + except Exception as e: + logger.error(f"Video generation failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + return GenerateVideoResponse( + video_id=video_id, + bucket=VIDEO_BUCKET, + object_key=object_key, + duration_seconds=round(duration, 2), + size_bytes=size_bytes, + ) + + +def _check_ffmpeg() -> bool: + """Check if ffmpeg is available.""" + import subprocess + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=5) + return True + except Exception: + return False diff --git a/compliance-tts-service/requirements.txt b/compliance-tts-service/requirements.txt new file mode 100644 index 0000000..fda38fb --- /dev/null +++ b/compliance-tts-service/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.109.2 +uvicorn[standard]==0.27.1 +piper-tts==1.2.0 +boto3==1.34.25 +python-multipart==0.0.6 +pydantic==2.6.1 diff --git a/compliance-tts-service/slide_renderer.py b/compliance-tts-service/slide_renderer.py new file mode 100644 index 0000000..8622b7a --- /dev/null +++ b/compliance-tts-service/slide_renderer.py @@ -0,0 +1,132 @@ +"""ImageMagick slide renderer for presentation videos.""" +import logging +import os +import subprocess +import textwrap + +logger = logging.getLogger(__name__) + +# Slide dimensions +WIDTH = 1920 +HEIGHT = 1080 +HEADER_HEIGHT = 120 +FOOTER_HEIGHT = 60 +FONT = "DejaVu-Sans" +FONT_BOLD = "DejaVu-Sans-Bold" + + +def render_slide( + heading: str, + text: str, + bullet_points: list[str], + slide_number: int, + total_slides: int, + module_code: str, + output_path: str, +) -> None: + """Render a single slide as PNG using ImageMagick.""" + cmd = [ + "convert", + "-size", f"{WIDTH}x{HEIGHT}", + "xc:white", + # Blue header bar + "-fill", "#1e3a5f", + "-draw", f"rectangle 0,0 {WIDTH},{HEADER_HEIGHT}", + # Header text + "-fill", "white", + "-font", FONT_BOLD, + "-pointsize", "42", + "-gravity", "NorthWest", + "-annotate", f"+60+{(HEADER_HEIGHT - 42) // 2}", heading[:80], + ] + + y_pos = HEADER_HEIGHT + 40 + + # Main text + if text: + wrapped = textwrap.fill(text, width=80) + for line in wrapped.split("\n")[:6]: + cmd.extend([ + "-fill", "#333333", + "-font", FONT, + "-pointsize", "28", + "-gravity", "NorthWest", + "-annotate", f"+60+{y_pos}", line, + ]) + y_pos += 38 + + y_pos += 20 + + # Bullet points + for bp in bullet_points[:8]: + wrapped_bp = textwrap.fill(bp, width=75) + first_line = True + for line in wrapped_bp.split("\n"): + prefix = " • " if first_line else " " + cmd.extend([ + "-fill", "#444444", + "-font", FONT, + "-pointsize", "26", + "-gravity", "NorthWest", + "-annotate", f"+60+{y_pos}", f"{prefix}{line}", + ]) + y_pos += 34 + first_line = False + y_pos += 8 + + # Footer bar + cmd.extend([ + "-fill", "#f0f0f0", + "-draw", f"rectangle 0,{HEIGHT - FOOTER_HEIGHT} {WIDTH},{HEIGHT}", + "-fill", "#888888", + "-font", FONT, + "-pointsize", "20", + "-gravity", "SouthWest", + "-annotate", f"+60+{(FOOTER_HEIGHT - 20) // 2}", f"{module_code}", + "-gravity", "SouthEast", + "-annotate", f"+60+{(FOOTER_HEIGHT - 20) // 2}", f"Folie {slide_number}/{total_slides}", + ]) + + cmd.append(output_path) + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise RuntimeError(f"ImageMagick failed: {result.stderr}") + + +def render_title_slide( + title: str, + subtitle: str, + output_path: str, +) -> None: + """Render a title slide.""" + cmd = [ + "convert", + "-size", f"{WIDTH}x{HEIGHT}", + "xc:white", + # Full blue background + "-fill", "#1e3a5f", + "-draw", f"rectangle 0,0 {WIDTH},{HEIGHT}", + # Title + "-fill", "white", + "-font", FONT_BOLD, + "-pointsize", "56", + "-gravity", "Center", + "-annotate", "+0-60", title[:60], + # Subtitle + "-fill", "#b0c4de", + "-font", FONT, + "-pointsize", "32", + "-gravity", "Center", + "-annotate", "+0+40", subtitle[:80], + # Footer + "-fill", "#6688aa", + "-pointsize", "22", + "-gravity", "South", + "-annotate", "+0+30", "BreakPilot Compliance Training", + output_path, + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + raise RuntimeError(f"ImageMagick title slide failed: {result.stderr}") diff --git a/compliance-tts-service/storage.py b/compliance-tts-service/storage.py new file mode 100644 index 0000000..972790b --- /dev/null +++ b/compliance-tts-service/storage.py @@ -0,0 +1,56 @@ +"""MinIO/S3 storage client for audio and video files.""" +import logging +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger(__name__) + + +class StorageClient: + """S3-compatible storage client for MinIO.""" + + def __init__(self, endpoint: str, access_key: str, secret_key: str, secure: bool = False): + self.client = boto3.client( + "s3", + endpoint_url=f"{'https' if secure else 'http'}://{endpoint}", + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name="us-east-1", + ) + self.endpoint = endpoint + + def ensure_bucket(self, bucket: str) -> None: + """Create bucket if it doesn't exist.""" + try: + self.client.head_bucket(Bucket=bucket) + except ClientError: + try: + self.client.create_bucket(Bucket=bucket) + logger.info(f"Created bucket: {bucket}") + except ClientError as e: + logger.error(f"Failed to create bucket {bucket}: {e}") + + def upload_file(self, bucket: str, object_key: str, file_path: str, content_type: str = "audio/mpeg") -> int: + """Upload a file to storage and return file size in bytes.""" + import os + self.client.upload_file( + file_path, bucket, object_key, + ExtraArgs={"ContentType": content_type}, + ) + return os.path.getsize(file_path) + + def get_presigned_url(self, bucket: str, object_key: str, expires: int = 3600) -> str: + """Generate a presigned URL for file access.""" + return self.client.generate_presigned_url( + "get_object", + Params={"Bucket": bucket, "Key": object_key}, + ExpiresIn=expires, + ) + + def is_connected(self) -> bool: + """Check if storage is accessible.""" + try: + self.client.list_buckets() + return True + except Exception: + return False diff --git a/compliance-tts-service/tts_engine.py b/compliance-tts-service/tts_engine.py new file mode 100644 index 0000000..b8736d8 --- /dev/null +++ b/compliance-tts-service/tts_engine.py @@ -0,0 +1,157 @@ +"""Piper TTS engine wrapper for speech synthesis.""" +import logging +import os +import re +import subprocess +import tempfile +import wave +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Sentence-end pattern: split on . ! ? followed by whitespace or end +SENTENCE_SPLIT = re.compile(r'(?<=[.!?])\s+') + +# Markdown stripping patterns +MD_PATTERNS = [ + (re.compile(r'^#{1,6}\s+', re.MULTILINE), ''), # Headers + (re.compile(r'\*\*(.+?)\*\*'), r'\1'), # Bold + (re.compile(r'\*(.+?)\*'), r'\1'), # Italic + (re.compile(r'`(.+?)`'), r'\1'), # Inline code + (re.compile(r'```[\s\S]*?```'), ''), # Code blocks + (re.compile(r'^\s*[-*+]\s+', re.MULTILINE), ''), # List markers + (re.compile(r'^\s*\d+\.\s+', re.MULTILINE), ''), # Numbered lists + (re.compile(r'\[([^\]]+)\]\([^)]+\)'), r'\1'), # Links + (re.compile(r'^\s*>\s+', re.MULTILINE), ''), # Blockquotes + (re.compile(r'---+'), ''), # Horizontal rules + (re.compile(r'\n{3,}'), '\n\n'), # Multiple newlines +] + + +def strip_markdown(text: str) -> str: + """Convert markdown to plain text for TTS.""" + for pattern, replacement in MD_PATTERNS: + text = pattern.sub(replacement, text) + return text.strip() + + +def split_sentences(text: str) -> list[str]: + """Split text into sentences.""" + sentences = SENTENCE_SPLIT.split(text) + return [s.strip() for s in sentences if s.strip()] + + +class PiperTTS: + """Piper TTS wrapper for local speech synthesis.""" + + def __init__(self, model_path: str): + self.model_path = model_path + self._check_piper() + + def _check_piper(self) -> None: + """Verify piper is installed and model exists.""" + if not Path(self.model_path).exists(): + raise FileNotFoundError(f"Piper model not found: {self.model_path}") + try: + result = subprocess.run( + ["piper", "--version"], capture_output=True, text=True, timeout=10, + ) + logger.info(f"Piper TTS available: {result.stdout.strip()}") + except FileNotFoundError: + # piper-tts pip package installs as python module + logger.info("Piper available via Python module") + + def synthesize_to_wav(self, text: str, output_path: str) -> None: + """Synthesize text to a WAV file using Piper.""" + cmd = [ + "piper", + "--model", self.model_path, + "--output_file", output_path, + ] + proc = subprocess.run( + cmd, input=text, capture_output=True, text=True, timeout=120, + ) + if proc.returncode != 0: + raise RuntimeError(f"Piper failed: {proc.stderr}") + + def synthesize_to_mp3(self, text: str, output_dir: str) -> tuple[str, float]: + """ + Synthesize text to MP3. + Splits text into sentences, synthesizes each, concatenates, encodes to MP3. + Returns (mp3_path, duration_seconds). + """ + plain_text = strip_markdown(text) + sentences = split_sentences(plain_text) + if not sentences: + sentences = [plain_text] + + wav_files = [] + try: + for i, sentence in enumerate(sentences): + wav_path = os.path.join(output_dir, f"seg_{i:04d}.wav") + self.synthesize_to_wav(sentence, wav_path) + wav_files.append(wav_path) + + # Concatenate WAV files + combined_wav = os.path.join(output_dir, "combined.wav") + self._concatenate_wavs(wav_files, combined_wav) + + # Convert to MP3 + mp3_path = os.path.join(output_dir, "output.mp3") + self._wav_to_mp3(combined_wav, mp3_path) + + # Get duration + duration = self._get_audio_duration(mp3_path) + + return mp3_path, duration + finally: + # Cleanup individual segments + for f in wav_files: + if os.path.exists(f): + os.remove(f) + + def _concatenate_wavs(self, wav_files: list[str], output_path: str) -> None: + """Concatenate multiple WAV files into one.""" + if len(wav_files) == 1: + import shutil + shutil.copy2(wav_files[0], output_path) + return + + # Read parameters from first file + with wave.open(wav_files[0], 'rb') as wf: + params = wf.getparams() + + with wave.open(output_path, 'wb') as out: + out.setparams(params) + for wav_file in wav_files: + with wave.open(wav_file, 'rb') as wf: + out.writeframes(wf.readframes(wf.getnframes())) + + def _wav_to_mp3(self, wav_path: str, mp3_path: str) -> None: + """Convert WAV to MP3 using FFmpeg.""" + cmd = [ + "ffmpeg", "-y", "-i", wav_path, + "-codec:a", "libmp3lame", "-qscale:a", "2", + mp3_path, + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if proc.returncode != 0: + raise RuntimeError(f"FFmpeg MP3 encoding failed: {proc.stderr}") + + def _get_audio_duration(self, file_path: str) -> float: + """Get audio duration using FFprobe.""" + cmd = [ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", file_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return float(result.stdout.strip()) + + @property + def is_available(self) -> bool: + """Check if Piper is available.""" + try: + subprocess.run(["piper", "--version"], capture_output=True, timeout=5) + return True + except Exception: + return False diff --git a/compliance-tts-service/video_generator.py b/compliance-tts-service/video_generator.py new file mode 100644 index 0000000..9dab95f --- /dev/null +++ b/compliance-tts-service/video_generator.py @@ -0,0 +1,127 @@ +"""FFmpeg video generator — combines slides + audio into presentation video.""" +import logging +import os +import subprocess +import tempfile + +from slide_renderer import render_slide, render_title_slide + +logger = logging.getLogger(__name__) + + +def generate_presentation_video( + script: dict, + audio_object_key: str, + output_dir: str, + storage, + audio_bucket: str, +) -> tuple[str, float]: + """ + Generate a presentation video from a slide script and audio. + + 1. Download audio from MinIO + 2. Get audio duration + 3. Render slides as PNGs + 4. Calculate timing per slide (proportional to text length) + 5. Create FFmpeg concat list + 6. Combine slides + audio into MP4 + + Returns (mp4_path, duration_seconds). + """ + title = script.get("title", "Compliance Training") + sections = script.get("sections", []) + + if not sections: + raise ValueError("Script has no sections") + + # Step 1: Download audio + audio_path = os.path.join(output_dir, "audio.mp3") + storage.client.download_file(audio_bucket, audio_object_key, audio_path) + + # Step 2: Get audio duration + duration = _get_duration(audio_path) + + # Step 3: Render slides + slides_dir = os.path.join(output_dir, "slides") + os.makedirs(slides_dir, exist_ok=True) + + slide_paths = [] + text_lengths = [] + + # Title slide + title_path = os.path.join(slides_dir, "slide_000.png") + render_title_slide(title, "Compliance Schulung", title_path) + slide_paths.append(title_path) + text_lengths.append(len(title) + 20) # Small weight for title + + # Content slides + module_code = script.get("module_code", "") + total_slides = len(sections) + 1 # +1 for title + + for i, section in enumerate(sections): + slide_path = os.path.join(slides_dir, f"slide_{i+1:03d}.png") + render_slide( + heading=section.get("heading", ""), + text=section.get("text", ""), + bullet_points=section.get("bullet_points", []), + slide_number=i + 2, # 1-based, title is 1 + total_slides=total_slides, + module_code=module_code, + output_path=slide_path, + ) + slide_paths.append(slide_path) + + # Text length for timing + text_len = len(section.get("heading", "")) + len(section.get("text", "")) + text_len += sum(len(bp) for bp in section.get("bullet_points", [])) + text_lengths.append(max(text_len, 50)) + + # Step 4: Calculate timing + total_text = sum(text_lengths) + slide_durations = [(tl / total_text) * duration for tl in text_lengths] + + # Minimum 3 seconds per slide + for i in range(len(slide_durations)): + if slide_durations[i] < 3.0: + slide_durations[i] = 3.0 + + # Step 5: Create FFmpeg concat file + concat_path = os.path.join(output_dir, "concat.txt") + with open(concat_path, "w") as f: + for slide_path, dur in zip(slide_paths, slide_durations): + f.write(f"file '{slide_path}'\n") + f.write(f"duration {dur:.2f}\n") + # Repeat last slide for FFmpeg concat demuxer + f.write(f"file '{slide_paths[-1]}'\n") + + # Step 6: Combine with FFmpeg + output_path = os.path.join(output_dir, "presentation.mp4") + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", "-i", concat_path, + "-i", audio_path, + "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "128k", + "-shortest", + "-movflags", "+faststart", + output_path, + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if result.returncode != 0: + raise RuntimeError(f"FFmpeg video generation failed: {result.stderr}") + + video_duration = _get_duration(output_path) + return output_path, video_duration + + +def _get_duration(file_path: str) -> float: + """Get media duration using FFprobe.""" + cmd = [ + "ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + file_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + return float(result.stdout.strip()) diff --git a/docker-compose.yml b/docker-compose.yml index 73f224f..799b1ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -147,6 +147,7 @@ services: AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365} AUDIT_LOG_PROMPTS: ${AUDIT_LOG_PROMPTS:-true} ALLOWED_ORIGINS: "*" + TTS_SERVICE_URL: http://compliance-tts-service:8095 extra_hosts: - "host.docker.internal:host-gateway" depends_on: @@ -163,6 +164,35 @@ services: - breakpilot-network # ========================================================= + # ========================================================= + # TTS SERVICE (Piper TTS + FFmpeg) + # ========================================================= + compliance-tts-service: + build: + context: ./compliance-tts-service + dockerfile: Dockerfile + container_name: bp-compliance-tts + platform: linux/arm64 + expose: + - "8095" + environment: + MINIO_ENDPOINT: bp-core-minio:9000 + MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-breakpilot} + MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-breakpilot123} + PIPER_MODEL_PATH: /app/models/de_DE-thorsten-high.onnx + depends_on: + core-health-check: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8095/health')"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + networks: + - breakpilot-network + # DATA SOVEREIGNTY # ========================================================= dsms-node: