feat(training): add Media Pipeline — TTS Audio, Presentation Video, Bulk Generation
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 48s
CI / test-python-backend-compliance (push) Successful in 35s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 20s

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-16 21:42:33 +01:00
parent fba4c411dc
commit 9b8b7ca073
28 changed files with 7088 additions and 0 deletions

View File

@@ -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<Tab>('overview')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<TrainingStats | null>(null)
const [modules, setModules] = useState<TrainingModule[]>([])
const [matrix, setMatrix] = useState<MatrixResponse | null>(null)
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
const [deadlines, setDeadlines] = useState<DeadlineInfo[]>([])
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
const [selectedModuleId, setSelectedModuleId] = useState<string>('')
const [generatedContent, setGeneratedContent] = useState<ModuleContent | null>(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<TrainingMedia[]>([])
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
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 (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
<div className="grid grid-cols-4 gap-4">
{[1,2,3,4].map(i => <div key={i} className="h-24 bg-gray-200 rounded"></div>)}
</div>
</div>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Training Engine</h1>
<p className="text-sm text-gray-500 mt-1">
Training-Module, Zuweisungen und Compliance-Schulungen verwalten
</p>
</div>
<button
onClick={handleCheckEscalation}
className="px-4 py-2 text-sm bg-orange-50 text-orange-700 border border-orange-200 rounded-lg hover:bg-orange-100"
>
Eskalation pruefen
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex -mb-px space-x-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<KPICard label="Module" value={stats.total_modules} />
<KPICard label="Zuweisungen" value={stats.total_assignments} />
<KPICard label="Abschlussrate" value={`${stats.completion_rate.toFixed(1)}%`} color={stats.completion_rate >= 80 ? 'green' : stats.completion_rate >= 50 ? 'yellow' : 'red'} />
<KPICard label="Ueberfaellig" value={stats.overdue_count} color={stats.overdue_count > 0 ? 'red' : 'green'} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<KPICard label="Ausstehend" value={stats.pending_count} />
<KPICard label="In Bearbeitung" value={stats.in_progress_count} />
<KPICard label="Avg. Quiz-Score" value={`${stats.avg_quiz_score.toFixed(1)}%`} />
<KPICard label="Deadlines (7d)" value={stats.upcoming_deadlines} color={stats.upcoming_deadlines > 5 ? 'yellow' : 'green'} />
</div>
{/* Status Bar */}
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Status-Verteilung</h3>
{stats.total_assignments > 0 && (
<div className="flex gap-1 h-6 rounded-full overflow-hidden bg-gray-100">
{stats.completed_count > 0 && <div className="bg-green-500" style={{ width: `${(stats.completed_count / stats.total_assignments) * 100}%` }} title={`Abgeschlossen: ${stats.completed_count}`} />}
{stats.in_progress_count > 0 && <div className="bg-blue-500" style={{ width: `${(stats.in_progress_count / stats.total_assignments) * 100}%` }} title={`In Bearbeitung: ${stats.in_progress_count}`} />}
{stats.pending_count > 0 && <div className="bg-gray-400" style={{ width: `${(stats.pending_count / stats.total_assignments) * 100}%` }} title={`Ausstehend: ${stats.pending_count}`} />}
{stats.overdue_count > 0 && <div className="bg-red-500" style={{ width: `${(stats.overdue_count / stats.total_assignments) * 100}%` }} title={`Ueberfaellig: ${stats.overdue_count}`} />}
</div>
)}
<div className="flex gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-green-500 inline-block" /> Abgeschlossen</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-blue-500 inline-block" /> In Bearbeitung</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-400 inline-block" /> Ausstehend</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-500 inline-block" /> Ueberfaellig</span>
</div>
</div>
{/* Deadlines */}
{deadlines.length > 0 && (
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Deadlines</h3>
<div className="space-y-2">
{deadlines.slice(0, 5).map(d => (
<div key={d.assignment_id} className="flex items-center justify-between text-sm">
<div>
<span className="font-medium">{d.module_title}</span>
<span className="text-gray-500 ml-2">({d.user_name})</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs ${
d.days_left <= 0 ? 'bg-red-100 text-red-700' :
d.days_left <= 7 ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{d.days_left <= 0 ? `${Math.abs(d.days_left)} Tage ueberfaellig` : `${d.days_left} Tage`}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'modules' && (
<div className="space-y-4">
<div className="flex gap-3">
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
<option value="">Alle Bereiche</option>
{Object.entries(REGULATION_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredModules.map(m => (
<div key={m.id} className="bg-white border rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between">
<div>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[m.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[m.regulation_area]?.text || 'text-gray-700'}`}>
{REGULATION_LABELS[m.regulation_area] || m.regulation_area}
</span>
<h3 className="mt-2 font-medium text-gray-900">{m.title}</h3>
<p className="text-xs text-gray-500 mt-0.5">{m.module_code}</p>
</div>
{m.nis2_relevant && (
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
)}
</div>
{m.description && <p className="text-sm text-gray-600 mt-2 line-clamp-2">{m.description}</p>}
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500">
<span>{m.duration_minutes} Min.</span>
<span>{FREQUENCY_LABELS[m.frequency_type]}</span>
<span>Quiz: {m.pass_threshold}%</span>
</div>
</div>
))}
</div>
{filteredModules.length === 0 && <p className="text-center text-gray-500 py-8">Keine Module gefunden</p>}
</div>
)}
{activeTab === 'matrix' && matrix && (
<div className="space-y-4">
<p className="text-sm text-gray-600">Compliance Training Matrix (CTM): Welche Rollen benoetigen welche Schulungsmodule</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left p-2 border font-medium text-gray-700 w-48">Rolle</th>
<th className="text-left p-2 border font-medium text-gray-700">Module</th>
<th className="text-center p-2 border font-medium text-gray-700 w-20">Anzahl</th>
</tr>
</thead>
<tbody>
{ALL_ROLES.map(role => {
const entries = matrix.entries[role] || []
return (
<tr key={role} className="border-b hover:bg-gray-50">
<td className="p-2 border font-medium">
<span className="text-gray-900">{role}</span>
<span className="text-gray-500 ml-1 text-xs">{ROLE_LABELS[role]}</span>
</td>
<td className="p-2 border">
<div className="flex flex-wrap gap-1">
{entries.map(e => (
<span key={e.id || e.module_id} className={`inline-block px-2 py-0.5 rounded text-xs ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`} title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}>
{e.module_code}
</span>
))}
{entries.length === 0 && <span className="text-gray-400 text-xs">Keine Module</span>}
</div>
</td>
<td className="p-2 border text-center">{entries.length}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'assignments' && (
<div className="space-y-4">
<div className="flex gap-3">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left p-2 border font-medium text-gray-700">Modul</th>
<th className="text-left p-2 border font-medium text-gray-700">Mitarbeiter</th>
<th className="text-left p-2 border font-medium text-gray-700">Rolle</th>
<th className="text-center p-2 border font-medium text-gray-700">Fortschritt</th>
<th className="text-center p-2 border font-medium text-gray-700">Status</th>
<th className="text-center p-2 border font-medium text-gray-700">Quiz</th>
<th className="text-left p-2 border font-medium text-gray-700">Deadline</th>
<th className="text-center p-2 border font-medium text-gray-700">Eskalation</th>
</tr>
</thead>
<tbody>
{filteredAssignments.map(a => (
<tr key={a.id} className="border-b hover:bg-gray-50">
<td className="p-2 border">
<div className="font-medium">{a.module_title || a.module_code}</div>
<div className="text-xs text-gray-500">{a.module_code}</div>
</td>
<td className="p-2 border">
<div>{a.user_name}</div>
<div className="text-xs text-gray-500">{a.user_email}</div>
</td>
<td className="p-2 border text-xs">{a.role_code || '-'}</td>
<td className="p-2 border text-center">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${a.progress_percent}%` }} />
</div>
<span className="text-xs w-8">{a.progress_percent}%</span>
</div>
</td>
<td className="p-2 border text-center">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[a.status]?.bg || ''} ${STATUS_COLORS[a.status]?.text || ''}`}>
{STATUS_LABELS[a.status] || a.status}
</span>
</td>
<td className="p-2 border text-center text-xs">
{a.quiz_score != null ? (
<span className={`font-medium ${a.quiz_passed ? 'text-green-600' : 'text-red-600'}`}>{a.quiz_score.toFixed(0)}%</span>
) : '-'}
</td>
<td className="p-2 border text-xs">{new Date(a.deadline).toLocaleDateString('de-DE')}</td>
<td className="p-2 border text-center">
{a.escalation_level > 0 ? <span className="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">L{a.escalation_level}</span> : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredAssignments.length === 0 && <p className="text-center text-gray-500 py-8">Keine Zuweisungen</p>}
</div>
)}
{activeTab === 'content' && (
<div className="space-y-6">
{/* Bulk Generation */}
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
<div className="flex gap-3">
<button
onClick={handleBulkContent}
disabled={bulkGenerating}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
</button>
<button
onClick={handleBulkQuiz}
disabled={bulkGenerating}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
</button>
</div>
{bulkResult && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex gap-6">
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
{bulkResult.errors.length > 0 && (
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
)}
</div>
{bulkResult.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
</div>
)}
</div>
)}
</div>
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
<select
value={selectedModuleId}
onChange={e => { setSelectedModuleId(e.target.value); setGeneratedContent(null); setModuleMedia([]); if (e.target.value) { handleLoadContent(e.target.value); loadModuleMedia(e.target.value); } }}
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
>
<option value="">Modul waehlen...</option>
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
</select>
</div>
<button onClick={handleGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{generating ? 'Generiere...' : 'Inhalt generieren'}
</button>
<button onClick={handleGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{generating ? 'Generiere...' : 'Quiz generieren'}
</button>
</div>
</div>
{generatedContent && (
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
</div>
{!generatedContent.is_published ? (
<button onClick={() => handlePublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
) : (
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
)}
</div>
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
</div>
</div>
)}
{/* Audio Player */}
{selectedModuleId && generatedContent?.is_published && (
<AudioPlayer
moduleId={selectedModuleId}
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
/>
)}
{/* Video Player */}
{selectedModuleId && generatedContent?.is_published && (
<VideoPlayer
moduleId={selectedModuleId}
video={moduleMedia.find(m => m.media_type === 'video') || null}
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
/>
)}
{/* Script Preview */}
{selectedModuleId && generatedContent?.is_published && (
<ScriptPreview moduleId={selectedModuleId} />
)}
</div>
)}
{activeTab === 'audit' && (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left p-2 border font-medium text-gray-700">Zeitpunkt</th>
<th className="text-left p-2 border font-medium text-gray-700">Aktion</th>
<th className="text-left p-2 border font-medium text-gray-700">Entitaet</th>
<th className="text-left p-2 border font-medium text-gray-700">Details</th>
</tr>
</thead>
<tbody>
{auditLog.map(entry => (
<tr key={entry.id} className="border-b hover:bg-gray-50">
<td className="p-2 border text-xs text-gray-600">{new Date(entry.created_at).toLocaleString('de-DE')}</td>
<td className="p-2 border"><span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700">{entry.action}</span></td>
<td className="p-2 border text-xs">{entry.entity_type}</td>
<td className="p-2 border text-xs text-gray-600">{JSON.stringify(entry.details).substring(0, 100)}</td>
</tr>
))}
</tbody>
</table>
</div>
{auditLog.length === 0 && <p className="text-center text-gray-500 py-8">Keine Audit-Eintraege</p>}
</div>
)}
</div>
)
}
function KPICard({ label, value, color }: { label: string; value: string | number; color?: string }) {
const colorMap: Record<string, string> = {
green: 'bg-green-50 border-green-200',
yellow: 'bg-yellow-50 border-yellow-200',
red: 'bg-red-50 border-red-200',
}
const textMap: Record<string, string> = {
green: 'text-green-700',
yellow: 'text-yellow-700',
red: 'text-red-700',
}
return (
<div className={`border rounded-lg p-4 ${color ? colorMap[color] || 'bg-white border-gray-200' : 'bg-white border-gray-200'}`}>
<p className="text-xs text-gray-500">{label}</p>
<p className={`text-2xl font-bold mt-1 ${color ? textMap[color] || 'text-gray-900' : 'text-gray-900'}`}>{value}</p>
</div>
)
}

View File

@@ -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')
}

View File

@@ -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<string | null>(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 (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Audio</h4>
{!audio && (
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating ? 'Generiere Audio...' : 'Audio generieren'}
</button>
)}
</div>
{error && (
<div className="text-xs text-red-600 mb-2">{error}</div>
)}
{generating && !audio && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
Audio wird generiert (kann einige Minuten dauern)...
</div>
)}
{audio && audio.status === 'processing' && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
Audio wird verarbeitet...
</div>
)}
{audio && audio.status === 'failed' && (
<div className="space-y-2">
<div className="text-xs text-red-600">Generierung fehlgeschlagen: {audio.error_message}</div>
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
Erneut versuchen
</button>
</div>
)}
{audio && audio.status === 'completed' && (
<div className="space-y-3">
<audio controls className="w-full" preload="none">
<source src={`/api/sdk/v1/training/media/${audio.id}/stream`} type="audio/mpeg" />
Ihr Browser unterstuetzt kein Audio.
</audio>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex gap-4">
<span>Dauer: {formatDuration(audio.duration_seconds)}</span>
<span>Groesse: {formatSize(audio.file_size_bytes)}</span>
<span>Stimme: {audio.voice_model}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePublishToggle}
className={`px-2 py-1 rounded text-xs ${audio.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}
>
{audio.is_published ? 'Veroeffentlicht' : 'Veroeffentlichen'}
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200 disabled:opacity-50"
>
Neu generieren
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<VideoScript | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Folien-Vorschau</h4>
<button
onClick={handlePreview}
disabled={loading}
className="px-3 py-1.5 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
>
{loading ? 'Generiere Vorschau...' : 'Script-Vorschau'}
</button>
</div>
{error && (
<div className="text-xs text-red-600 mb-2">{error}</div>
)}
{loading && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-gray-500 border-t-transparent rounded-full" />
Folien-Script wird generiert...
</div>
)}
{script && (
<div className="space-y-3">
<h5 className="text-sm font-medium text-gray-900">{script.title}</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{script.sections.map((section, i) => (
<div key={i} className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-white bg-blue-900 rounded-full w-6 h-6 flex items-center justify-center">
{i + 1}
</span>
<h6 className="text-sm font-medium text-gray-800">{section.heading}</h6>
</div>
{section.text && (
<p className="text-xs text-gray-600 mb-2">{section.text}</p>
)}
{section.bullet_points && section.bullet_points.length > 0 && (
<ul className="space-y-1">
{section.bullet_points.map((bp, j) => (
<li key={j} className="text-xs text-gray-600 flex items-start gap-1">
<span className="text-blue-500 mt-0.5">{String.fromCharCode(8226)}</span>
{bp}
</li>
))}
</ul>
)}
</div>
))}
</div>
<p className="text-xs text-gray-400">{script.sections.length} Folien</p>
</div>
)}
</div>
)
}

View File

@@ -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<string | null>(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 (
<div className="border rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Praesentationsvideo</h4>
{!video && (
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
{generating ? 'Generiere Video...' : 'Video generieren'}
</button>
)}
</div>
{error && (
<div className="text-xs text-red-600 mb-2">{error}</div>
)}
{generating && !video && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-purple-500 border-t-transparent rounded-full" />
Video wird generiert (Folien + Audio, kann einige Minuten dauern)...
</div>
)}
{video && video.status === 'processing' && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="animate-spin h-4 w-4 border-2 border-purple-500 border-t-transparent rounded-full" />
Video wird verarbeitet...
</div>
)}
{video && video.status === 'failed' && (
<div className="space-y-2">
<div className="text-xs text-red-600">Generierung fehlgeschlagen: {video.error_message}</div>
<button
onClick={handleGenerate}
disabled={generating}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
Erneut versuchen
</button>
</div>
)}
{video && video.status === 'completed' && (
<div className="space-y-3">
<video controls className="w-full rounded-lg bg-black" preload="none">
<source src={`/api/sdk/v1/training/media/${video.id}/stream`} type="video/mp4" />
Ihr Browser unterstuetzt kein Video.
</video>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex gap-4">
<span>Dauer: {formatDuration(video.duration_seconds)}</span>
<span>Groesse: {formatSize(video.file_size_bytes)}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePublishToggle}
className={`px-2 py-1 rounded text-xs ${video.is_published ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}
>
{video.is_published ? 'Veroeffentlicht' : 'Veroeffentlichen'}
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200 disabled:opacity-50"
>
Neu generieren
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<ModuleListResponse> {
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<ModuleListResponse>(`/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<TrainingModule> {
return apiFetch<TrainingModule>('/modules', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateModule(id: string, data: Record<string, unknown>): Promise<TrainingModule> {
return apiFetch<TrainingModule>(`/modules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
// =============================================================================
// MATRIX
// =============================================================================
export async function getMatrix(): Promise<MatrixResponse> {
return apiFetch<MatrixResponse>('/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<unknown> {
return apiFetch('/matrix', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteMatrixEntry(role: string, moduleId: string): Promise<unknown> {
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<AssignmentListResponse> {
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<AssignmentListResponse>(`/assignments${qs ? `?${qs}` : ''}`)
}
export async function getAssignment(id: string): Promise<TrainingAssignment> {
return apiFetch<TrainingAssignment>(`/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<QuizSubmitResponse> {
return apiFetch<QuizSubmitResponse>(`/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<ModuleContent> {
return apiFetch<ModuleContent>('/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<ModuleContent> {
return apiFetch<ModuleContent>(`/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<DeadlineListResponse> {
const qs = limit ? `?limit=${limit}` : ''
return apiFetch<DeadlineListResponse>(`/deadlines${qs}`)
}
export async function getOverdueDeadlines(): Promise<DeadlineListResponse> {
return apiFetch<DeadlineListResponse>('/deadlines/overdue')
}
export async function checkEscalation(): Promise<EscalationResponse> {
return apiFetch<EscalationResponse>('/escalation/check', { method: 'POST' })
}
// =============================================================================
// AUDIT & STATS
// =============================================================================
export async function getAuditLog(filters?: {
action?: string
entity_type?: string
limit?: number
offset?: number
}): Promise<AuditLogResponse> {
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<AuditLogResponse>(`/audit-log${qs ? `?${qs}` : ''}`)
}
export async function getStats(): Promise<TrainingStats> {
return apiFetch<TrainingStats>('/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<TrainingMedia> {
return apiFetch<TrainingMedia>(`/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<TrainingMedia> {
return apiFetch<TrainingMedia>(`/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' })
}

View File

@@ -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<RegulationArea, string> = {
dsgvo: 'DSGVO',
nis2: 'NIS-2',
iso27001: 'ISO 27001',
ai_act: 'AI Act',
geschgehg: 'GeschGehG',
hinschg: 'HinSchG',
}
export const REGULATION_COLORS: Record<RegulationArea, { bg: string; text: string; border: string }> = {
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<FrequencyType, string> = {
onboarding: 'Onboarding',
annual: 'Jaehrlich',
event_trigger: 'Ereignisbasiert',
micro: 'Micro-Training',
}
export const STATUS_LABELS: Record<AssignmentStatus, string> = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
overdue: 'Ueberfaellig',
expired: 'Abgelaufen',
}
export const STATUS_COLORS: Record<AssignmentStatus, { bg: string; text: string }> = {
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<string, string> = {
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<string, unknown>
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<string, TrainingMatrixEntry[]>
roles: Record<string, string>
}
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<string, unknown>
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[]
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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] + "..."
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 $$;

View File

@@ -0,0 +1,157 @@
-- =========================================================
-- Migration 015: IT-Security Training Modules
-- =========================================================
-- 8 neue IT-Security Micro-/Annual-Trainingsmodule
-- fuer Template-Tenant und Breakpilot-Tenant
-- =========================================================
DO $$
DECLARE
-- Tenant IDs
tmpl_id UUID := '00000000-0000-0000-0000-000000000000';
bp_id UUID := '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e';
-- Module IDs for template tenant CTM
t_sec_pwd UUID;
t_sec_desk UUID;
t_sec_kiai UUID;
t_sec_byod UUID;
t_sec_video UUID;
t_sec_usb UUID;
t_sec_inc UUID;
t_sec_home UUID;
-- Module IDs for breakpilot tenant CTM
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
-- =======================================================
-- 1) Template Tenant Modules
-- =======================================================
IF EXISTS (SELECT 1 FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-PWD' LIMIT 1) THEN
RAISE NOTICE 'IT-Security modules already exist for template tenant, skipping template insert';
ELSE
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(), tmpl_id, 'SEC-PWD', 'Passwortsicherheit & MFA', 'Sichere Passwoerter, Multi-Faktor-Authentifizierung, Passwort-Manager', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 21),
(gen_random_uuid(), tmpl_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(), tmpl_id, 'SEC-KIAI', 'Personenbezogene Daten in KI-Tools', 'DSGVO-konforme Nutzung von KI, ChatGPT & Co., Datenweitergabe-Risiken', 'dsgvo', false, 'annual', 365, 2.5, 30, 70, 23),
(gen_random_uuid(), tmpl_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(), tmpl_id, 'SEC-VIDEO', 'Sichere Videokonferenzen', 'Datenschutz in Videokonferenzen, Screensharing-Risiken, Aufzeichnungsregeln', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 25),
(gen_random_uuid(), tmpl_id, 'SEC-USB', 'USB & Externe Medien', 'Risiken externer Datentraeger, USB-Richtlinien, Verschluesselung', 'iso27001', false, 'micro', 180, 1.5, 10, 70, 26),
(gen_random_uuid(), tmpl_id, 'SEC-INC', 'Sicherheitsvorfall melden', 'Erkennung von Sicherheitsvorfaellen, Meldewege, Sofortmassnahmen, Dokumentation', 'iso27001', true, 'micro', 180, 1.5, 10, 70, 27),
(gen_random_uuid(), tmpl_id, 'SEC-HOME', 'Homeoffice-Sicherheit', 'Sicheres Arbeiten von zuhause, VPN, WLAN-Sicherheit, physische Sicherheit', 'iso27001', false, 'annual', 365, 2.0, 15, 70, 28);
END IF;
-- Lookup template module IDs for CTM
SELECT id INTO t_sec_pwd FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-PWD';
SELECT id INTO t_sec_desk FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-DESK';
SELECT id INTO t_sec_kiai FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-KIAI';
SELECT id INTO t_sec_byod FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-BYOD';
SELECT id INTO t_sec_video FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-VIDEO';
SELECT id INTO t_sec_usb FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-USB';
SELECT id INTO t_sec_inc FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-INC';
SELECT id INTO t_sec_home FROM training_modules WHERE tenant_id = tmpl_id AND module_code = 'SEC-HOME';
-- Template CTM entries (skip if already exist)
IF NOT EXISTS (SELECT 1 FROM training_matrix WHERE tenant_id = tmpl_id AND module_id = t_sec_pwd LIMIT 1) THEN
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
-- SEC-PWD: R7, R8, R9
(tmpl_id, 'R7', t_sec_pwd, true, 4),
(tmpl_id, 'R8', t_sec_pwd, true, 4),
(tmpl_id, 'R9', t_sec_pwd, true, 3),
-- SEC-DESK: R9
(tmpl_id, 'R9', t_sec_desk, true, 3),
-- SEC-KIAI: R3, R7, R9
(tmpl_id, 'R3', t_sec_kiai, true, 3),
(tmpl_id, 'R7', t_sec_kiai, true, 4),
(tmpl_id, 'R9', t_sec_kiai, true, 3),
-- SEC-BYOD: R2, R8, R9
(tmpl_id, 'R2', t_sec_byod, true, 4),
(tmpl_id, 'R8', t_sec_byod, true, 4),
(tmpl_id, 'R9', t_sec_byod, true, 3),
-- SEC-VIDEO: R9 (optional)
(tmpl_id, 'R9', t_sec_video, false, 5),
-- SEC-USB: R2, R8, R9
(tmpl_id, 'R2', t_sec_usb, true, 4),
(tmpl_id, 'R8', t_sec_usb, true, 4),
(tmpl_id, 'R9', t_sec_usb, true, 3),
-- SEC-INC: R2, R7, R8, R9
(tmpl_id, 'R2', t_sec_inc, true, 2),
(tmpl_id, 'R7', t_sec_inc, true, 4),
(tmpl_id, 'R8', t_sec_inc, true, 2),
(tmpl_id, 'R9', t_sec_inc, true, 2),
-- SEC-HOME: R8, R9
(tmpl_id, 'R8', t_sec_home, true, 4),
(tmpl_id, 'R9', t_sec_home, true, 3);
END IF;
-- =======================================================
-- 2) Breakpilot Tenant Modules
-- =======================================================
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 Breakpilot insert';
ELSE
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 & 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);
END IF;
-- Lookup Breakpilot module IDs for CTM
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';
-- Breakpilot CTM entries (skip if already exist)
IF NOT EXISTS (SELECT 1 FROM training_matrix WHERE tenant_id = bp_id AND module_id = b_sec_pwd LIMIT 1) THEN
INSERT INTO training_matrix (tenant_id, role_code, module_id, is_mandatory, priority) VALUES
-- SEC-PWD: R7, R8, R9
(bp_id, 'R7', b_sec_pwd, true, 4),
(bp_id, 'R8', b_sec_pwd, true, 4),
(bp_id, 'R9', b_sec_pwd, true, 3),
-- SEC-DESK: R9
(bp_id, 'R9', b_sec_desk, true, 3),
-- SEC-KIAI: R3, R7, R9
(bp_id, 'R3', b_sec_kiai, true, 3),
(bp_id, 'R7', b_sec_kiai, true, 4),
(bp_id, 'R9', b_sec_kiai, true, 3),
-- SEC-BYOD: R2, R8, R9
(bp_id, 'R2', b_sec_byod, true, 4),
(bp_id, 'R8', b_sec_byod, true, 4),
(bp_id, 'R9', b_sec_byod, true, 3),
-- SEC-VIDEO: R9 (optional)
(bp_id, 'R9', b_sec_video, false, 5),
-- SEC-USB: R2, R8, R9
(bp_id, 'R2', b_sec_usb, true, 4),
(bp_id, 'R8', b_sec_usb, true, 4),
(bp_id, 'R9', b_sec_usb, true, 3),
-- SEC-INC: R2, R7, R8, R9
(bp_id, 'R2', b_sec_inc, true, 2),
(bp_id, 'R7', b_sec_inc, true, 4),
(bp_id, 'R8', b_sec_inc, true, 2),
(bp_id, 'R9', b_sec_inc, true, 2),
-- SEC-HOME: R8, R9
(bp_id, 'R8', b_sec_home, true, 4),
(bp_id, 'R9', b_sec_home, true, 3);
END IF;
RAISE NOTICE 'Migration 015: IT-Security modules inserted successfully';
END $$;

View File

@@ -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;

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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: