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
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:
607
admin-compliance/app/(sdk)/sdk/training/page.tsx
Normal file
607
admin-compliance/app/(sdk)/sdk/training/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts
Normal file
105
admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts
Normal 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')
|
||||||
|
}
|
||||||
130
admin-compliance/components/training/AudioPlayer.tsx
Normal file
130
admin-compliance/components/training/AudioPlayer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
admin-compliance/components/training/ScriptPreview.tsx
Normal file
86
admin-compliance/components/training/ScriptPreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
admin-compliance/components/training/VideoPlayer.tsx
Normal file
129
admin-compliance/components/training/VideoPlayer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
311
admin-compliance/lib/sdk/training/api.ts
Normal file
311
admin-compliance/lib/sdk/training/api.ts
Normal 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' })
|
||||||
|
}
|
||||||
309
admin-compliance/lib/sdk/training/types.ts
Normal file
309
admin-compliance/lib/sdk/training/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
|
"github.com/breakpilot/ai-compliance-sdk/internal/workshop"
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
|
"github.com/breakpilot/ai-compliance-sdk/internal/portfolio"
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/gci"
|
"github.com/breakpilot/ai-compliance-sdk/internal/gci"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -129,6 +130,11 @@ func main() {
|
|||||||
gciEngine := gci.NewEngine()
|
gciEngine := gci.NewEngine()
|
||||||
gciHandlers := handlers.NewGCIHandlers(gciEngine)
|
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
|
// Initialize middleware
|
||||||
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
|
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
|
||||||
|
|
||||||
@@ -680,6 +686,63 @@ func main() {
|
|||||||
gciRoutes.GET("/iso/mappings/:controlId", gciHandlers.GetISOMapping)
|
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
|
// Create HTTP server
|
||||||
|
|||||||
1113
ai-compliance-sdk/internal/api/handlers/training_handlers.go
Normal file
1113
ai-compliance-sdk/internal/api/handlers/training_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,8 @@ type Config struct {
|
|||||||
|
|
||||||
// Frontend URLs
|
// Frontend URLs
|
||||||
AdminFrontendURL string
|
AdminFrontendURL string
|
||||||
|
// TTS Service
|
||||||
|
TTSServiceURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load loads configuration from environment variables
|
// Load loads configuration from environment variables
|
||||||
@@ -105,6 +107,7 @@ func Load() (*Config, error) {
|
|||||||
// Integration
|
// Integration
|
||||||
ConsentServiceURL: getEnv("CONSENT_SERVICE_URL", "http://localhost:8081"),
|
ConsentServiceURL: getEnv("CONSENT_SERVICE_URL", "http://localhost:8081"),
|
||||||
AdminFrontendURL: getEnv("ADMIN_FRONTEND_URL", "http://localhost:3002"),
|
AdminFrontendURL: getEnv("ADMIN_FRONTEND_URL", "http://localhost:3002"),
|
||||||
|
TTSServiceURL: getEnv("TTS_SERVICE_URL", "http://compliance-tts-service:8095"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse allowed origins
|
// Parse allowed origins
|
||||||
|
|||||||
183
ai-compliance-sdk/internal/training/assignment.go
Normal file
183
ai-compliance-sdk/internal/training/assignment.go
Normal 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"`
|
||||||
|
}
|
||||||
602
ai-compliance-sdk/internal/training/content_generator.go
Normal file
602
ai-compliance-sdk/internal/training/content_generator.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
177
ai-compliance-sdk/internal/training/escalation.go
Normal file
177
ai-compliance-sdk/internal/training/escalation.go
Normal 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
|
||||||
|
}
|
||||||
127
ai-compliance-sdk/internal/training/matrix.go
Normal file
127
ai-compliance-sdk/internal/training/matrix.go
Normal 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
|
||||||
|
}
|
||||||
186
ai-compliance-sdk/internal/training/media.go
Normal file
186
ai-compliance-sdk/internal/training/media.go
Normal 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
|
||||||
|
}
|
||||||
500
ai-compliance-sdk/internal/training/models.go
Normal file
500
ai-compliance-sdk/internal/training/models.go
Normal 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"`
|
||||||
|
}
|
||||||
1277
ai-compliance-sdk/internal/training/store.go
Normal file
1277
ai-compliance-sdk/internal/training/store.go
Normal file
File diff suppressed because it is too large
Load Diff
268
ai-compliance-sdk/migrations/014_training_engine.sql
Normal file
268
ai-compliance-sdk/migrations/014_training_engine.sql
Normal 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 $$;
|
||||||
157
ai-compliance-sdk/migrations/015_it_security_modules.sql
Normal file
157
ai-compliance-sdk/migrations/015_it_security_modules.sql
Normal 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 $$;
|
||||||
28
ai-compliance-sdk/migrations/016_training_media.sql
Normal file
28
ai-compliance-sdk/migrations/016_training_media.sql
Normal 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;
|
||||||
44
compliance-tts-service/Dockerfile
Normal file
44
compliance-tts-service/Dockerfile
Normal 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"]
|
||||||
175
compliance-tts-service/main.py
Normal file
175
compliance-tts-service/main.py
Normal 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
|
||||||
6
compliance-tts-service/requirements.txt
Normal file
6
compliance-tts-service/requirements.txt
Normal 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
|
||||||
132
compliance-tts-service/slide_renderer.py
Normal file
132
compliance-tts-service/slide_renderer.py
Normal 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}")
|
||||||
56
compliance-tts-service/storage.py
Normal file
56
compliance-tts-service/storage.py
Normal 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
|
||||||
157
compliance-tts-service/tts_engine.py
Normal file
157
compliance-tts-service/tts_engine.py
Normal 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
|
||||||
127
compliance-tts-service/video_generator.py
Normal file
127
compliance-tts-service/video_generator.py
Normal 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())
|
||||||
@@ -147,6 +147,7 @@ services:
|
|||||||
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365}
|
||||||
AUDIT_LOG_PROMPTS: ${AUDIT_LOG_PROMPTS:-true}
|
AUDIT_LOG_PROMPTS: ${AUDIT_LOG_PROMPTS:-true}
|
||||||
ALLOWED_ORIGINS: "*"
|
ALLOWED_ORIGINS: "*"
|
||||||
|
TTS_SERVICE_URL: http://compliance-tts-service:8095
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -163,6 +164,35 @@ services:
|
|||||||
- breakpilot-network
|
- 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
|
# DATA SOVEREIGNTY
|
||||||
# =========================================================
|
# =========================================================
|
||||||
dsms-node:
|
dsms-node:
|
||||||
|
|||||||
Reference in New Issue
Block a user