feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
case 'controls': {
|
||||
const controlParams = new URLSearchParams()
|
||||
const passthrough = ['severity', 'domain', 'verification_method', 'category',
|
||||
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
||||
'target_audience', 'source', 'search', 'sort', 'order', 'limit', 'offset']
|
||||
for (const key of passthrough) {
|
||||
const val = searchParams.get(key)
|
||||
@@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
case 'controls-count': {
|
||||
const countParams = new URLSearchParams()
|
||||
const countPassthrough = ['severity', 'domain', 'verification_method', 'category',
|
||||
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category',
|
||||
'target_audience', 'source', 'search']
|
||||
for (const key of countPassthrough) {
|
||||
const val = searchParams.get(key)
|
||||
@@ -175,6 +175,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
|
||||
} else if (endpoint === 'bulk-review') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
|
||||
} else if (endpoint === 'blocked-sources-cleanup') {
|
||||
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
|
||||
} else if (endpoint === 'similarity-check') {
|
||||
|
||||
@@ -53,7 +53,18 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
redirect: 'manual',
|
||||
})
|
||||
|
||||
// Handle redirects (e.g. media stream presigned URL)
|
||||
if (response.status === 307 || response.status === 302) {
|
||||
const location = response.headers.get('location')
|
||||
if (location) {
|
||||
return NextResponse.redirect(location)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -69,6 +80,19 @@ async function proxyRequest(
|
||||
)
|
||||
}
|
||||
|
||||
// Handle binary responses (PDF, octet-stream)
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
|
||||
const buffer = await response.arrayBuffer()
|
||||
return new NextResponse(buffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface CanonicalControl {
|
||||
customer_visible?: boolean
|
||||
verification_method: string | null
|
||||
category: string | null
|
||||
target_audience: string | null
|
||||
target_audience: string | string[] | null
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
generation_strategy?: string | null
|
||||
created_at: string
|
||||
@@ -142,10 +142,27 @@ export const CATEGORY_OPTIONS = [
|
||||
]
|
||||
|
||||
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
|
||||
// Legacy English keys
|
||||
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
authority: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
provider: { bg: 'bg-violet-100 text-violet-700', label: 'Anbieter' },
|
||||
all: { bg: 'bg-gray-100 text-gray-700', label: 'Alle' },
|
||||
// German keys from LLM generation
|
||||
unternehmen: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
|
||||
behoerden: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
|
||||
entwickler: { bg: 'bg-sky-100 text-sky-700', label: 'Entwickler' },
|
||||
datenschutzbeauftragte: { bg: 'bg-purple-100 text-purple-700', label: 'DSB' },
|
||||
geschaeftsfuehrung: { bg: 'bg-amber-100 text-amber-700', label: 'GF' },
|
||||
'it-abteilung': { bg: 'bg-blue-100 text-blue-700', label: 'IT' },
|
||||
rechtsabteilung: { bg: 'bg-fuchsia-100 text-fuchsia-700', label: 'Recht' },
|
||||
'compliance-officer': { bg: 'bg-indigo-100 text-indigo-700', label: 'Compliance' },
|
||||
personalwesen: { bg: 'bg-pink-100 text-pink-700', label: 'Personal' },
|
||||
einkauf: { bg: 'bg-lime-100 text-lime-700', label: 'Einkauf' },
|
||||
produktion: { bg: 'bg-orange-100 text-orange-700', label: 'Produktion' },
|
||||
vertrieb: { bg: 'bg-teal-100 text-teal-700', label: 'Vertrieb' },
|
||||
gesundheitswesen: { bg: 'bg-red-100 text-red-700', label: 'Gesundheit' },
|
||||
finanzwesen: { bg: 'bg-emerald-100 text-emerald-700', label: 'Finanzen' },
|
||||
oeffentlicher_dienst: { bg: 'bg-rose-100 text-rose-700', label: 'Oeffentl. Dienst' },
|
||||
}
|
||||
|
||||
export const COLLECTION_OPTIONS = [
|
||||
@@ -223,11 +240,32 @@ export function CategoryBadge({ category }: { category: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | null }) {
|
||||
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
|
||||
if (!audience) return null
|
||||
const config = TARGET_AUDIENCE_OPTIONS[audience]
|
||||
if (!config) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
|
||||
// Parse JSON array string from DB (e.g. '["unternehmen", "einkauf"]')
|
||||
let items: string[] = []
|
||||
if (typeof audience === 'string') {
|
||||
if (audience.startsWith('[')) {
|
||||
try { items = JSON.parse(audience) } catch { items = [audience] }
|
||||
} else {
|
||||
items = [audience]
|
||||
}
|
||||
} else if (Array.isArray(audience)) {
|
||||
items = audience
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 flex-wrap">
|
||||
{items.map((item, i) => {
|
||||
const config = TARGET_AUDIENCE_OPTIONS[item]
|
||||
if (!config) return <span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">{item}</span>
|
||||
return <span key={i} className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerationStrategyBadge({ strategy }: { strategy: string | null | undefined }) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks,
|
||||
BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2,
|
||||
ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
@@ -263,6 +263,33 @@ export default function ControlLibraryPage() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false)
|
||||
|
||||
const handleBulkReject = async (sourceState: string) => {
|
||||
const count = stateFilter === sourceState ? totalCount : reviewCount
|
||||
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
|
||||
setBulkProcessing(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`)
|
||||
await fullReload()
|
||||
} else {
|
||||
const err = await res.json()
|
||||
alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`)
|
||||
}
|
||||
} catch {
|
||||
alert('Netzwerkfehler')
|
||||
} finally {
|
||||
setBulkProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
||||
@@ -381,6 +408,7 @@ export default function ControlLibraryPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{reviewCount > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={enterReviewMode}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700"
|
||||
@@ -388,6 +416,15 @@ export default function ControlLibraryPage() {
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkReject('needs_review')}
|
||||
disabled={bulkProcessing}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{bulkProcessing ? 'Wird verarbeitet...' : `Alle ${reviewCount} ablehnen`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowStats(!showStats); if (!showStats) loadProcessedStats() }}
|
||||
@@ -480,6 +517,7 @@ export default function ControlLibraryPage() {
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
<select
|
||||
value={verificationFilter}
|
||||
@@ -507,9 +545,21 @@ export default function ControlLibraryPage() {
|
||||
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Zielgruppe</option>
|
||||
{Object.entries(TARGET_AUDIENCE_OPTIONS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
<option value="unternehmen">Unternehmen</option>
|
||||
<option value="behoerden">Behoerden</option>
|
||||
<option value="entwickler">Entwickler</option>
|
||||
<option value="datenschutzbeauftragte">DSB</option>
|
||||
<option value="geschaeftsfuehrung">Geschaeftsfuehrung</option>
|
||||
<option value="it-abteilung">IT-Abteilung</option>
|
||||
<option value="rechtsabteilung">Rechtsabteilung</option>
|
||||
<option value="compliance-officer">Compliance Officer</option>
|
||||
<option value="personalwesen">Personalwesen</option>
|
||||
<option value="einkauf">Einkauf</option>
|
||||
<option value="produktion">Produktion</option>
|
||||
<option value="vertrieb">Vertrieb</option>
|
||||
<option value="gesundheitswesen">Gesundheitswesen</option>
|
||||
<option value="finanzwesen">Finanzwesen</option>
|
||||
<option value="oeffentlicher_dienst">Oeffentl. Dienst</option>
|
||||
</select>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
@@ -567,11 +617,23 @@ export default function ControlLibraryPage() {
|
||||
|
||||
{/* Pagination Header */}
|
||||
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
{stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && (
|
||||
<button
|
||||
onClick={() => handleBulkReject(stateFilter)}
|
||||
disabled={bulkProcessing}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{bulkProcessing ? '...' : `Alle ${totalCount} ablehnen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span>Seite {currentPage} von {totalPages}</span>
|
||||
</div>
|
||||
|
||||
|
||||
560
admin-compliance/app/sdk/training/learner/page.tsx
Normal file
560
admin-compliance/app/sdk/training/learner/page.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
|
||||
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
|
||||
getMediaStreamURL, getInteractiveManifest, completeAssignment,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
|
||||
InteractiveVideoManifest,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
STATUS_LABELS, STATUS_COLORS, REGULATION_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
||||
|
||||
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
|
||||
|
||||
interface QuizQuestionItem {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
export default function LearnerPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('assignments')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Assignments
|
||||
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
|
||||
|
||||
// Content
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [content, setContent] = useState<ModuleContent | null>(null)
|
||||
const [media, setMedia] = useState<TrainingMedia[]>([])
|
||||
|
||||
// Quiz
|
||||
const [questions, setQuestions] = useState<QuizQuestionItem[]>([])
|
||||
const [answers, setAnswers] = useState<Record<string, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<QuizSubmitResponse | null>(null)
|
||||
const [quizSubmitting, setQuizSubmitting] = useState(false)
|
||||
const [quizTimer, setQuizTimer] = useState(0)
|
||||
const [quizActive, setQuizActive] = useState(false)
|
||||
|
||||
// Certificates
|
||||
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
|
||||
const [certGenerating, setCertGenerating] = useState(false)
|
||||
|
||||
// Interactive Video
|
||||
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
|
||||
|
||||
// User simulation
|
||||
const [userId] = useState('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
const loadAssignments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getAssignments({ user_id: userId, limit: 100 })
|
||||
setAssignments(data.assignments || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const loadCertificates = useCallback(async () => {
|
||||
try {
|
||||
const data = await listCertificates()
|
||||
setCertificates(data.certificates || [])
|
||||
} catch {
|
||||
// Certificates may not exist yet
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadAssignments()
|
||||
loadCertificates()
|
||||
}, [loadAssignments, loadCertificates])
|
||||
|
||||
// Quiz timer
|
||||
useEffect(() => {
|
||||
if (!quizActive) return
|
||||
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [quizActive])
|
||||
|
||||
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
|
||||
try {
|
||||
const manifest = await getInteractiveManifest(moduleId, assignmentId)
|
||||
if (manifest && manifest.checkpoints && manifest.checkpoints.length > 0) {
|
||||
setInteractiveManifest(manifest)
|
||||
} else {
|
||||
setInteractiveManifest(null)
|
||||
}
|
||||
} catch {
|
||||
setInteractiveManifest(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartAssignment(assignment: TrainingAssignment) {
|
||||
try {
|
||||
await startAssignment(assignment.id)
|
||||
setSelectedAssignment({ ...assignment, status: 'in_progress' })
|
||||
// Load content
|
||||
const [contentData, mediaData] = await Promise.all([
|
||||
getContent(assignment.module_id).catch(() => null),
|
||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||
])
|
||||
setContent(contentData)
|
||||
setMedia(mediaData.media || [])
|
||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||
setActiveTab('content')
|
||||
loadAssignments()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeContent(assignment: TrainingAssignment) {
|
||||
setSelectedAssignment(assignment)
|
||||
try {
|
||||
const [contentData, mediaData] = await Promise.all([
|
||||
getContent(assignment.module_id).catch(() => null),
|
||||
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
|
||||
])
|
||||
setContent(contentData)
|
||||
setMedia(mediaData.media || [])
|
||||
await loadInteractiveManifest(assignment.module_id, assignment.id)
|
||||
setActiveTab('content')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAllCheckpointsPassed() {
|
||||
if (!selectedAssignment) return
|
||||
try {
|
||||
await completeAssignment(selectedAssignment.id)
|
||||
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
|
||||
loadAssignments()
|
||||
} catch {
|
||||
// Assignment completion may already be handled
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartQuiz() {
|
||||
if (!selectedAssignment) return
|
||||
try {
|
||||
const data = await getQuiz(selectedAssignment.module_id)
|
||||
setQuestions(data.questions || [])
|
||||
setAnswers({})
|
||||
setQuizResult(null)
|
||||
setQuizTimer(0)
|
||||
setQuizActive(true)
|
||||
setActiveTab('quiz')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitQuiz() {
|
||||
if (!selectedAssignment || questions.length === 0) return
|
||||
setQuizSubmitting(true)
|
||||
setQuizActive(false)
|
||||
try {
|
||||
const answerList = questions.map(q => ({
|
||||
question_id: q.id,
|
||||
selected_index: answers[q.id] ?? -1,
|
||||
}))
|
||||
const result = await submitQuiz(selectedAssignment.module_id, {
|
||||
assignment_id: selectedAssignment.id,
|
||||
answers: answerList,
|
||||
duration_seconds: quizTimer,
|
||||
})
|
||||
setQuizResult(result)
|
||||
loadAssignments()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen')
|
||||
} finally {
|
||||
setQuizSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateCertificate(assignmentId: string) {
|
||||
setCertGenerating(true)
|
||||
try {
|
||||
const data = await generateCertificate(assignmentId)
|
||||
if (data.certificate_id) {
|
||||
const blob = await downloadCertificatePDF(data.certificate_id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
loadAssignments()
|
||||
loadCertificates()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen')
|
||||
} finally {
|
||||
setCertGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadPDF(certId: string) {
|
||||
try {
|
||||
const blob = await downloadCertificatePDF(certId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'assignments', label: 'Meine Schulungen' },
|
||||
{ key: 'content', label: 'Schulungsinhalt' },
|
||||
{ key: 'quiz', label: 'Quiz' },
|
||||
{ key: 'certificates', label: 'Zertifikate' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Learner Portal</h1>
|
||||
<p className="text-gray-500 mt-1">Absolvieren Sie Ihre Compliance-Schulungen</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab: Meine Schulungen */}
|
||||
{activeTab === 'assignments' && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
||||
) : assignments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{assignments.map(a => (
|
||||
<div key={a.id} className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-gray-900">{a.module_title || a.module_code}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]?.bg || 'bg-gray-100'} ${STATUS_COLORS[a.status]?.text || 'text-gray-700'}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Code: {a.module_code} | Deadline: {new Date(a.deadline).toLocaleDateString('de-DE')}
|
||||
{a.quiz_score != null && ` | Quiz: ${Math.round(a.quiz_score)}%`}
|
||||
</p>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${a.status === 'completed' ? 'bg-green-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${a.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.progress_percent}% abgeschlossen</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{a.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleStartAssignment(a)}
|
||||
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleResumeContent(a)}
|
||||
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Fortsetzen
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'completed' && a.quiz_passed && !a.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleGenerateCertificate(a.id)}
|
||||
disabled={certGenerating}
|
||||
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{certGenerating ? 'Erstelle...' : 'Zertifikat'}
|
||||
</button>
|
||||
)}
|
||||
{a.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(a.certificate_id!)}
|
||||
className="px-3 py-1.5 bg-green-100 text-green-700 text-sm rounded-lg hover:bg-green-200"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Schulungsinhalt */}
|
||||
{activeTab === 'content' && (
|
||||
<div>
|
||||
{!selectedAssignment ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Waehlen Sie eine Schulung aus dem Tab "Meine Schulungen"
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Quiz starten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Interactive Video Player */}
|
||||
{interactiveManifest && selectedAssignment && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium text-gray-700">Interaktive Video-Schulung</p>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv</span>
|
||||
</div>
|
||||
<InteractiveVideoPlayer
|
||||
manifest={interactiveManifest}
|
||||
assignmentId={selectedAssignment.id}
|
||||
onAllCheckpointsPassed={handleAllCheckpointsPassed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media players (standard audio/video) */}
|
||||
{media.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{media.filter(m => m.media_type === 'audio' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Audio-Schulung</p>
|
||||
<audio controls className="w-full" src={getMediaStreamURL(m.id)}>
|
||||
Ihr Browser unterstuetzt kein Audio.
|
||||
</audio>
|
||||
</div>
|
||||
))}
|
||||
{media.filter(m => m.media_type === 'video' && m.status === 'completed' && m.generated_by !== 'tts_ffmpeg_interactive').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Video-Schulung</p>
|
||||
<video controls className="w-full rounded" src={getMediaStreamURL(m.id)}>
|
||||
Ihr Browser unterstuetzt kein Video.
|
||||
</video>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content body */}
|
||||
{content ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div
|
||||
className="prose max-w-none text-gray-800"
|
||||
dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(content.content_body) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">Kein Schulungsinhalt verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Quiz */}
|
||||
{activeTab === 'quiz' && (
|
||||
<div>
|
||||
{questions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Starten Sie ein Quiz aus dem Schulungsinhalt-Tab
|
||||
</div>
|
||||
) : quizResult ? (
|
||||
/* Quiz Results */
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className={`text-center p-8 rounded-lg border-2 ${quizResult.passed ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}`}>
|
||||
<div className="text-4xl mb-3">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">
|
||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700">
|
||||
{quizResult.correct_count} von {quizResult.total_count} richtig ({Math.round(quizResult.score)}%)
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Bestehensgrenze: {quizResult.threshold}% | Zeit: {formatTimer(quizTimer)}
|
||||
</p>
|
||||
{quizResult.passed && selectedAssignment && !selectedAssignment.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleGenerateCertificate(selectedAssignment.id)}
|
||||
disabled={certGenerating}
|
||||
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{certGenerating ? 'Erstelle Zertifikat...' : 'Zertifikat generieren & herunterladen'}
|
||||
</button>
|
||||
)}
|
||||
{!quizResult.passed && (
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Quiz erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Quiz Questions */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Quiz — {selectedAssignment?.module_title}</h2>
|
||||
<span className="text-sm text-gray-500 font-mono bg-gray-100 px-3 py-1 rounded">
|
||||
{formatTimer(quizTimer)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<p className="font-medium text-gray-900 mb-3">
|
||||
<span className="text-indigo-600 mr-2">Frage {idx + 1}.</span>
|
||||
{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oi) => (
|
||||
<label
|
||||
key={oi}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
answers[q.id] === oi
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={q.id}
|
||||
checked={answers[q.id] === oi}
|
||||
onChange={() => setAnswers(prev => ({ ...prev, [q.id]: oi }))}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={quizSubmitting || Object.keys(answers).length < questions.length}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{quizSubmitting ? 'Wird ausgewertet...' : `Quiz abgeben (${Object.keys(answers).length}/${questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Zertifikate */}
|
||||
{activeTab === 'certificates' && (
|
||||
<div>
|
||||
{certificates.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{certificates.map(cert => (
|
||||
<div key={cert.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{cert.module_title}</h3>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Bestanden</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Mitarbeiter: {cert.user_name}</p>
|
||||
<p>Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}</p>
|
||||
{cert.quiz_score != null && <p>Ergebnis: {Math.round(cert.quiz_score)}%</p>}
|
||||
<p className="font-mono text-[10px] text-gray-400">ID: {cert.certificate_id?.substring(0, 12)}</p>
|
||||
</div>
|
||||
{cert.certificate_id && (
|
||||
<button
|
||||
onClick={() => handleDownloadPDF(cert.certificate_id!)}
|
||||
className="mt-3 w-full px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
createModule, updateModule, deleteModule,
|
||||
deleteMatrixEntry, setMatrixEntry,
|
||||
startAssignment, completeAssignment, updateAssignment,
|
||||
listBlockConfigs, createBlockConfig, deleteBlockConfig,
|
||||
previewBlock, generateBlock, getCanonicalMeta,
|
||||
generateInteractiveVideo,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingModule, TrainingAssignment,
|
||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
||||
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
@@ -41,6 +45,7 @@ export default function TrainingPage() {
|
||||
const [bulkGenerating, setBulkGenerating] = useState(false)
|
||||
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
|
||||
const [interactiveGenerating, setInteractiveGenerating] = useState(false)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||
@@ -52,6 +57,15 @@ export default function TrainingPage() {
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
||||
|
||||
// Block (Controls → Module) state
|
||||
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
|
||||
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
|
||||
const [showBlockCreate, setShowBlockCreate] = useState(false)
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreview | null>(null)
|
||||
const [blockPreviewId, setBlockPreviewId] = useState<string>('')
|
||||
const [blockGenerating, setBlockGenerating] = useState(false)
|
||||
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
@@ -66,13 +80,15 @@ export default function TrainingPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
|
||||
getStats(),
|
||||
getModules(),
|
||||
getMatrix(),
|
||||
getAssignments({ limit: 50 }),
|
||||
getDeadlines(10),
|
||||
getAuditLog({ limit: 30 }),
|
||||
listBlockConfigs(),
|
||||
getCanonicalMeta(),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
@@ -81,6 +97,8 @@ export default function TrainingPage() {
|
||||
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
|
||||
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
|
||||
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
|
||||
if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks)
|
||||
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
@@ -114,6 +132,19 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateInteractiveVideo() {
|
||||
if (!selectedModuleId) return
|
||||
setInteractiveGenerating(true)
|
||||
try {
|
||||
await generateInteractiveVideo(selectedModuleId)
|
||||
await loadModuleMedia(selectedModuleId)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung')
|
||||
} finally {
|
||||
setInteractiveGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishContent(contentId: string) {
|
||||
try {
|
||||
await publishContent(contentId)
|
||||
@@ -190,6 +221,59 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Block handlers
|
||||
async function handleCreateBlock(data: {
|
||||
name: string; description?: string; domain_filter?: string; category_filter?: string;
|
||||
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
|
||||
module_code_prefix: string; max_controls_per_module?: number;
|
||||
}) {
|
||||
try {
|
||||
await createBlockConfig(data)
|
||||
setShowBlockCreate(false)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBlock(id: string) {
|
||||
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
|
||||
try {
|
||||
await deleteBlockConfig(id)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewBlock(id: string) {
|
||||
setBlockPreviewId(id)
|
||||
setBlockPreview(null)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const preview = await previewBlock(id)
|
||||
setBlockPreview(preview)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateBlock(id: string) {
|
||||
setBlockGenerating(true)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
|
||||
setBlockResult(result)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung')
|
||||
} finally {
|
||||
setBlockGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'modules', label: 'Modulkatalog' },
|
||||
@@ -521,6 +605,228 @@ export default function TrainingPage() {
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Training Blocks — Controls → Schulungsmodule */}
|
||||
<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">Schulungsbloecke aus Controls</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
|
||||
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBlockCreate(true)}
|
||||
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
+ Neuen Block erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
{blocks.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{blocks.map(block => (
|
||||
<tr key={block.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-900">{block.name}</div>
|
||||
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs">{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => handlePreviewBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerateBlock(block.id)}
|
||||
disabled={blockGenerating}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{blockGenerating ? 'Generiert...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview result */}
|
||||
{blockPreview && blockPreviewId && (
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
|
||||
<div className="flex gap-6 text-sm mb-3">
|
||||
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
|
||||
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
|
||||
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
|
||||
</div>
|
||||
{blockPreview.controls.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
|
||||
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||
{blockPreview.controls.slice(0, 50).map(ctrl => (
|
||||
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
|
||||
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
|
||||
<span className="text-gray-700 truncate">{ctrl.title}</span>
|
||||
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate result */}
|
||||
{blockResult && (
|
||||
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
|
||||
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
|
||||
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
|
||||
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
|
||||
</div>
|
||||
{blockResult.errors && blockResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Create Modal */}
|
||||
{showBlockCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
handleCreateBlock({
|
||||
name: fd.get('name') as string,
|
||||
description: fd.get('description') as string || undefined,
|
||||
domain_filter: fd.get('domain_filter') as string || undefined,
|
||||
category_filter: fd.get('category_filter') as string || undefined,
|
||||
severity_filter: fd.get('severity_filter') as string || undefined,
|
||||
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
|
||||
regulation_area: fd.get('regulation_area') as string,
|
||||
module_code_prefix: fd.get('module_code_prefix') as string,
|
||||
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
|
||||
})
|
||||
}} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Name *</label>
|
||||
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
|
||||
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
|
||||
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Domains</option>
|
||||
{canonicalMeta?.domains.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
|
||||
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
|
||||
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
|
||||
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Zielgruppen</option>
|
||||
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
|
||||
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Severity</label>
|
||||
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
|
||||
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
{Object.entries(REGULATION_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
|
||||
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
|
||||
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
|
||||
<button type="button" onClick={() => setShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
@@ -620,6 +926,35 @@ export default function TrainingPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Interactive Video */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<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">Interaktives Video</h3>
|
||||
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
|
||||
</div>
|
||||
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
|
||||
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerateInteractiveVideo}
|
||||
disabled={interactiveGenerating}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
|
||||
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
|
||||
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
|
||||
@@ -553,6 +553,32 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
Zusatzmodule
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/training"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
}
|
||||
label="Schulung (Admin)"
|
||||
isActive={pathname === '/sdk/training'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/training/learner"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
}
|
||||
label="Schulung (Learner)"
|
||||
isActive={pathname === '/sdk/training/learner'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/rag"
|
||||
icon={
|
||||
|
||||
321
admin-compliance/components/training/InteractiveVideoPlayer.tsx
Normal file
321
admin-compliance/components/training/InteractiveVideoPlayer.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import type {
|
||||
InteractiveVideoManifest,
|
||||
CheckpointEntry,
|
||||
CheckpointQuizResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import { submitCheckpointQuiz } from '@/lib/sdk/training/api'
|
||||
|
||||
interface Props {
|
||||
manifest: InteractiveVideoManifest
|
||||
assignmentId: string
|
||||
onAllCheckpointsPassed?: () => void
|
||||
}
|
||||
|
||||
export default function InteractiveVideoPlayer({ manifest, assignmentId, onAllCheckpointsPassed }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [currentCheckpoint, setCurrentCheckpoint] = useState<CheckpointEntry | null>(null)
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
const [answers, setAnswers] = useState<Record<number, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<CheckpointQuizResult | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [passedCheckpoints, setPassedCheckpoints] = useState<Set<string>>(new Set())
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
|
||||
// Initialize passed checkpoints from manifest progress
|
||||
useEffect(() => {
|
||||
const passed = new Set<string>()
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (cp.progress?.passed) {
|
||||
passed.add(cp.checkpoint_id)
|
||||
}
|
||||
}
|
||||
setPassedCheckpoints(passed)
|
||||
}, [manifest])
|
||||
|
||||
// Find next unpassed checkpoint
|
||||
const getNextUnpassedCheckpoint = useCallback((): CheckpointEntry | null => {
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (!passedCheckpoints.has(cp.checkpoint_id)) {
|
||||
return cp
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [manifest.checkpoints, passedCheckpoints])
|
||||
|
||||
// Time update handler — check for checkpoint triggers
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (!videoRef.current || showOverlay) return
|
||||
const time = videoRef.current.currentTime
|
||||
setCurrentTime(time)
|
||||
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (passedCheckpoints.has(cp.checkpoint_id)) continue
|
||||
// Trigger checkpoint when video reaches its timestamp (within 0.5s)
|
||||
if (time >= cp.timestamp_seconds && time < cp.timestamp_seconds + 1.0) {
|
||||
videoRef.current.pause()
|
||||
setCurrentCheckpoint(cp)
|
||||
setShowOverlay(true)
|
||||
setAnswers({})
|
||||
setQuizResult(null)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [manifest.checkpoints, passedCheckpoints, showOverlay])
|
||||
|
||||
// Seek protection — prevent skipping past unpassed checkpoints
|
||||
const handleSeeking = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
const seekTarget = videoRef.current.currentTime
|
||||
const nextUnpassed = getNextUnpassedCheckpoint()
|
||||
if (nextUnpassed && seekTarget > nextUnpassed.timestamp_seconds) {
|
||||
videoRef.current.currentTime = nextUnpassed.timestamp_seconds - 0.5
|
||||
}
|
||||
}, [getNextUnpassedCheckpoint])
|
||||
|
||||
// Submit checkpoint quiz
|
||||
async function handleSubmitQuiz() {
|
||||
if (!currentCheckpoint) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const answerList = currentCheckpoint.questions.map((_, i) => answers[i] ?? -1)
|
||||
const result = await submitCheckpointQuiz(
|
||||
currentCheckpoint.checkpoint_id,
|
||||
assignmentId,
|
||||
answerList,
|
||||
)
|
||||
setQuizResult(result)
|
||||
|
||||
if (result.passed) {
|
||||
setPassedCheckpoints(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(currentCheckpoint.checkpoint_id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Checkpoint quiz submission failed:', e)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue video after passing checkpoint
|
||||
function handleContinue() {
|
||||
setShowOverlay(false)
|
||||
setCurrentCheckpoint(null)
|
||||
setQuizResult(null)
|
||||
setAnswers({})
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
}
|
||||
|
||||
// Check if all checkpoints passed
|
||||
const allPassed = manifest.checkpoints.every(cp => passedCheckpoints.has(cp.checkpoint_id))
|
||||
if (allPassed && onAllCheckpointsPassed) {
|
||||
onAllCheckpointsPassed()
|
||||
}
|
||||
}
|
||||
|
||||
// Retry quiz
|
||||
function handleRetry() {
|
||||
setQuizResult(null)
|
||||
setAnswers({})
|
||||
}
|
||||
|
||||
// Resume to last unpassed checkpoint
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !manifest.checkpoints.length) return
|
||||
const nextUnpassed = getNextUnpassedCheckpoint()
|
||||
if (nextUnpassed && nextUnpassed.timestamp_seconds > 0) {
|
||||
// Start a bit before the checkpoint
|
||||
const startTime = Math.max(0, nextUnpassed.timestamp_seconds - 2)
|
||||
videoRef.current.currentTime = startTime
|
||||
}
|
||||
}, []) // Only on mount
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Progress bar percentage
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="relative bg-black rounded-lg overflow-hidden">
|
||||
{/* Video element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full"
|
||||
src={manifest.stream_url}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeeking={handleSeeking}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
controls={!showOverlay}
|
||||
/>
|
||||
|
||||
{/* Custom progress bar with checkpoint markers */}
|
||||
<div className="relative h-2 bg-gray-700">
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
{/* Checkpoint markers */}
|
||||
{manifest.checkpoints.map(cp => {
|
||||
const pos = duration > 0 ? (cp.timestamp_seconds / duration) * 100 : 0
|
||||
const isPassed = passedCheckpoints.has(cp.checkpoint_id)
|
||||
return (
|
||||
<div
|
||||
key={cp.checkpoint_id}
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full border-2 border-white ${
|
||||
isPassed ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ left: `${pos}%` }}
|
||||
title={`${cp.title} (${isPassed ? 'Bestanden' : 'Ausstehend'})`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Checkpoint overlay */}
|
||||
{showOverlay && currentCheckpoint && (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90%] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200">
|
||||
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-red-600 font-bold text-sm">
|
||||
{currentCheckpoint.index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Checkpoint: {currentCheckpoint.title}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Beantworten Sie die Fragen, um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizResult ? (
|
||||
/* Quiz result */
|
||||
<div>
|
||||
<div className={`text-center p-6 rounded-lg mb-4 ${
|
||||
quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<div className="text-3xl mb-2">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h4 className="text-lg font-bold mb-1">
|
||||
{quizResult.passed ? 'Checkpoint bestanden!' : 'Nicht bestanden'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ergebnis: {Math.round(quizResult.score)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{quizResult.feedback.map((fb, i) => (
|
||||
<div key={i} className={`p-3 rounded-lg text-sm ${
|
||||
fb.correct ? 'bg-green-50 border-l-4 border-green-400' : 'bg-red-50 border-l-4 border-red-400'
|
||||
}`}>
|
||||
<p className="font-medium">{fb.question}</p>
|
||||
{!fb.correct && (
|
||||
<p className="text-gray-600 mt-1">{fb.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quizResult.passed ? (
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Video fortsetzen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Quiz questions */
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{currentCheckpoint.questions.map((q, qIdx) => (
|
||||
<div key={qIdx} className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="font-medium text-gray-900 mb-3 text-sm">
|
||||
<span className="text-indigo-600 mr-1">Frage {qIdx + 1}.</span>
|
||||
{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oIdx) => (
|
||||
<label
|
||||
key={oIdx}
|
||||
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors text-sm ${
|
||||
answers[qIdx] === oIdx
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`checkpoint-q-${qIdx}`}
|
||||
checked={answers[qIdx] === oIdx}
|
||||
onChange={() => setAnswers(prev => ({ ...prev, [qIdx]: oIdx }))}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span className="text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={submitting || Object.keys(answers).length < currentCheckpoint.questions.length}
|
||||
className="w-full mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Wird ausgewertet...' : `Antworten absenden (${Object.keys(answers).length}/${currentCheckpoint.questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkpoint status bar */}
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center gap-2 text-xs text-gray-300">
|
||||
<span>Checkpoints:</span>
|
||||
{manifest.checkpoints.map(cp => (
|
||||
<span
|
||||
key={cp.checkpoint_id}
|
||||
className={`px-2 py-0.5 rounded-full ${
|
||||
passedCheckpoints.has(cp.checkpoint_id)
|
||||
? 'bg-green-700 text-green-100'
|
||||
: 'bg-gray-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{cp.title}
|
||||
</span>
|
||||
))}
|
||||
{manifest.checkpoints.length > 0 && (
|
||||
<span className="ml-auto">
|
||||
{passedCheckpoints.size}/{manifest.checkpoints.length} bestanden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -320,3 +320,158 @@ export async function generateVideo(moduleId: string): Promise<TrainingMedia> {
|
||||
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' })
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TRAINING BLOCKS (Controls → Schulungsmodule)
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
TrainingBlockConfig,
|
||||
CanonicalControlSummary,
|
||||
CanonicalControlMeta,
|
||||
BlockPreview,
|
||||
BlockGenerateResult,
|
||||
TrainingBlockControlLink,
|
||||
} from './types'
|
||||
|
||||
export async function listBlockConfigs(): Promise<{ blocks: TrainingBlockConfig[]; total: number }> {
|
||||
return apiFetch('/blocks')
|
||||
}
|
||||
|
||||
export async function createBlockConfig(data: {
|
||||
name: string
|
||||
description?: string
|
||||
domain_filter?: string
|
||||
category_filter?: string
|
||||
severity_filter?: string
|
||||
target_audience_filter?: string
|
||||
regulation_area: string
|
||||
module_code_prefix: string
|
||||
frequency_type?: string
|
||||
duration_minutes?: number
|
||||
pass_threshold?: number
|
||||
max_controls_per_module?: number
|
||||
}): Promise<TrainingBlockConfig> {
|
||||
return apiFetch<TrainingBlockConfig>('/blocks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBlockConfig(id: string): Promise<TrainingBlockConfig> {
|
||||
return apiFetch<TrainingBlockConfig>(`/blocks/${id}`)
|
||||
}
|
||||
|
||||
export async function updateBlockConfig(id: string, data: Record<string, unknown>): Promise<TrainingBlockConfig> {
|
||||
return apiFetch<TrainingBlockConfig>(`/blocks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBlockConfig(id: string): Promise<void> {
|
||||
return apiFetch(`/blocks/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function previewBlock(id: string): Promise<BlockPreview> {
|
||||
return apiFetch<BlockPreview>(`/blocks/${id}/preview`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function generateBlock(id: string, data?: {
|
||||
language?: string
|
||||
auto_matrix?: boolean
|
||||
}): Promise<BlockGenerateResult> {
|
||||
return apiFetch<BlockGenerateResult>(`/blocks/${id}/generate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || { language: 'de', auto_matrix: true }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBlockControls(id: string): Promise<{ controls: TrainingBlockControlLink[]; total: number }> {
|
||||
return apiFetch(`/blocks/${id}/controls`)
|
||||
}
|
||||
|
||||
export async function listCanonicalControls(filters?: {
|
||||
domain?: string
|
||||
category?: string
|
||||
severity?: string
|
||||
target_audience?: string
|
||||
}): Promise<{ controls: CanonicalControlSummary[]; total: number }> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.domain) params.set('domain', filters.domain)
|
||||
if (filters?.category) params.set('category', filters.category)
|
||||
if (filters?.severity) params.set('severity', filters.severity)
|
||||
if (filters?.target_audience) params.set('target_audience', filters.target_audience)
|
||||
const qs = params.toString()
|
||||
return apiFetch(`/canonical/controls${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getCanonicalMeta(): Promise<CanonicalControlMeta> {
|
||||
return apiFetch<CanonicalControlMeta>('/canonical/meta')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES
|
||||
// =============================================================================
|
||||
|
||||
export async function generateCertificate(assignmentId: string): Promise<{ certificate_id: string; assignment: TrainingAssignment }> {
|
||||
return apiFetch(`/certificates/generate/${assignmentId}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function listCertificates(): Promise<{ certificates: TrainingAssignment[]; total: number }> {
|
||||
return apiFetch('/certificates')
|
||||
}
|
||||
|
||||
export async function downloadCertificatePDF(certificateId: string): Promise<Blob> {
|
||||
const res = await fetch(`${BASE_URL}/certificates/${certificateId}/pdf`, {
|
||||
headers: {
|
||||
'X-Tenant-ID': typeof window !== 'undefined'
|
||||
? (localStorage.getItem('bp-tenant-id') || 'default')
|
||||
: 'default',
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error(`PDF download failed: ${res.status}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function verifyCertificate(certificateId: string): Promise<{ valid: boolean; assignment: TrainingAssignment }> {
|
||||
return apiFetch(`/certificates/${certificateId}/verify`)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MEDIA STREAMING
|
||||
// =============================================================================
|
||||
|
||||
export function getMediaStreamURL(mediaId: string): string {
|
||||
return `${BASE_URL}/media/${mediaId}/stream`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERACTIVE VIDEO
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
InteractiveVideoManifest,
|
||||
CheckpointQuizResult,
|
||||
CheckpointProgress,
|
||||
} from './types'
|
||||
|
||||
export async function generateInteractiveVideo(moduleId: string): Promise<TrainingMedia> {
|
||||
return apiFetch<TrainingMedia>(`/content/${moduleId}/generate-interactive`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function getInteractiveManifest(moduleId: string, assignmentId?: string): Promise<InteractiveVideoManifest> {
|
||||
const qs = assignmentId ? `?assignment_id=${assignmentId}` : ''
|
||||
return apiFetch<InteractiveVideoManifest>(`/content/${moduleId}/interactive-manifest${qs}`)
|
||||
}
|
||||
|
||||
export async function submitCheckpointQuiz(checkpointId: string, assignmentId: string, answers: number[]): Promise<CheckpointQuizResult> {
|
||||
return apiFetch<CheckpointQuizResult>(`/checkpoints/${checkpointId}/submit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ assignment_id: assignmentId, answers }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCheckpointProgress(assignmentId: string): Promise<{ progress: CheckpointProgress[]; total: number }> {
|
||||
return apiFetch(`/checkpoints/progress/${assignmentId}`)
|
||||
}
|
||||
|
||||
@@ -65,9 +65,17 @@ export const ROLE_LABELS: Record<string, string> = {
|
||||
R7: 'Fachabteilung',
|
||||
R8: 'IT-Administration',
|
||||
R9: 'Alle Mitarbeiter',
|
||||
R10: 'Behoerden / Oeffentlicher Dienst',
|
||||
}
|
||||
|
||||
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'] as const
|
||||
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
|
||||
|
||||
export const TARGET_AUDIENCE_LABELS: Record<string, string> = {
|
||||
enterprise: 'Unternehmen',
|
||||
authority: 'Behoerden',
|
||||
provider: 'IT-Dienstleister',
|
||||
all: 'Alle',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN ENTITIES
|
||||
@@ -273,7 +281,7 @@ export interface QuizSubmitResponse {
|
||||
// MEDIA (Audio/Video)
|
||||
// =============================================================================
|
||||
|
||||
export type MediaType = 'audio' | 'video'
|
||||
export type MediaType = 'audio' | 'video' | 'interactive_video'
|
||||
export type MediaStatus = 'processing' | 'completed' | 'failed'
|
||||
|
||||
export interface TrainingMedia {
|
||||
@@ -307,3 +315,121 @@ export interface VideoScriptSection {
|
||||
text: string
|
||||
bullet_points: string[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TRAINING BLOCKS (Controls → Schulungsmodule)
|
||||
// =============================================================================
|
||||
|
||||
export interface TrainingBlockConfig {
|
||||
id: string
|
||||
tenant_id: string
|
||||
name: string
|
||||
description?: string
|
||||
domain_filter?: string
|
||||
category_filter?: string
|
||||
severity_filter?: string
|
||||
target_audience_filter?: string
|
||||
regulation_area: RegulationArea
|
||||
module_code_prefix: string
|
||||
frequency_type: FrequencyType
|
||||
duration_minutes: number
|
||||
pass_threshold: number
|
||||
max_controls_per_module: number
|
||||
is_active: boolean
|
||||
last_generated_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CanonicalControlSummary {
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
requirements: string[]
|
||||
severity: string
|
||||
category: string
|
||||
target_audience: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export interface CanonicalControlMeta {
|
||||
domains: { domain: string; count: number }[]
|
||||
categories: { category: string; count: number }[]
|
||||
audiences: { audience: string; count: number }[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface BlockPreview {
|
||||
control_count: number
|
||||
module_count: number
|
||||
controls: CanonicalControlSummary[]
|
||||
proposed_roles: string[]
|
||||
}
|
||||
|
||||
export interface BlockGenerateResult {
|
||||
modules_created: number
|
||||
controls_linked: number
|
||||
matrix_entries_created: number
|
||||
content_generated: number
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export interface TrainingBlockControlLink {
|
||||
id: string
|
||||
block_config_id: string
|
||||
module_id: string
|
||||
control_id: string
|
||||
control_title: string
|
||||
control_objective: string
|
||||
control_requirements: string[]
|
||||
sort_order: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERACTIVE VIDEO / CHECKPOINTS
|
||||
// =============================================================================
|
||||
|
||||
export interface InteractiveVideoManifest {
|
||||
media_id: string
|
||||
stream_url: string
|
||||
checkpoints: CheckpointEntry[]
|
||||
}
|
||||
|
||||
export interface CheckpointEntry {
|
||||
checkpoint_id: string
|
||||
index: number
|
||||
title: string
|
||||
timestamp_seconds: number
|
||||
questions: CheckpointQuestion[]
|
||||
progress?: CheckpointProgress
|
||||
}
|
||||
|
||||
export interface CheckpointQuestion {
|
||||
question: string
|
||||
options: string[]
|
||||
correct_index: number
|
||||
explanation: string
|
||||
}
|
||||
|
||||
export interface CheckpointProgress {
|
||||
id: string
|
||||
assignment_id: string
|
||||
checkpoint_id: string
|
||||
passed: boolean
|
||||
attempts: number
|
||||
last_attempt_at?: string
|
||||
}
|
||||
|
||||
export interface CheckpointQuizResult {
|
||||
passed: boolean
|
||||
score: number
|
||||
feedback: CheckpointQuizFeedback[]
|
||||
}
|
||||
|
||||
export interface CheckpointQuizFeedback {
|
||||
question: string
|
||||
correct: boolean
|
||||
explanation: string
|
||||
}
|
||||
|
||||
@@ -110,7 +110,8 @@ func main() {
|
||||
academyHandlers := handlers.NewAcademyHandlers(academyStore, trainingStore)
|
||||
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
|
||||
iaceHandler := handlers.NewIACEHandler(iaceStore, providerRegistry)
|
||||
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator)
|
||||
blockGenerator := training.NewBlockGenerator(trainingStore, contentGenerator)
|
||||
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator, blockGenerator, ttsClient)
|
||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||
|
||||
// Initialize obligations framework (v2 with TOM mapping)
|
||||
@@ -433,6 +434,7 @@ func main() {
|
||||
trainingRoutes.GET("/modules/:id", trainingHandlers.GetModule)
|
||||
trainingRoutes.POST("/modules", trainingHandlers.CreateModule)
|
||||
trainingRoutes.PUT("/modules/:id", trainingHandlers.UpdateModule)
|
||||
trainingRoutes.DELETE("/modules/:id", trainingHandlers.DeleteModule)
|
||||
|
||||
// Compliance Training Matrix (CTM)
|
||||
trainingRoutes.GET("/matrix", trainingHandlers.GetMatrix)
|
||||
@@ -447,6 +449,7 @@ func main() {
|
||||
trainingRoutes.POST("/assignments/:id/start", trainingHandlers.StartAssignment)
|
||||
trainingRoutes.POST("/assignments/:id/progress", trainingHandlers.UpdateAssignmentProgress)
|
||||
trainingRoutes.POST("/assignments/:id/complete", trainingHandlers.CompleteAssignment)
|
||||
trainingRoutes.PUT("/assignments/:id", trainingHandlers.UpdateAssignment)
|
||||
|
||||
// Quiz
|
||||
trainingRoutes.GET("/quiz/:moduleId", trainingHandlers.GetQuiz)
|
||||
@@ -479,6 +482,10 @@ func main() {
|
||||
c.Params = append(c.Params, gin.Param{Key: "id", Value: c.Param("mediaId")})
|
||||
trainingHandlers.PublishMedia(c)
|
||||
})
|
||||
trainingRoutes.GET("/media/:mediaId/stream", func(c *gin.Context) {
|
||||
c.Params = append(c.Params, gin.Param{Key: "id", Value: c.Param("mediaId")})
|
||||
trainingHandlers.StreamMedia(c)
|
||||
})
|
||||
|
||||
// Deadlines & Escalation
|
||||
trainingRoutes.GET("/deadlines", trainingHandlers.GetDeadlines)
|
||||
@@ -490,7 +497,30 @@ func main() {
|
||||
trainingRoutes.GET("/stats", trainingHandlers.GetStats)
|
||||
|
||||
// Certificates
|
||||
trainingRoutes.POST("/certificates/generate/:assignmentId", trainingHandlers.GenerateCertificate)
|
||||
trainingRoutes.GET("/certificates", trainingHandlers.ListCertificates)
|
||||
trainingRoutes.GET("/certificates/:id/verify", trainingHandlers.VerifyCertificate)
|
||||
trainingRoutes.GET("/certificates/:id/pdf", trainingHandlers.DownloadCertificatePDF)
|
||||
|
||||
// Training Blocks — Controls → Schulungsmodule Pipeline
|
||||
trainingRoutes.GET("/blocks", trainingHandlers.ListBlockConfigs)
|
||||
trainingRoutes.POST("/blocks", trainingHandlers.CreateBlockConfig)
|
||||
trainingRoutes.GET("/blocks/:id", trainingHandlers.GetBlockConfig)
|
||||
trainingRoutes.PUT("/blocks/:id", trainingHandlers.UpdateBlockConfig)
|
||||
trainingRoutes.DELETE("/blocks/:id", trainingHandlers.DeleteBlockConfig)
|
||||
trainingRoutes.POST("/blocks/:id/preview", trainingHandlers.PreviewBlock)
|
||||
trainingRoutes.POST("/blocks/:id/generate", trainingHandlers.GenerateBlock)
|
||||
trainingRoutes.GET("/blocks/:id/controls", trainingHandlers.GetBlockControls)
|
||||
|
||||
// Canonical Controls Browsing
|
||||
trainingRoutes.GET("/canonical/controls", trainingHandlers.ListCanonicalControls)
|
||||
trainingRoutes.GET("/canonical/meta", trainingHandlers.GetCanonicalMeta)
|
||||
|
||||
// Interactive Video (Narrator + Checkpoints)
|
||||
trainingRoutes.POST("/content/:moduleId/generate-interactive", trainingHandlers.GenerateInteractiveVideo)
|
||||
trainingRoutes.GET("/content/:moduleId/interactive-manifest", trainingHandlers.GetInteractiveManifest)
|
||||
trainingRoutes.POST("/checkpoints/:checkpointId/submit", trainingHandlers.SubmitCheckpointQuiz)
|
||||
trainingRoutes.GET("/checkpoints/progress/:assignmentId", trainingHandlers.GetCheckpointProgress)
|
||||
}
|
||||
|
||||
// Whistleblower routes - Hinweisgebersystem (HinSchG)
|
||||
|
||||
270
ai-compliance-sdk/data/ce-libraries/evidence-library-50.md
Normal file
270
ai-compliance-sdk/data/ce-libraries/evidence-library-50.md
Normal file
@@ -0,0 +1,270 @@
|
||||
Evidence Library — 50 Nachweisarten (vollständig beschrieben)
|
||||
|
||||
Jeder Nachweis dient dazu, die Wirksamkeit einer Schutzmaßnahme oder Sicherheitsanforderung nachzuweisen. Die Struktur ist so gestaltet, dass sie direkt in eine Compliance‑ oder CE‑Dokumentations‑Engine integriert werden kann.
|
||||
|
||||
Struktur eines Nachweises
|
||||
|
||||
evidence_id
|
||||
|
||||
title
|
||||
|
||||
purpose
|
||||
|
||||
verification_method
|
||||
|
||||
typical_steps
|
||||
|
||||
expected_result
|
||||
|
||||
generated_document
|
||||
|
||||
|
||||
### E01 Konstruktionsreview
|
||||
|
||||
**Purpose:** Überprüfung der sicherheitsrelevanten Konstruktion. Verification Method: Engineering Review. Typical Steps: Zeichnungen prüfen, Gefahrenstellen identifizieren, Schutzmaßnahmen bewerten. Expected Result: Konstruktion erfüllt Sicherheitsanforderungen. Generated Document: Review‑Protokoll.
|
||||
|
||||
|
||||
### E02 Sicherheitskonzept
|
||||
|
||||
**Purpose:** Dokumentation der Sicherheitsarchitektur. Verification Method: Architekturprüfung. Typical Steps: Systemgrenzen definieren, Schutzkonzept beschreiben. Expected Result: vollständiges Sicherheitskonzept. Generated Document: Sicherheitsdokument.
|
||||
|
||||
|
||||
### E03 Gefährdungsanalyse
|
||||
|
||||
**Purpose:** Identifikation aller relevanten Gefährdungen. Verification Method: strukturierte Analyse. Typical Steps: Gefährdungsliste erstellen, Risikobewertung durchführen. Expected Result: vollständige Hazard List. Generated Document: Risikoanalysebericht.
|
||||
|
||||
|
||||
### E04 Sicherheitsabstandsberechnung
|
||||
|
||||
**Purpose:** Nachweis sicherer Mindestabstände. Verification Method: mathematische Berechnung. Typical Steps: Bewegungsenergie bestimmen, Distanz berechnen. Expected Result: Mindestabstand erfüllt Anforderungen. Generated Document: Berechnungsprotokoll.
|
||||
|
||||
|
||||
### E05 Festigkeitsnachweis
|
||||
|
||||
**Purpose:** strukturelle Stabilität sicherstellen. Verification Method: statische Berechnung oder Simulation. Typical Steps: Belastungen definieren, Struktur analysieren. Expected Result: Bauteil hält Belastungen stand. Generated Document: Festigkeitsbericht.
|
||||
|
||||
|
||||
### E06 Risikoanalysebericht
|
||||
|
||||
**Purpose:** Dokumentation der Risikobeurteilung. Verification Method: Risikomodell. Typical Steps: Gefährdungen bewerten, Maßnahmen definieren. Expected Result: akzeptables Restrisiko. Generated Document: Risikobeurteilung.
|
||||
|
||||
|
||||
### E07 Architekturdiagramm
|
||||
|
||||
**Purpose:** Darstellung der Systemarchitektur. Verification Method: Systemmodellierung. Typical Steps: Komponenten und Schnittstellen beschreiben. Expected Result: nachvollziehbare Systemstruktur. Generated Document: Architekturdiagramm.
|
||||
|
||||
|
||||
### E08 Software‑Designreview
|
||||
|
||||
**Purpose:** Bewertung des Softwaredesigns. Verification Method: Entwicklerreview. Typical Steps: Architektur analysieren, Sicherheitslogik prüfen. Expected Result: robustes Design. Generated Document: Reviewbericht.
|
||||
|
||||
|
||||
### E09 Code Review
|
||||
|
||||
**Purpose:** Fehler und Sicherheitsprobleme erkennen. Verification Method: Peer Review. Typical Steps: Quellcode analysieren. Expected Result: sicherer und wartbarer Code. Generated Document: Code‑Review‑Protokoll.
|
||||
|
||||
|
||||
### E10 Sicherheitsanforderungsdokument
|
||||
|
||||
**Purpose:** Definition der Sicherheitsanforderungen. Verification Method: Dokumentationsprüfung. Typical Steps: Anforderungen sammeln und validieren. Expected Result: vollständige Security Requirements. Generated Document: Requirements Dokument.
|
||||
|
||||
|
||||
### E11 Funktionstest
|
||||
|
||||
**Purpose:** Überprüfung der Systemfunktion. Verification Method: Testfallausführung. Typical Steps: Testfälle definieren, Ergebnisse dokumentieren. Expected Result: Funktionen arbeiten korrekt. Generated Document: Testprotokoll.
|
||||
|
||||
|
||||
### E12 Integrationstest
|
||||
|
||||
**Purpose:** Zusammenspiel von Komponenten prüfen. Verification Method: Systemtests. Typical Steps: Schnittstellen testen. Expected Result: korrekte Interaktion. Generated Document: Integrationsbericht.
|
||||
|
||||
|
||||
### E13 Systemtest
|
||||
|
||||
**Purpose:** Gesamtfunktion der Maschine prüfen. Verification Method: End‑to‑End Test. Typical Steps: reale Betriebsbedingungen simulieren. Expected Result: System arbeitet stabil. Generated Document: Systemtestbericht.
|
||||
|
||||
|
||||
### E14 Sicherheitsfunktionstest
|
||||
|
||||
**Purpose:** Wirksamkeit der Sicherheitsfunktion prüfen. Verification Method: gezielte Auslösung. Typical Steps: Sicherheitsfunktion aktivieren. Expected Result: sichere Reaktion. Generated Document: Sicherheitsprotokoll.
|
||||
|
||||
|
||||
### E15 Not‑Halt Test
|
||||
|
||||
**Purpose:** Funktion des Not‑Halts sicherstellen. Verification Method: manuelle Betätigung. Typical Steps: Not‑Halt drücken, Stopzeit messen. Expected Result: Maschine stoppt sofort. Generated Document: Testbericht.
|
||||
|
||||
|
||||
### E16 Verriegelungstest
|
||||
|
||||
**Purpose:** Schutzsystem prüfen. Verification Method: mechanischer Test. Typical Steps: Tür öffnen während Betrieb. Expected Result: Maschine stoppt. Generated Document: Prüfprotokoll.
|
||||
|
||||
|
||||
### E17 Fault Injection Test
|
||||
|
||||
**Purpose:** Fehlerreaktionen prüfen. Verification Method: simulierte Fehler. Typical Steps: Sensorfehler auslösen. Expected Result: sichere Reaktion. Generated Document: Testreport.
|
||||
|
||||
|
||||
### E18 Simulationstest
|
||||
|
||||
**Purpose:** Verhalten im Modell prüfen. Verification Method: Simulation. Typical Steps: Szenarien simulieren. Expected Result: korrektes Verhalten. Generated Document: Simulationsbericht.
|
||||
|
||||
|
||||
### E19 Lasttest
|
||||
|
||||
**Purpose:** Verhalten unter Last prüfen. Verification Method: Belastungstest. Typical Steps: maximale Last anwenden. Expected Result: System bleibt stabil. Generated Document: Lasttestbericht.
|
||||
|
||||
|
||||
### E20 Stresstest
|
||||
|
||||
**Purpose:** Extrembedingungen prüfen. Verification Method: Überlastsimulation. Typical Steps: Grenzwerte testen. Expected Result: System bleibt kontrollierbar. Generated Document: Stresstestbericht.
|
||||
|
||||
|
||||
### E21 Schutzleiterprüfung
|
||||
|
||||
**Purpose:** Erdung überprüfen. Verification Method: elektrische Messung. Expected Result: ausreichende Leitfähigkeit. Generated Document: Messprotokoll.
|
||||
|
||||
|
||||
### E22 Isolationsmessung
|
||||
|
||||
**Purpose:** elektrische Isolation prüfen. Verification Method: Hochspannungsmessung. Expected Result: Isolation ausreichend. Generated Document: Prüfbericht.
|
||||
|
||||
|
||||
### E23 Hochspannungsprüfung
|
||||
|
||||
**Purpose:** elektrische Sicherheit testen. Verification Method: HV‑Test. Expected Result: keine Durchschläge. Generated Document: Testprotokoll.
|
||||
|
||||
|
||||
### E24 Kurzschlussprüfung
|
||||
|
||||
**Purpose:** Verhalten bei Kurzschluss prüfen. Verification Method: Simulation. Expected Result: sichere Abschaltung. Generated Document: Testbericht.
|
||||
|
||||
|
||||
### E25 Erdungsmessung
|
||||
|
||||
**Purpose:** Erdungssystem validieren. Verification Method: Widerstandsmessung. Expected Result: zulässiger Erdungswert. Generated Document: Messprotokoll.
|
||||
|
||||
|
||||
### E26 Penetration Test
|
||||
|
||||
**Purpose:** IT‑Sicherheit prüfen. Verification Method: Angriffssimulation. Expected Result: keine kritischen Schwachstellen. Generated Document: Pentest‑Report.
|
||||
|
||||
|
||||
### E27 Vulnerability Scan
|
||||
|
||||
**Purpose:** bekannte Schwachstellen erkennen. Verification Method: automatisierter Scan. Expected Result: Schwachstellenliste. Generated Document: Scanbericht.
|
||||
|
||||
|
||||
### E28 SBOM Prüfung
|
||||
|
||||
**Purpose:** Softwareabhängigkeiten prüfen. Verification Method: Komponentenliste analysieren. Expected Result: bekannte Risiken erkannt. Generated Document: SBOM‑Report.
|
||||
|
||||
|
||||
### E29 Dependency Scan
|
||||
|
||||
**Purpose:** Bibliotheken prüfen. Verification Method: CVE‑Abgleich. Expected Result: keine kritischen Abhängigkeiten. Generated Document: Scanreport.
|
||||
|
||||
|
||||
### E30 Update‑Signaturprüfung
|
||||
|
||||
**Purpose:** Authentizität von Updates prüfen. Verification Method: kryptographische Validierung. Expected Result: gültige Signatur. Generated Document: Verifikationsprotokoll.
|
||||
|
||||
|
||||
### E31 Betriebsanleitung
|
||||
|
||||
**Purpose:** sichere Nutzung dokumentieren. Verification Method: Dokumentationsprüfung. Expected Result: vollständige Anleitung. Generated Document: Handbuch.
|
||||
|
||||
|
||||
### E32 Wartungsanleitung
|
||||
|
||||
**Purpose:** sichere Wartung ermöglichen. Verification Method: Review. Expected Result: klare Wartungsprozesse. Generated Document: Wartungsdokument.
|
||||
|
||||
|
||||
### E33 Sicherheitsanweisung
|
||||
|
||||
**Purpose:** Sicherheitsregeln festlegen. Verification Method: Freigabeprozess. Expected Result: verbindliche Richtlinie. Generated Document: Sicherheitsdokument.
|
||||
|
||||
|
||||
### E34 Schulungsnachweis
|
||||
|
||||
**Purpose:** Kompetenz nachweisen. Verification Method: Teilnahmeprotokoll. Expected Result: Mitarbeiter geschult. Generated Document: Trainingszertifikat.
|
||||
|
||||
|
||||
### E35 Risikoabnahmeprotokoll
|
||||
|
||||
**Purpose:** Freigabe der Risikobeurteilung. Verification Method: Managementreview. Expected Result: Risiko akzeptiert. Generated Document: Freigabedokument.
|
||||
|
||||
|
||||
### E36 Freigabedokument
|
||||
|
||||
**Purpose:** formale Systemfreigabe. Verification Method: Genehmigungsprozess. Expected Result: System genehmigt. Generated Document: Freigabeprotokoll.
|
||||
|
||||
|
||||
### E37 Änderungsprotokoll
|
||||
|
||||
**Purpose:** Änderungen nachvollziehen. Verification Method: Change Management. Expected Result: Änderungsverlauf dokumentiert. Generated Document: Change Log.
|
||||
|
||||
|
||||
### E38 Auditbericht
|
||||
|
||||
**Purpose:** Compliance prüfen. Verification Method: Audit. Expected Result: Audit ohne kritische Abweichungen. Generated Document: Auditreport.
|
||||
|
||||
|
||||
### E39 Abnahmeprotokoll
|
||||
|
||||
**Purpose:** Endabnahme dokumentieren. Verification Method: Abnahmetest. Expected Result: System akzeptiert. Generated Document: Abnahmebericht.
|
||||
|
||||
|
||||
### E40 Prüfprotokoll
|
||||
|
||||
**Purpose:** Prüfergebnisse festhalten. Verification Method: standardisierte Tests. Expected Result: erfolgreiche Prüfung. Generated Document: Prüfprotokoll.
|
||||
|
||||
|
||||
### E41 Monitoring‑Logs
|
||||
|
||||
**Purpose:** Betriebsüberwachung. Verification Method: Loganalyse. Expected Result: keine Sicherheitsereignisse. Generated Document: Logreport.
|
||||
|
||||
|
||||
### E42 Ereignisprotokolle
|
||||
|
||||
**Purpose:** sicherheitsrelevante Ereignisse dokumentieren. Verification Method: Ereignisaufzeichnung. Expected Result: vollständige Historie. Generated Document: Ereignisbericht.
|
||||
|
||||
|
||||
### E43 Alarmberichte
|
||||
|
||||
**Purpose:** Systemalarme dokumentieren. Verification Method: Alarmanalyse. Expected Result: nachvollziehbare Alarmhistorie. Generated Document: Alarmreport.
|
||||
|
||||
|
||||
### E44 Incident‑Report
|
||||
|
||||
**Purpose:** Sicherheitsvorfall dokumentieren. Verification Method: Incident Management. Expected Result: Ursachenanalyse abgeschlossen. Generated Document: Incidentbericht.
|
||||
|
||||
|
||||
### E45 Wartungsbericht
|
||||
|
||||
**Purpose:** Wartungsarbeiten dokumentieren. Verification Method: Servicebericht. Expected Result: Wartung durchgeführt. Generated Document: Wartungsprotokoll.
|
||||
|
||||
|
||||
### E46 Redundanzprüfung
|
||||
|
||||
**Purpose:** Redundante Systeme testen. Verification Method: Failover‑Test. Expected Result: System bleibt funktionsfähig. Generated Document: Redundanzbericht.
|
||||
|
||||
|
||||
### E47 Sicherheitsvalidierung
|
||||
|
||||
**Purpose:** Gesamtvalidierung der Sicherheitsfunktionen. Verification Method: kombinierte Tests. Expected Result: Sicherheitsanforderungen erfüllt. Generated Document: Validierungsbericht.
|
||||
|
||||
|
||||
### E48 Cyber‑Security‑Audit
|
||||
|
||||
**Purpose:** IT‑Sicherheitsprüfung. Verification Method: Auditverfahren. Expected Result: Sicherheitsniveau bestätigt. Generated Document: Auditbericht.
|
||||
|
||||
|
||||
### E49 Konfigurationsprüfung
|
||||
|
||||
**Purpose:** Systemkonfiguration prüfen. Verification Method: Konfigurationsreview. Expected Result: sichere Einstellungen. Generated Document: Konfigurationsbericht.
|
||||
|
||||
|
||||
### E50 Endabnahmebericht
|
||||
|
||||
**Purpose:** finale Systemfreigabe. Verification Method: Abschlussprüfung. Expected Result: Maschine freigegeben. Generated Document: Endabnahmebericht.
|
||||
|
||||
3531
ai-compliance-sdk/data/ce-libraries/hazard-library-150.md
Normal file
3531
ai-compliance-sdk/data/ce-libraries/hazard-library-150.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
Lifecycle Phases Library — 25 Lebensphasen vollständig beschrieben
|
||||
|
||||
Diese Bibliothek beschreibt die typischen Lebensphasen einer Maschine oder Anlage über ihren gesamten Lebenszyklus. Die Struktur ist so ausgelegt, dass sie direkt in eine CE‑Risikobeurteilungs‑ oder Compliance‑Engine integriert werden kann.
|
||||
|
||||
Struktur pro Lebensphase
|
||||
|
||||
phase_id
|
||||
|
||||
title
|
||||
|
||||
description
|
||||
|
||||
typical_activities
|
||||
|
||||
typical_hazards
|
||||
|
||||
involved_roles
|
||||
|
||||
safety_focus
|
||||
|
||||
|
||||
### LP01 Transport
|
||||
|
||||
**Description:** Bewegung der Maschine oder einzelner Komponenten vom Hersteller zum Installationsort. Typical Activities: - Verladen - Transport per LKW / Kran - Entladen Typical Hazards: - Absturz von Lasten - Quetschungen - Kollisionen Involved Roles: - Logistikpersonal - Kranführer Safety Focus: - Sichere Lastaufnahme - Transportverriegelungen
|
||||
|
||||
|
||||
### LP02 Lagerung
|
||||
|
||||
**Description:** Zwischenlagerung der Maschine oder Baugruppen vor Installation. Typical Activities: - Lagerhaltung - Schutz vor Umwelteinflüssen Typical Hazards: - Instabilität - Korrosion Involved Roles: - Logistikpersonal Safety Focus: - sichere Lagerposition - Schutzabdeckung
|
||||
|
||||
|
||||
### LP03 Montage
|
||||
|
||||
**Description:** Zusammenbau einzelner Komponenten zur vollständigen Maschine. Typical Activities: - mechanische Montage - Verschraubungen Typical Hazards: - Quetschungen - Absturz von Bauteilen Involved Roles: - Monteure Safety Focus: - sichere Montageverfahren
|
||||
|
||||
|
||||
### LP04 Installation
|
||||
|
||||
**Description:** Aufstellung und Anschluss der Maschine am Einsatzort. Typical Activities: - Positionierung - Anschluss von Energiequellen Typical Hazards: - elektrische Gefahren - mechanische Belastungen Involved Roles: - Installationspersonal - Elektriker Safety Focus: - korrekte Installation
|
||||
|
||||
|
||||
### LP05 Inbetriebnahme
|
||||
|
||||
**Description:** Erstmaliges Starten der Maschine nach Installation. Typical Activities: - Funktionstests - Parametrierung Typical Hazards: - unerwartete Bewegungen Involved Roles: - Servicetechniker - Einrichter Safety Focus: - sichere Testbedingungen
|
||||
|
||||
|
||||
### LP06 Parametrierung
|
||||
|
||||
**Description:** Konfiguration von Maschinenparametern und Steuerungswerten. Typical Activities: - Softwareeinstellungen - Prozessparameter definieren Typical Hazards: - Fehlkonfiguration Involved Roles: - Einrichter - Softwareingenieur Safety Focus: - sichere Parameter
|
||||
|
||||
|
||||
### LP07 Einrichten
|
||||
|
||||
**Description:** Vorbereitung der Maschine für einen Produktionsauftrag. Typical Activities: - Werkzeugwechsel - Prozessanpassung Typical Hazards: - Quetschungen - unerwartete Bewegungen Involved Roles: - Einrichter Safety Focus: - sichere Einrichtverfahren
|
||||
|
||||
|
||||
### LP08 Normalbetrieb
|
||||
|
||||
**Description:** regulärer Produktionsbetrieb. Typical Activities: - Maschinenbedienung - Prozessüberwachung Typical Hazards: - mechanische Gefahren Involved Roles: - Maschinenbediener Safety Focus: - sichere Bedienung
|
||||
|
||||
|
||||
### LP09 Automatikbetrieb
|
||||
|
||||
**Description:** vollautomatisierter Betrieb ohne direkte Bedienerinteraktion. Typical Activities: - automatisierte Produktionszyklen Typical Hazards: - Kollisionen Involved Roles: - Anlagenfahrer Safety Focus: - sichere Steuerungslogik
|
||||
|
||||
|
||||
### LP10 Handbetrieb
|
||||
|
||||
**Description:** Betrieb der Maschine im manuellen Modus. Typical Activities: - manuelle Bewegungssteuerung Typical Hazards: - direkte Nähe zu beweglichen Teilen Involved Roles: - Einrichter Safety Focus: - reduzierte Geschwindigkeit
|
||||
|
||||
|
||||
### LP11 Teach-Modus
|
||||
|
||||
**Description:** Programmierung von Bewegungsabläufen. Typical Activities: - Roboterprogrammierung Typical Hazards: - unerwartete Bewegungen Involved Roles: - Programmierer Safety Focus: - Zustimmschalter
|
||||
|
||||
|
||||
### LP12 Produktionsstart
|
||||
|
||||
**Description:** Übergang vom Stillstand zur Produktion. Typical Activities: - Start des Produktionszyklus Typical Hazards: - unerwarteter Start Involved Roles: - Maschinenbediener Safety Focus: - Startfreigabe
|
||||
|
||||
|
||||
### LP13 Produktionsstopp
|
||||
|
||||
**Description:** planmäßiges Stoppen der Produktion. Typical Activities: - Maschinenabschaltung Typical Hazards: - Restbewegungen Involved Roles: - Maschinenbediener Safety Focus: - kontrolliertes Stoppen
|
||||
|
||||
|
||||
### LP14 Prozessüberwachung
|
||||
|
||||
**Description:** Überwachung des laufenden Produktionsprozesses. Typical Activities: - Anzeigenkontrolle Typical Hazards: - Fehlinterpretation Involved Roles: - Anlagenfahrer Safety Focus: - Alarmüberwachung
|
||||
|
||||
|
||||
### LP15 Reinigung
|
||||
|
||||
**Description:** Entfernen von Produktionsrückständen. Typical Activities: - manuelle Reinigung Typical Hazards: - Kontakt mit gefährlichen Bereichen Involved Roles: - Reinigungspersonal Safety Focus: - Energieabschaltung
|
||||
|
||||
|
||||
### LP16 Wartung
|
||||
|
||||
**Description:** planmäßige Wartung der Maschine. Typical Activities: - Austausch von Verschleißteilen Typical Hazards: - unerwarteter Wiederanlauf Involved Roles: - Wartungstechniker Safety Focus: - Lockout Tagout
|
||||
|
||||
|
||||
### LP17 Inspektion
|
||||
|
||||
**Description:** Überprüfung des Maschinenzustands. Typical Activities: - Sichtprüfung Typical Hazards: - Zugang zu Gefahrenbereichen Involved Roles: - Wartungspersonal Safety Focus: - sichere Zugänge
|
||||
|
||||
|
||||
### LP18 Kalibrierung
|
||||
|
||||
**Description:** Justierung von Sensoren oder Messsystemen. Typical Activities: - Kalibrierprozesse Typical Hazards: - Fehlmessungen Involved Roles: - Techniker Safety Focus: - präzise Einstellung
|
||||
|
||||
|
||||
### LP19 Störungsbeseitigung
|
||||
|
||||
**Description:** Diagnose und Behebung von Störungen. Typical Activities: - Fehleranalyse Typical Hazards: - unerwartete Bewegungen Involved Roles: - Servicetechniker Safety Focus: - sichere Diagnoseverfahren
|
||||
|
||||
|
||||
### LP20 Reparatur
|
||||
|
||||
**Description:** Austausch oder Reparatur beschädigter Komponenten. Typical Activities: - Komponentenwechsel Typical Hazards: - mechanische Gefahren Involved Roles: - Wartungstechniker Safety Focus: - sichere Reparaturverfahren
|
||||
|
||||
|
||||
### LP21 Umrüstung
|
||||
|
||||
**Description:** Anpassung der Maschine für neue Produkte. Typical Activities: - Werkzeugwechsel Typical Hazards: - Quetschungen Involved Roles: - Einrichter Safety Focus: - sichere Umrüstprozesse
|
||||
|
||||
|
||||
### LP22 Software-Update
|
||||
|
||||
**Description:** Aktualisierung der Steuerungssoftware. Typical Activities: - Firmwareupdate Typical Hazards: - Fehlkonfiguration Involved Roles: - Softwareingenieur Safety Focus: - sichere Updateprozesse
|
||||
|
||||
|
||||
### LP23 Fernwartung
|
||||
|
||||
**Description:** Wartung über Remotezugriff. Typical Activities: - Diagnose Typical Hazards: - unautorisierter Zugriff Involved Roles: - Fernwartungsdienst Safety Focus: - sichere Authentifizierung
|
||||
|
||||
|
||||
### LP24 Außerbetriebnahme
|
||||
|
||||
**Description:** dauerhafte Stilllegung der Maschine. Typical Activities: - Abschaltung Typical Hazards: - Restenergie Involved Roles: - Betreiber Safety Focus: - sichere Abschaltung
|
||||
|
||||
|
||||
### LP25 Demontage / Entsorgung
|
||||
|
||||
**Description:** Zerlegung und Entsorgung der Maschine. Typical Activities: - Demontage Typical Hazards: - Absturz von Bauteilen Involved Roles: - Demontagepersonal Safety Focus: - sichere Demontageverfahren
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
Maschinenkomponenten- und Energiequellenbibliothek
|
||||
|
||||
Dieses Dokument enthält zwei zentrale Bibliotheken für eine CE-Risikobeurteilungs- oder Compliance-Engine:
|
||||
|
||||
Maschinenkomponentenbibliothek (≈120 typische Komponenten)
|
||||
|
||||
Energiequellenbibliothek (≈20 Energiearten)
|
||||
|
||||
Diese Bibliotheken ermöglichen eine automatische Zuordnung von Gefährdungen, sobald ein Benutzer eine Maschine, Anlage oder Produktionslinie beschreibt.
|
||||
|
||||
1. Maschinenkomponentenbibliothek (120 Komponenten)
|
||||
|
||||
Struktur pro Komponente
|
||||
|
||||
component_id
|
||||
|
||||
name
|
||||
|
||||
category
|
||||
|
||||
description
|
||||
|
||||
typical_hazards
|
||||
|
||||
typical_energy_sources
|
||||
|
||||
Mechanische Komponenten
|
||||
|
||||
|
||||
### C001 Roboterarm C002 Roboter-Greifer C003 Förderband C004 Rollenförderer C005 Kettenförderer C006 Hubtisch C007 Linearachse C008 Rotationsachse C009 Pressmechanismus C010 Stanzwerkzeug C011 Schneidwerkzeug C012 Fräswerkzeug C013 Bohrspindel C014 Schleifaggregat C015 Werkzeugwechsler C016 Werkstückhalter C017 Spannvorrichtung C018 Greifersystem C019 Manipulator C020 Pick-and-Place-Einheit
|
||||
|
||||
Strukturelle Komponenten
|
||||
|
||||
|
||||
### C021 Maschinenrahmen C022 Schutzgehäuse C023 Schutzgitter C024 Schutzhaube C025 Zugangstür C026 Wartungsklappe C027 Podest C028 Leiter C029 Plattform C030 Fundament
|
||||
|
||||
Antriebskomponenten
|
||||
|
||||
|
||||
### C031 Elektromotor C032 Servomotor C033 Schrittmotor C034 Getriebe C035 Kupplung C036 Bremse C037 Riemenantrieb C038 Zahnradantrieb C039 Kettenantrieb C040 Spindelantrieb
|
||||
|
||||
Hydraulische Komponenten
|
||||
|
||||
|
||||
### C041 Hydraulikpumpe C042 Hydraulikzylinder C043 Hydraulikventil C044 Hydraulikleitung C045 Hydraulikaggregat C046 Hydraulikspeicher C047 Hydraulikfilter C048 Druckregler C049 Drucksensor C050 Hydraulikverteiler
|
||||
|
||||
Pneumatische Komponenten
|
||||
|
||||
|
||||
### C051 Pneumatikzylinder C052 Pneumatikventil C053 Druckluftleitung C054 Druckregler C055 Luftfilter C056 Kompressor C057 Pneumatikverteiler C058 Vakuumerzeuger C059 Sauggreifer C060 Drucksensor Pneumatik
|
||||
|
||||
Elektrische Komponenten
|
||||
|
||||
|
||||
### C061 Schaltschrank C062 Stromversorgung C063 Netzteil C064 Sicherung C065 Leistungsschalter C066 Relais C067 Sicherheitsrelais C068 Transformator C069 Steckverbinder C070 Kabelsystem
|
||||
|
||||
Steuerungskomponenten
|
||||
|
||||
|
||||
### C071 SPS C072 Sicherheits-SPS C073 Industrie-PC C074 Embedded Controller C075 Feldbusmodul C076 I/O-Modul C077 HMI-Bedienpanel C078 Touchpanel C079 Steuerungssoftware C080 Visualisierungssystem
|
||||
|
||||
Sensorik
|
||||
|
||||
|
||||
### C081 Positionssensor C082 Näherungssensor C083 Lichtschranke C084 Laserscanner C085 Kamerasystem C086 Drucksensor C087 Temperatursensor C088 Vibrationssensor C089 Drehzahlsensor C090 Kraftsensor
|
||||
|
||||
Aktorik
|
||||
|
||||
|
||||
### C091 Magnetventil C092 Linearantrieb C093 Rotationsantrieb C094 Servoregler C095 Ventilsteuerung C096 Aktuatorsteuerung C097 Motorsteuerung C098 Schütz C099 Relaisausgang C100 Leistungsmodul
|
||||
|
||||
Sicherheitskomponenten
|
||||
|
||||
|
||||
### C101 Not-Halt Taster C102 Sicherheitslichtgitter C103 Sicherheitslaserscanner C104 Sicherheitsmatte C105 Türverriegelung C106 Zweihandbedienung C107 Zustimmschalter C108 Sicherheitsrelais C109 Sicherheitssteuerung C110 Sicherheitsüberwachung
|
||||
|
||||
IT- und Netzwerkkomponenten
|
||||
|
||||
|
||||
### C111 Industrial Switch C112 Router C113 Firewall C114 Edge Computer C115 Gateway C116 Remote-Service-Gateway C117 Datenspeicher C118 Cloud-Schnittstelle C119 Netzwerkinterface C120 Diagnosemodul
|
||||
|
||||
2. Energiequellenbibliothek (20 Energiearten)
|
||||
|
||||
Struktur
|
||||
|
||||
energy_id
|
||||
|
||||
name
|
||||
|
||||
description
|
||||
|
||||
typical_components
|
||||
|
||||
typical_hazards
|
||||
|
||||
|
||||
### E01 Mechanische Energie Beschreibung: Bewegungsenergie durch rotierende oder lineare Bewegung.
|
||||
|
||||
|
||||
### E02 Elektrische Energie Beschreibung: Energie durch elektrische Spannung oder Stromfluss.
|
||||
|
||||
|
||||
### E03 Hydraulische Energie Beschreibung: Energie durch Flüssigkeitsdruck.
|
||||
|
||||
|
||||
### E04 Pneumatische Energie Beschreibung: Energie durch Druckluft.
|
||||
|
||||
|
||||
### E05 Thermische Energie Beschreibung: Energie in Form von Wärme.
|
||||
|
||||
|
||||
### E06 Chemische Energie Beschreibung: Energie durch chemische Reaktionen.
|
||||
|
||||
|
||||
### E07 Potenzielle Energie Beschreibung: Energie durch Höhenlage oder gespeicherte Last.
|
||||
|
||||
|
||||
### E08 Kinetische Energie Beschreibung: Energie durch Bewegung von Körpern.
|
||||
|
||||
|
||||
### E09 Strahlungsenergie Beschreibung: Energie durch elektromagnetische Strahlung.
|
||||
|
||||
|
||||
### E10 Laserenergie Beschreibung: konzentrierte optische Strahlung.
|
||||
|
||||
|
||||
### E11 Magnetische Energie Beschreibung: Energie durch magnetische Felder.
|
||||
|
||||
|
||||
### E12 Schallenergie Beschreibung: Energie durch akustische Wellen.
|
||||
|
||||
|
||||
### E13 Vibrationsenergie Beschreibung: Energie durch mechanische Schwingungen.
|
||||
|
||||
|
||||
### E14 Druckenergie Beschreibung: Energie durch gespeicherte Druckkräfte.
|
||||
|
||||
|
||||
### E15 Federenergie Beschreibung: Energie gespeichert in elastischen Komponenten.
|
||||
|
||||
|
||||
### E16 Rotationsenergie Beschreibung: Energie rotierender Bauteile.
|
||||
|
||||
|
||||
### E17 Softwaregesteuerte Energie Beschreibung: Energieflüsse ausgelöst durch Steuerungssoftware.
|
||||
|
||||
|
||||
### E18 Cyber-physische Energie Beschreibung: physische Aktionen ausgelöst durch digitale Systeme.
|
||||
|
||||
|
||||
### E19 KI-gesteuerte Energie Beschreibung: Energieflüsse gesteuert durch autonome Entscheidungslogik.
|
||||
|
||||
|
||||
### E20 Restenergie Beschreibung: gespeicherte Energie nach Abschaltung (z. B. Kondensatoren, Druckspeicher).
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
Protection Measures Library — 200 Schutzmaßnahmen vollständig beschrieben
|
||||
|
||||
Diese Bibliothek beschreibt typische Schutzmaßnahmen für Maschinen, Anlagen, Software und cyber‑physische Systeme. Sie ist so strukturiert, dass sie direkt in eine CE‑Risikobeurteilungs‑Engine integriert werden kann.
|
||||
|
||||
Struktur pro Maßnahme
|
||||
|
||||
measure_id
|
||||
|
||||
title
|
||||
|
||||
category
|
||||
|
||||
description
|
||||
|
||||
typical_application
|
||||
|
||||
typical_verification
|
||||
|
||||
Die Maßnahmen sind in drei Hauptgruppen gegliedert: 1. Inherently Safe Design (konstruktive Maßnahmen) 2. Technische Schutzmaßnahmen 3. Benutzerinformation / organisatorische Maßnahmen
|
||||
|
||||
1 Inherently Safe Design (M001–M050)
|
||||
|
||||
|
||||
### M001 Gefahrenstelle konstruktiv eliminieren Description: Gefährliche Bewegung oder Energiequelle vollständig entfernen. Application: Redesign der Mechanik. Verification: Designreview.
|
||||
|
||||
|
||||
### M002 Bewegungsenergie reduzieren Description: Reduzierung kinetischer Energie bewegter Teile. Application: geringere Geschwindigkeiten. Verification: Berechnung.
|
||||
|
||||
|
||||
### M003 Geschwindigkeit begrenzen Description: Begrenzung maximaler Bewegungsgeschwindigkeit. Application: Roboter oder Fördertechnik. Verification: Systemtest.
|
||||
|
||||
|
||||
### M004 Kraft begrenzen Description: Begrenzung mechanischer Kräfte. Application: kollaborative Systeme. Verification: Kraftmessung.
|
||||
|
||||
|
||||
### M005 Sicherheitsabstände vergrößern Description: Abstand zwischen Mensch und Gefahrenzone erhöhen. Application: Layoutänderung. Verification: Distanzmessung.
|
||||
|
||||
|
||||
### M006 Kinematik ändern Description: Mechanische Bewegung so ändern, dass Gefahr reduziert wird. Application: Gelenkdesign. Verification: Simulation.
|
||||
|
||||
|
||||
### M007 Rotationsbewegung vermeiden Description: gefährliche Drehbewegungen eliminieren. Application: alternative Mechanik. Verification: Designprüfung.
|
||||
|
||||
|
||||
### M008 Scharfe Kanten entfernen Description: Abrunden oder Abdecken von Kanten. Application: Gehäuse. Verification: Sichtprüfung.
|
||||
|
||||
|
||||
### M009 sichere Materialwahl Description: Materialien mit geringer Bruchgefahr verwenden. Application: Strukturteile. Verification: Materialprüfung.
|
||||
|
||||
|
||||
### M010 Gewicht reduzieren Description: Verringerung der Masse beweglicher Teile. Application: Greifer oder Arme. Verification: Berechnung.
|
||||
|
||||
|
||||
### M011 redundante Konstruktion Description: Doppelte mechanische Sicherheit. Application: Tragstrukturen. Verification: Belastungstest.
|
||||
|
||||
|
||||
### M012 mechanische Begrenzung Description: Endanschläge begrenzen Bewegungsbereich. Application: Linearachsen. Verification: Funktionsprüfung.
|
||||
|
||||
|
||||
### M013 sichere Geometrie Description: Gestaltung verhindert Einklemmen. Application: Spaltgrößen. Verification: Designreview.
|
||||
|
||||
|
||||
### M014 stabile Konstruktion Description: Struktur verhindert Umkippen. Application: Maschinenrahmen. Verification: Stabilitätsberechnung.
|
||||
|
||||
|
||||
### M015 kollisionsfreie Bewegungsbahnen Description: Bewegungsplanung vermeidet Kollision. Application: Robotik. Verification: Simulation.
|
||||
|
||||
|
||||
### M016 sichere Greifer Description: Greifer verlieren Werkstück nicht. Application: Pick‑and‑Place. Verification: Belastungstest.
|
||||
|
||||
|
||||
### M017 sichere Halterungen Description: Bauteile fest fixieren. Application: Montagevorrichtungen. Verification: Inspektion.
|
||||
|
||||
|
||||
### M018 sichere Werkstückaufnahme Description: stabile Fixierung. Application: CNC‑Maschinen. Verification: Belastungstest.
|
||||
|
||||
|
||||
### M019 sichere Lageführung Description: Führung verhindert Fehlposition. Application: Fördertechnik. Verification: Test.
|
||||
|
||||
|
||||
### M020 sichere Lastführung Description: Last wird stabil transportiert. Application: Hebesysteme. Verification: Simulation.
|
||||
|
||||
|
||||
### M021 sichere Kraftübertragung Description: Vermeidung von Überlast. Application: Getriebe. Verification: Berechnung.
|
||||
|
||||
|
||||
### M022 sichere Energieübertragung Description: Energiefluss kontrollieren. Application: Motorsteuerung. Verification: Test.
|
||||
|
||||
|
||||
### M023 sichere Hydraulikauslegung Description: Drucksystem korrekt dimensionieren. Verification: Drucktest.
|
||||
|
||||
|
||||
### M024 sichere Pneumatikauslegung Description: sichere Druckbereiche definieren. Verification: Drucktest.
|
||||
|
||||
|
||||
### M025 sichere thermische Auslegung Description: Überhitzung verhindern. Verification: Temperaturmessung.
|
||||
|
||||
|
||||
### M026 Temperaturbegrenzung Description: maximal zulässige Temperatur definieren. Verification: Sensorprüfung.
|
||||
|
||||
|
||||
### M027 passive Kühlung Description: Wärmeabführung ohne aktive Systeme. Verification: Temperaturtest.
|
||||
|
||||
|
||||
### M028 automatische Druckbegrenzung Description: Sicherheitsventil begrenzt Druck. Verification: Funktionstest.
|
||||
|
||||
|
||||
### M029 sichere Sensorposition Description: Sensoren an sicheren Stellen montieren. Verification: Review.
|
||||
|
||||
|
||||
### M030 sichere Kabelführung Description: Kabel vor Beschädigung schützen. Verification: Inspektion.
|
||||
|
||||
|
||||
### M031 sichere Leitungsführung Description: Leitungen stabil verlegen. Verification: Sichtprüfung.
|
||||
|
||||
|
||||
### M032 vibrationsarme Konstruktion Description: Schwingungen minimieren. Verification: Vibrationsmessung.
|
||||
|
||||
|
||||
### M033 ergonomische Gestaltung Description: Bedienung ergonomisch gestalten. Verification: Nutzeranalyse.
|
||||
|
||||
|
||||
### M034 gute Sichtbarkeit Description: Gefahrenbereiche sichtbar machen. Verification: Sichtprüfung.
|
||||
|
||||
|
||||
### M035 intuitive Bedienoberfläche Description: Bedienfehler reduzieren. Verification: Usability Test.
|
||||
|
||||
|
||||
### M036 sichere Mensch‑Maschine‑Interaktion Description: sichere Interaktionskonzepte. Verification: Risikoanalyse.
|
||||
|
||||
|
||||
### M037 sichere Bedienhöhe Description: ergonomische Bedienhöhe. Verification: Designprüfung.
|
||||
|
||||
|
||||
### M038 sichere Reichweiten Description: Bediener erreicht Gefahrenbereich nicht. Verification: Distanzprüfung.
|
||||
|
||||
|
||||
### M039 sichere Wartungszugänge Description: sichere Wartungspunkte. Verification: Inspektion.
|
||||
|
||||
|
||||
### M040 sichere Montagepunkte Description: stabile Montagepunkte. Verification: Belastungstest.
|
||||
|
||||
|
||||
### M041 sichere Demontagepunkte Description: sichere Demontage ermöglichen. Verification: Test.
|
||||
|
||||
|
||||
### M042 sichere Servicezugänge Description: Servicebereiche sicher gestalten. Verification: Inspektion.
|
||||
|
||||
|
||||
### M043 sichere Software‑Fallbacks Description: definierter Zustand bei Fehler. Verification: Test.
|
||||
|
||||
|
||||
### M044 deterministische Steuerungslogik Description: klar definierte Zustände. Verification: Code Review.
|
||||
|
||||
|
||||
### M045 definierte Zustandsmaschine Description: klar strukturierte Steuerungszustände. Verification: Simulation.
|
||||
|
||||
|
||||
### M046 sichere Restart‑Logik Description: Neustart nur nach Freigabe. Verification: Funktionstest.
|
||||
|
||||
|
||||
### M047 sichere Fehlermodi Description: Fehler führt zu sicherem Zustand. Verification: Fault Test.
|
||||
|
||||
|
||||
### M048 sichere Energieabschaltung Description: Energiequellen trennen. Verification: Test.
|
||||
|
||||
|
||||
### M049 sichere Energieentladung Description: gespeicherte Energie abbauen. Verification: Messung.
|
||||
|
||||
|
||||
### M050 sichere Notzustände Description: Maschine geht in sicheren Zustand. Verification: Sicherheitsprüfung.
|
||||
|
||||
2 Technische Schutzmaßnahmen (M051–M140)
|
||||
|
||||
|
||||
### M051 feste trennende Schutzeinrichtung Description: mechanische Barriere verhindert Zugriff. Verification: Sichtprüfung.
|
||||
|
||||
|
||||
### M052 bewegliche Schutzeinrichtung Description: Tür oder Haube schützt Gefahrenbereich. Verification: Funktionstest.
|
||||
|
||||
|
||||
### M053 verriegelte Schutztür Description: Maschine stoppt beim Öffnen. Verification: Verriegelungstest.
|
||||
|
||||
|
||||
### M054 Lichtgitter Description: Unterbricht Maschine bei Zutritt. Verification: Sicherheitstest.
|
||||
|
||||
|
||||
### M055 Laserscanner Description: überwacht Gefahrenzone. Verification: Funktionsprüfung.
|
||||
|
||||
|
||||
### M056 Zweihandbedienung Description: beide Hände erforderlich. Verification: Test.
|
||||
|
||||
|
||||
### M057 Zustimmschalter Description: Bediener muss Schalter aktiv halten. Verification: Test.
|
||||
|
||||
|
||||
### M058 Not‑Halt Description: sofortige Maschinenabschaltung. Verification: Not‑Halt Test.
|
||||
|
||||
|
||||
### M059 Sicherheitsrelais Description: sichere Signalverarbeitung. Verification: Funktionsprüfung.
|
||||
|
||||
|
||||
### M060 sichere SPS Description: Steuerung mit Sicherheitsfunktionen. Verification: Validierung.
|
||||
|
||||
… (Maßnahmen M061–M139 folgen in gleicher Struktur)
|
||||
|
||||
|
||||
### M140 sichere Abschaltung bei Fehler Description: System stoppt automatisch. Verification: Fehler‑Simulation.
|
||||
|
||||
3 Benutzerinformation / organisatorische Maßnahmen (M141–M200)
|
||||
|
||||
|
||||
### M141 Warnhinweis Description: Warnung vor Gefahren. Verification: Dokumentenprüfung.
|
||||
|
||||
|
||||
### M142 Gefahrenkennzeichnung Description: visuelle Kennzeichnung. Verification: Inspektion.
|
||||
|
||||
|
||||
### M143 Betriebsanweisung Description: sichere Bedienung beschreiben. Verification: Dokumentenreview.
|
||||
|
||||
|
||||
### M144 Wartungsanweisung Description: Wartungsprozesse dokumentieren. Verification: Review.
|
||||
|
||||
|
||||
### M145 Reinigungsanweisung Description: sichere Reinigung beschreiben. Verification: Review.
|
||||
|
||||
|
||||
### M146 Schulungsprogramm Description: Mitarbeiterschulung. Verification: Trainingsnachweis.
|
||||
|
||||
|
||||
### M147 Sicherheitsunterweisung Description: jährliche Sicherheitsunterweisung. Verification: Protokoll.
|
||||
|
||||
|
||||
### M148 Bedienungsanleitung Description: vollständige Maschinenanleitung. Verification: Dokumentenprüfung.
|
||||
|
||||
|
||||
### M149 Servicehandbuch Description: Anleitung für Techniker. Verification: Review.
|
||||
|
||||
|
||||
### M150 Notfallanweisung Description: Verhalten im Notfall. Verification: Audit.
|
||||
|
||||
… (Maßnahmen M151–M199 folgen in gleicher Struktur)
|
||||
|
||||
|
||||
### M200 Notfallkontaktliste Description: Kontaktinformationen für Notfälle. Verification: Dokumentenprüfung.
|
||||
|
||||
322
ai-compliance-sdk/data/ce-libraries/roles-library-20.md
Normal file
322
ai-compliance-sdk/data/ce-libraries/roles-library-20.md
Normal file
@@ -0,0 +1,322 @@
|
||||
Roles Library — 20 Rollen vollständig beschrieben
|
||||
|
||||
Diese Rollenbibliothek beschreibt typische beteiligte Personengruppen bei Planung, Betrieb, Wartung und Prüfung von Maschinen oder Anlagen. Die Struktur ist so gestaltet, dass sie direkt in eine Compliance‑, CE‑ oder Risikobeurteilungs‑Engine integriert werden kann.
|
||||
|
||||
Struktur pro Rolle
|
||||
|
||||
role_id
|
||||
|
||||
title
|
||||
|
||||
description
|
||||
|
||||
primary_responsibilities
|
||||
|
||||
required_training
|
||||
|
||||
allowed_access
|
||||
|
||||
typical_lifecycle_phases
|
||||
|
||||
safety_relevance
|
||||
|
||||
|
||||
### R01 Maschinenbediener (Operator)
|
||||
|
||||
**Description:** Person, die die Maschine im täglichen Betrieb bedient.
|
||||
|
||||
**Primary Responsibilities:** - Starten und Stoppen der Maschine - Überwachung des Produktionsprozesses - Meldung von Störungen
|
||||
|
||||
**Required Training:** - Bedienerschulung - Sicherheitsunterweisung
|
||||
|
||||
**Allowed Access:** - Bedienpanel - Start/Stop Funktionen
|
||||
|
||||
**Typical Lifecycle Phases:** Normalbetrieb, Produktionsstart, Produktionsstopp
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R02 Einrichter (Setup Technician)
|
||||
|
||||
**Description:** Fachpersonal, das Maschinen für neue Produktionsaufträge einrichtet.
|
||||
|
||||
**Primary Responsibilities:** - Parametrierung der Maschine - Werkzeugwechsel - Prozessoptimierung
|
||||
|
||||
**Required Training:** - Maschineneinrichtung - Sicherheitsfunktionen
|
||||
|
||||
**Allowed Access:** - Setup-Modus - Parametrierung
|
||||
|
||||
**Typical Lifecycle Phases:** Einrichten, Produktionsstart
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R03 Wartungstechniker
|
||||
|
||||
**Description:** Techniker für Inspektion, Wartung und Reparatur.
|
||||
|
||||
**Primary Responsibilities:** - Wartung - Austausch von Komponenten - Fehlerdiagnose
|
||||
|
||||
**Required Training:** - Wartungsschulung - Lockout‑Tagout Verfahren
|
||||
|
||||
**Allowed Access:** - Wartungsmodus - interne Maschinenteile
|
||||
|
||||
**Typical Lifecycle Phases:** Wartung, Reparatur
|
||||
|
||||
**Safety Relevance:** Sehr hoch
|
||||
|
||||
|
||||
### R04 Servicetechniker
|
||||
|
||||
**Description:** Externer oder interner Spezialist für technische Unterstützung.
|
||||
|
||||
**Primary Responsibilities:** - Fehleranalyse - Systemupdates - technische Beratung
|
||||
|
||||
**Required Training:** - Herstellerschulung
|
||||
|
||||
**Allowed Access:** - Servicezugang
|
||||
|
||||
**Typical Lifecycle Phases:** Reparatur, Fernwartung
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R05 Reinigungspersonal
|
||||
|
||||
**Description:** Personal für Reinigung der Anlage.
|
||||
|
||||
**Primary Responsibilities:** - Reinigung der Maschine - Entfernen von Produktionsrückständen
|
||||
|
||||
**Required Training:** - Sicherheitsunterweisung
|
||||
|
||||
**Allowed Access:** - Reinigungsmodus
|
||||
|
||||
**Typical Lifecycle Phases:** Reinigung
|
||||
|
||||
**Safety Relevance:** Mittel
|
||||
|
||||
|
||||
### R06 Produktionsleiter
|
||||
|
||||
**Description:** Verantwortlich für Produktionsplanung und -überwachung.
|
||||
|
||||
**Primary Responsibilities:** - Produktionsplanung - Prozessüberwachung
|
||||
|
||||
**Required Training:** - Produktionsmanagement
|
||||
|
||||
**Allowed Access:** - Produktionsdaten
|
||||
|
||||
**Typical Lifecycle Phases:** Normalbetrieb
|
||||
|
||||
**Safety Relevance:** Mittel
|
||||
|
||||
|
||||
### R07 Sicherheitsbeauftragter
|
||||
|
||||
**Description:** Verantwortlicher für Arbeitssicherheit.
|
||||
|
||||
**Primary Responsibilities:** - Sicherheitsüberwachung - Risikoanalysen
|
||||
|
||||
**Required Training:** - Arbeitssicherheit
|
||||
|
||||
**Allowed Access:** - Sicherheitsdokumentation
|
||||
|
||||
**Typical Lifecycle Phases:** Alle
|
||||
|
||||
**Safety Relevance:** Sehr hoch
|
||||
|
||||
|
||||
### R08 Elektriker
|
||||
|
||||
**Description:** Fachkraft für elektrische Systeme.
|
||||
|
||||
**Primary Responsibilities:** - elektrische Wartung - Fehlersuche
|
||||
|
||||
**Required Training:** - Elektrotechnische Ausbildung
|
||||
|
||||
**Allowed Access:** - Schaltschrank
|
||||
|
||||
**Typical Lifecycle Phases:** Wartung, Reparatur
|
||||
|
||||
**Safety Relevance:** Sehr hoch
|
||||
|
||||
|
||||
### R09 Softwareingenieur
|
||||
|
||||
**Description:** Entwickler für Steuerungssoftware.
|
||||
|
||||
**Primary Responsibilities:** - Softwareentwicklung - Fehlerbehebung
|
||||
|
||||
**Required Training:** - Softwareentwicklung
|
||||
|
||||
**Allowed Access:** - Steuerungssysteme
|
||||
|
||||
**Typical Lifecycle Phases:** Softwareupdate
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R10 Instandhaltungsleiter
|
||||
|
||||
**Description:** Leitung der Wartungsabteilung.
|
||||
|
||||
**Primary Responsibilities:** - Wartungsplanung - Ressourcenkoordination
|
||||
|
||||
**Required Training:** - Instandhaltungsmanagement
|
||||
|
||||
**Allowed Access:** - Wartungsberichte
|
||||
|
||||
**Typical Lifecycle Phases:** Wartung
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R11 Anlagenfahrer
|
||||
|
||||
**Description:** Bediener komplexer Anlagen.
|
||||
|
||||
**Primary Responsibilities:** - Anlagenüberwachung
|
||||
|
||||
**Required Training:** - Anlagenbedienung
|
||||
|
||||
**Allowed Access:** - Steuerungssystem
|
||||
|
||||
**Typical Lifecycle Phases:** Normalbetrieb
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R12 Qualitätssicherung
|
||||
|
||||
**Description:** Verantwortlich für Qualitätskontrollen.
|
||||
|
||||
**Primary Responsibilities:** - Produktprüfung
|
||||
|
||||
**Required Training:** - Qualitätsmanagement
|
||||
|
||||
**Allowed Access:** - Qualitätsdaten
|
||||
|
||||
**Typical Lifecycle Phases:** Produktion
|
||||
|
||||
**Safety Relevance:** Niedrig
|
||||
|
||||
|
||||
### R13 Logistikpersonal
|
||||
|
||||
**Description:** Verantwortlich für Materialtransport.
|
||||
|
||||
**Primary Responsibilities:** - Materialbereitstellung
|
||||
|
||||
**Required Training:** - Logistikprozesse
|
||||
|
||||
**Allowed Access:** - Transportbereiche
|
||||
|
||||
**Typical Lifecycle Phases:** Transport
|
||||
|
||||
**Safety Relevance:** Mittel
|
||||
|
||||
|
||||
### R14 Fremdfirma
|
||||
|
||||
**Description:** Externe Dienstleister.
|
||||
|
||||
**Primary Responsibilities:** - Spezialarbeiten
|
||||
|
||||
**Required Training:** - Sicherheitsunterweisung
|
||||
|
||||
**Allowed Access:** - begrenzte Bereiche
|
||||
|
||||
**Typical Lifecycle Phases:** Wartung
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R15 Besucher
|
||||
|
||||
**Description:** Nicht geschulte Personen.
|
||||
|
||||
**Primary Responsibilities:** - keine
|
||||
|
||||
**Required Training:** - Sicherheitseinweisung
|
||||
|
||||
**Allowed Access:** - Besucherzonen
|
||||
|
||||
**Typical Lifecycle Phases:** Betrieb
|
||||
|
||||
**Safety Relevance:** Mittel
|
||||
|
||||
|
||||
### R16 Auditor
|
||||
|
||||
**Description:** Prüfer für Compliance und Sicherheit.
|
||||
|
||||
**Primary Responsibilities:** - Audits
|
||||
|
||||
**Required Training:** - Auditmethoden
|
||||
|
||||
**Allowed Access:** - Dokumentation
|
||||
|
||||
**Typical Lifecycle Phases:** Audit
|
||||
|
||||
**Safety Relevance:** Mittel
|
||||
|
||||
|
||||
### R17 IT-Administrator
|
||||
|
||||
**Description:** Verantwortlich für IT-Systeme.
|
||||
|
||||
**Primary Responsibilities:** - Netzwerkverwaltung
|
||||
|
||||
**Required Training:** - IT-Sicherheit
|
||||
|
||||
**Allowed Access:** - IT-Infrastruktur
|
||||
|
||||
**Typical Lifecycle Phases:** Betrieb
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R18 Fernwartungsdienst
|
||||
|
||||
**Description:** Externe Remote-Spezialisten.
|
||||
|
||||
**Primary Responsibilities:** - Remote Diagnose
|
||||
|
||||
**Required Training:** - Fernwartungssysteme
|
||||
|
||||
**Allowed Access:** - Remote-Zugang
|
||||
|
||||
**Typical Lifecycle Phases:** Fernwartung
|
||||
|
||||
**Safety Relevance:** Hoch
|
||||
|
||||
|
||||
### R19 Betreiber
|
||||
|
||||
**Description:** Eigentümer oder verantwortlicher Betreiber der Anlage.
|
||||
|
||||
**Primary Responsibilities:** - Gesamtverantwortung
|
||||
|
||||
**Required Training:** - Betreiberpflichten
|
||||
|
||||
**Allowed Access:** - Managementebene
|
||||
|
||||
**Typical Lifecycle Phases:** Alle
|
||||
|
||||
**Safety Relevance:** Sehr hoch
|
||||
|
||||
|
||||
### R20 Notfallpersonal
|
||||
|
||||
**Description:** Einsatzkräfte bei Unfällen.
|
||||
|
||||
**Primary Responsibilities:** - Rettung - Erste Hilfe
|
||||
|
||||
**Required Training:** - Notfallmanagement
|
||||
|
||||
**Allowed Access:** - Notfallzugänge
|
||||
|
||||
**Typical Lifecycle Phases:** Notfall
|
||||
|
||||
**Safety Relevance:** Kritisch
|
||||
|
||||
@@ -25,7 +25,6 @@ func NewRAGHandlers(corpusVersionStore *ucca.CorpusVersionStore) *RAGHandlers {
|
||||
// AllowedCollections is the whitelist of Qdrant collections that can be queried.
|
||||
var AllowedCollections = map[string]bool{
|
||||
"bp_compliance_ce": true,
|
||||
"bp_compliance_recht": true,
|
||||
"bp_compliance_gesetze": true,
|
||||
"bp_compliance_datenschutz": true,
|
||||
"bp_compliance_gdpr": true,
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,13 +16,17 @@ import (
|
||||
type TrainingHandlers struct {
|
||||
store *training.Store
|
||||
contentGenerator *training.ContentGenerator
|
||||
blockGenerator *training.BlockGenerator
|
||||
ttsClient *training.TTSClient
|
||||
}
|
||||
|
||||
// NewTrainingHandlers creates new training handlers
|
||||
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator) *TrainingHandlers {
|
||||
func NewTrainingHandlers(store *training.Store, contentGenerator *training.ContentGenerator, blockGenerator *training.BlockGenerator, ttsClient *training.TTSClient) *TrainingHandlers {
|
||||
return &TrainingHandlers{
|
||||
store: store,
|
||||
contentGenerator: contentGenerator,
|
||||
blockGenerator: blockGenerator,
|
||||
ttsClient: ttsClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +218,33 @@ func (h *TrainingHandlers) UpdateModule(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, module)
|
||||
}
|
||||
|
||||
// DeleteModule deletes a training module
|
||||
// DELETE /sdk/v1/training/modules/:id
|
||||
func (h *TrainingHandlers) DeleteModule(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||
return
|
||||
}
|
||||
|
||||
module, err := h.store.GetModule(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if module == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteModule(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Matrix Endpoints
|
||||
// ============================================================================
|
||||
@@ -459,6 +492,48 @@ func (h *TrainingHandlers) UpdateAssignmentProgress(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": string(status), "progress": req.Progress})
|
||||
}
|
||||
|
||||
// UpdateAssignment updates assignment fields (e.g. deadline)
|
||||
// PUT /sdk/v1/training/assignments/:id
|
||||
func (h *TrainingHandlers) UpdateAssignment(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Deadline *string `json:"deadline"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Deadline != nil {
|
||||
deadline, err := time.Parse(time.RFC3339, *req.Deadline)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deadline format (use RFC3339)"})
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateAssignmentDeadline(c.Request.Context(), id, deadline); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
// CompleteAssignment marks an assignment as completed
|
||||
// POST /sdk/v1/training/assignments/:id/complete
|
||||
func (h *TrainingHandlers) CompleteAssignment(c *gin.Context) {
|
||||
@@ -1111,3 +1186,679 @@ func (h *TrainingHandlers) PreviewVideoScript(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, script)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Training Block Endpoints (Controls → Schulungsmodule)
|
||||
// ============================================================================
|
||||
|
||||
// ListBlockConfigs returns all block configs for the tenant
|
||||
// GET /sdk/v1/training/blocks
|
||||
func (h *TrainingHandlers) ListBlockConfigs(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
configs, err := h.store.ListBlockConfigs(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"blocks": configs,
|
||||
"total": len(configs),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBlockConfig creates a new block configuration
|
||||
// POST /sdk/v1/training/blocks
|
||||
func (h *TrainingHandlers) CreateBlockConfig(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
var req training.CreateBlockConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
config := &training.TrainingBlockConfig{
|
||||
TenantID: tenantID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
DomainFilter: req.DomainFilter,
|
||||
CategoryFilter: req.CategoryFilter,
|
||||
SeverityFilter: req.SeverityFilter,
|
||||
TargetAudienceFilter: req.TargetAudienceFilter,
|
||||
RegulationArea: req.RegulationArea,
|
||||
ModuleCodePrefix: req.ModuleCodePrefix,
|
||||
FrequencyType: req.FrequencyType,
|
||||
DurationMinutes: req.DurationMinutes,
|
||||
PassThreshold: req.PassThreshold,
|
||||
MaxControlsPerModule: req.MaxControlsPerModule,
|
||||
}
|
||||
|
||||
if config.FrequencyType == "" {
|
||||
config.FrequencyType = training.FrequencyAnnual
|
||||
}
|
||||
if config.DurationMinutes == 0 {
|
||||
config.DurationMinutes = 45
|
||||
}
|
||||
if config.PassThreshold == 0 {
|
||||
config.PassThreshold = 70
|
||||
}
|
||||
if config.MaxControlsPerModule == 0 {
|
||||
config.MaxControlsPerModule = 20
|
||||
}
|
||||
|
||||
if err := h.store.CreateBlockConfig(c.Request.Context(), config); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, config)
|
||||
}
|
||||
|
||||
// GetBlockConfig returns a single block config
|
||||
// GET /sdk/v1/training/blocks/:id
|
||||
func (h *TrainingHandlers) GetBlockConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// UpdateBlockConfig updates a block config
|
||||
// PUT /sdk/v1/training/blocks/:id
|
||||
func (h *TrainingHandlers) UpdateBlockConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.store.GetBlockConfig(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "block config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req training.UpdateBlockConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
config.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
config.Description = *req.Description
|
||||
}
|
||||
if req.DomainFilter != nil {
|
||||
config.DomainFilter = *req.DomainFilter
|
||||
}
|
||||
if req.CategoryFilter != nil {
|
||||
config.CategoryFilter = *req.CategoryFilter
|
||||
}
|
||||
if req.SeverityFilter != nil {
|
||||
config.SeverityFilter = *req.SeverityFilter
|
||||
}
|
||||
if req.TargetAudienceFilter != nil {
|
||||
config.TargetAudienceFilter = *req.TargetAudienceFilter
|
||||
}
|
||||
if req.MaxControlsPerModule != nil {
|
||||
config.MaxControlsPerModule = *req.MaxControlsPerModule
|
||||
}
|
||||
if req.DurationMinutes != nil {
|
||||
config.DurationMinutes = *req.DurationMinutes
|
||||
}
|
||||
if req.PassThreshold != nil {
|
||||
config.PassThreshold = *req.PassThreshold
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
config.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.store.UpdateBlockConfig(c.Request.Context(), config); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// DeleteBlockConfig deletes a block config
|
||||
// DELETE /sdk/v1/training/blocks/:id
|
||||
func (h *TrainingHandlers) DeleteBlockConfig(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteBlockConfig(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// PreviewBlock performs a dry run showing matching controls and proposed roles
|
||||
// POST /sdk/v1/training/blocks/:id/preview
|
||||
func (h *TrainingHandlers) PreviewBlock(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := h.blockGenerator.Preview(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, preview)
|
||||
}
|
||||
|
||||
// GenerateBlock runs the full generation pipeline
|
||||
// POST /sdk/v1/training/blocks/:id/generate
|
||||
func (h *TrainingHandlers) GenerateBlock(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req training.GenerateBlockRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// Defaults are fine
|
||||
req.Language = "de"
|
||||
req.AutoMatrix = true
|
||||
}
|
||||
|
||||
result, err := h.blockGenerator.Generate(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetBlockControls returns control links for a block config
|
||||
// GET /sdk/v1/training/blocks/:id/controls
|
||||
func (h *TrainingHandlers) GetBlockControls(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid block config ID"})
|
||||
return
|
||||
}
|
||||
|
||||
links, err := h.store.GetControlLinksForBlock(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": links,
|
||||
"total": len(links),
|
||||
})
|
||||
}
|
||||
|
||||
// ListCanonicalControls returns filtered canonical controls for browsing
|
||||
// GET /sdk/v1/training/canonical/controls
|
||||
func (h *TrainingHandlers) ListCanonicalControls(c *gin.Context) {
|
||||
domain := c.Query("domain")
|
||||
category := c.Query("category")
|
||||
severity := c.Query("severity")
|
||||
targetAudience := c.Query("target_audience")
|
||||
|
||||
controls, err := h.store.QueryCanonicalControls(c.Request.Context(),
|
||||
domain, category, severity, targetAudience,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"controls": controls,
|
||||
"total": len(controls),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCanonicalMeta returns aggregated metadata about canonical controls
|
||||
// GET /sdk/v1/training/canonical/meta
|
||||
func (h *TrainingHandlers) GetCanonicalMeta(c *gin.Context) {
|
||||
meta, err := h.store.GetCanonicalControlMeta(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, meta)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Media Streaming Endpoint
|
||||
// ============================================================================
|
||||
|
||||
// StreamMedia returns a redirect to a presigned URL for a media file
|
||||
// GET /sdk/v1/training/media/:mediaId/stream
|
||||
func (h *TrainingHandlers) StreamMedia(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid media ID"})
|
||||
return
|
||||
}
|
||||
|
||||
media, err := h.store.GetMedia(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if media == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.ttsClient == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "media streaming not available"})
|
||||
return
|
||||
}
|
||||
|
||||
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), media.Bucket, media.ObjectKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate streaming URL: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GenerateCertificate generates a certificate for a completed assignment
|
||||
// POST /sdk/v1/training/certificates/generate/:assignmentId
|
||||
func (h *TrainingHandlers) GenerateCertificate(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
assignment, err := h.store.GetAssignment(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if assignment.Status != training.AssignmentStatusCompleted {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "assignment is not completed"})
|
||||
return
|
||||
}
|
||||
if assignment.QuizPassed == nil || !*assignment.QuizPassed {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "quiz has not been passed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate certificate ID
|
||||
certID := uuid.New()
|
||||
if err := h.store.SetCertificateID(c.Request.Context(), assignmentID, certID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log
|
||||
userID := rbac.GetUserID(c)
|
||||
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||
TenantID: tenantID,
|
||||
UserID: &userID,
|
||||
Action: training.AuditActionCertificateIssued,
|
||||
EntityType: training.AuditEntityCertificate,
|
||||
EntityID: &certID,
|
||||
Details: map[string]interface{}{
|
||||
"assignment_id": assignmentID.String(),
|
||||
"user_name": assignment.UserName,
|
||||
"module_title": assignment.ModuleTitle,
|
||||
},
|
||||
})
|
||||
|
||||
// Reload assignment with certificate_id
|
||||
assignment, _ = h.store.GetAssignment(c.Request.Context(), assignmentID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"certificate_id": certID,
|
||||
"assignment": assignment,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadCertificatePDF generates and returns a PDF certificate
|
||||
// GET /sdk/v1/training/certificates/:id/pdf
|
||||
func (h *TrainingHandlers) DownloadCertificatePDF(c *gin.Context) {
|
||||
certID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.store.GetAssignmentByCertificateID(c.Request.Context(), certID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if assignment == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get module for title
|
||||
module, _ := h.store.GetModule(c.Request.Context(), assignment.ModuleID)
|
||||
courseName := assignment.ModuleTitle
|
||||
if module != nil {
|
||||
courseName = module.Title
|
||||
}
|
||||
|
||||
score := 0
|
||||
if assignment.QuizScore != nil {
|
||||
score = int(*assignment.QuizScore)
|
||||
}
|
||||
|
||||
issuedAt := assignment.UpdatedAt
|
||||
if assignment.CompletedAt != nil {
|
||||
issuedAt = *assignment.CompletedAt
|
||||
}
|
||||
|
||||
// Use academy PDF generator
|
||||
pdfBytes, err := academy.GenerateCertificatePDF(academy.CertificateData{
|
||||
CertificateID: certID.String(),
|
||||
UserName: assignment.UserName,
|
||||
CourseName: courseName,
|
||||
Score: score,
|
||||
IssuedAt: issuedAt,
|
||||
ValidUntil: issuedAt.AddDate(1, 0, 0),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "PDF generation failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+certID.String()[:8]+".pdf")
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
// ListCertificates returns all certificates for a tenant
|
||||
// GET /sdk/v1/training/certificates
|
||||
func (h *TrainingHandlers) ListCertificates(c *gin.Context) {
|
||||
tenantID := rbac.GetTenantID(c)
|
||||
|
||||
certificates, err := h.store.ListCertificates(c.Request.Context(), tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"certificates": certificates,
|
||||
"total": len(certificates),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Endpoints
|
||||
// ============================================================================
|
||||
|
||||
// GenerateInteractiveVideo triggers the full interactive video pipeline
|
||||
// POST /sdk/v1/training/content/:moduleId/generate-interactive
|
||||
func (h *TrainingHandlers) GenerateInteractiveVideo(c *gin.Context) {
|
||||
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||
return
|
||||
}
|
||||
|
||||
module, err := h.store.GetModule(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if module == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "module not found"})
|
||||
return
|
||||
}
|
||||
|
||||
media, err := h.contentGenerator.GenerateInteractiveVideo(c.Request.Context(), *module)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, media)
|
||||
}
|
||||
|
||||
// GetInteractiveManifest returns the interactive video manifest with checkpoints and progress
|
||||
// GET /sdk/v1/training/content/:moduleId/interactive-manifest
|
||||
func (h *TrainingHandlers) GetInteractiveManifest(c *gin.Context) {
|
||||
moduleID, err := uuid.Parse(c.Param("moduleId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get interactive video media
|
||||
mediaList, err := h.store.GetMediaForModule(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Find interactive video
|
||||
var interactiveMedia *training.TrainingMedia
|
||||
for i := range mediaList {
|
||||
if mediaList[i].MediaType == training.MediaTypeInteractiveVideo && mediaList[i].Status == training.MediaStatusCompleted {
|
||||
interactiveMedia = &mediaList[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if interactiveMedia == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no interactive video found for this module"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get checkpoints
|
||||
checkpoints, err := h.store.ListCheckpoints(c.Request.Context(), moduleID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: get assignment ID for progress
|
||||
assignmentIDStr := c.Query("assignment_id")
|
||||
|
||||
// Build manifest entries
|
||||
entries := make([]training.CheckpointManifestEntry, len(checkpoints))
|
||||
for i, cp := range checkpoints {
|
||||
// Get questions for this checkpoint
|
||||
questions, _ := h.store.GetCheckpointQuestions(c.Request.Context(), cp.ID)
|
||||
|
||||
cpQuestions := make([]training.CheckpointQuestion, len(questions))
|
||||
for j, q := range questions {
|
||||
cpQuestions[j] = training.CheckpointQuestion{
|
||||
Question: q.Question,
|
||||
Options: q.Options,
|
||||
CorrectIndex: q.CorrectIndex,
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
}
|
||||
|
||||
entry := training.CheckpointManifestEntry{
|
||||
CheckpointID: cp.ID,
|
||||
Index: cp.CheckpointIndex,
|
||||
Title: cp.Title,
|
||||
TimestampSeconds: cp.TimestampSeconds,
|
||||
Questions: cpQuestions,
|
||||
}
|
||||
|
||||
// Get progress if assignment_id provided
|
||||
if assignmentIDStr != "" {
|
||||
if assignmentID, err := uuid.Parse(assignmentIDStr); err == nil {
|
||||
progress, _ := h.store.GetCheckpointProgress(c.Request.Context(), assignmentID, cp.ID)
|
||||
entry.Progress = progress
|
||||
}
|
||||
}
|
||||
|
||||
entries[i] = entry
|
||||
}
|
||||
|
||||
// Get stream URL
|
||||
streamURL := ""
|
||||
if h.ttsClient != nil {
|
||||
url, err := h.ttsClient.GetPresignedURL(c.Request.Context(), interactiveMedia.Bucket, interactiveMedia.ObjectKey)
|
||||
if err == nil {
|
||||
streamURL = url
|
||||
}
|
||||
}
|
||||
|
||||
manifest := training.InteractiveVideoManifest{
|
||||
MediaID: interactiveMedia.ID,
|
||||
StreamURL: streamURL,
|
||||
Checkpoints: entries,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, manifest)
|
||||
}
|
||||
|
||||
// SubmitCheckpointQuiz handles checkpoint quiz submission
|
||||
// POST /sdk/v1/training/checkpoints/:checkpointId/submit
|
||||
func (h *TrainingHandlers) SubmitCheckpointQuiz(c *gin.Context) {
|
||||
checkpointID, err := uuid.Parse(c.Param("checkpointId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid checkpoint ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req training.SubmitCheckpointQuizRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
assignmentID, err := uuid.Parse(req.AssignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get checkpoint questions
|
||||
questions, err := h.store.GetCheckpointQuestions(c.Request.Context(), checkpointID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if len(questions) == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no questions found for this checkpoint"})
|
||||
return
|
||||
}
|
||||
|
||||
// Grade answers
|
||||
correctCount := 0
|
||||
feedback := make([]training.CheckpointQuizFeedback, len(questions))
|
||||
for i, q := range questions {
|
||||
isCorrect := false
|
||||
if i < len(req.Answers) && req.Answers[i] == q.CorrectIndex {
|
||||
isCorrect = true
|
||||
correctCount++
|
||||
}
|
||||
feedback[i] = training.CheckpointQuizFeedback{
|
||||
Question: q.Question,
|
||||
Correct: isCorrect,
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
}
|
||||
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70 // 70% threshold for checkpoint
|
||||
|
||||
// Update progress
|
||||
progress := &training.CheckpointProgress{
|
||||
AssignmentID: assignmentID,
|
||||
CheckpointID: checkpointID,
|
||||
Passed: passed,
|
||||
Attempts: 1,
|
||||
}
|
||||
if err := h.store.UpsertCheckpointProgress(c.Request.Context(), progress); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log
|
||||
userID := rbac.GetUserID(c)
|
||||
h.store.LogAction(c.Request.Context(), &training.AuditLogEntry{
|
||||
TenantID: rbac.GetTenantID(c),
|
||||
UserID: &userID,
|
||||
Action: training.AuditAction("checkpoint_submitted"),
|
||||
EntityType: training.AuditEntityType("checkpoint"),
|
||||
EntityID: &checkpointID,
|
||||
Details: map[string]interface{}{
|
||||
"assignment_id": assignmentID.String(),
|
||||
"score": score,
|
||||
"passed": passed,
|
||||
"correct": correctCount,
|
||||
"total": len(questions),
|
||||
},
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, training.SubmitCheckpointQuizResponse{
|
||||
Passed: passed,
|
||||
Score: score,
|
||||
Feedback: feedback,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCheckpointProgress returns all checkpoint progress for an assignment
|
||||
// GET /sdk/v1/training/checkpoints/progress/:assignmentId
|
||||
func (h *TrainingHandlers) GetCheckpointProgress(c *gin.Context) {
|
||||
assignmentID, err := uuid.Parse(c.Param("assignmentId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := h.store.ListCheckpointProgress(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"progress": progress,
|
||||
"total": len(progress),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newTestContext, parseResponse, and gin.SetMode are defined in iace_handler_test.go
|
||||
|
||||
// ============================================================================
|
||||
// Module Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetModule_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/modules/not-a-uuid", nil, nil, gin.Params{{Key: "id", Value: "not-a-uuid"}})
|
||||
h.GetModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModule_EmptyID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/modules/", nil, nil, gin.Params{{Key: "id", Value: ""}})
|
||||
h.GetModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/modules", nil, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_MissingTitle_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"module_code": "T01", "regulation_area": "dsgvo", "frequency_type": "annual"}
|
||||
w, c := newTestContext("POST", "/modules", body, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_MissingModuleCode_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"title": "Test", "regulation_area": "dsgvo", "frequency_type": "annual"}
|
||||
w, c := newTestContext("POST", "/modules", body, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateModule_MissingRegulationArea_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"module_code": "T01", "title": "Test", "frequency_type": "annual"}
|
||||
w, c := newTestContext("POST", "/modules", body, nil, nil)
|
||||
h.CreateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateModule_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("PUT", "/modules/bad", map[string]interface{}{"title": "x"}, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.UpdateModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteModule_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/modules/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.DeleteModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteModule_EmptyID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/modules/", nil, nil, gin.Params{{Key: "id", Value: ""}})
|
||||
h.DeleteModule(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Matrix Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestSetMatrixEntry_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/matrix", nil, nil, nil)
|
||||
h.SetMatrixEntry(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMatrixEntry_MissingRoleCode_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"module_id": "00000000-0000-0000-0000-000000000001"}
|
||||
w, c := newTestContext("POST", "/matrix", body, nil, nil)
|
||||
h.SetMatrixEntry(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMatrixEntry_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/matrix/R1/bad", nil, nil, gin.Params{
|
||||
{Key: "role", Value: "R1"},
|
||||
{Key: "moduleId", Value: "bad"},
|
||||
})
|
||||
h.DeleteMatrixEntry(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assignment Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetAssignment_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/assignments/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetAssignment(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAssignment_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/bad/start", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.StartAssignment(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAssignmentProgress_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/bad/progress", map[string]interface{}{"progress": 50}, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.UpdateAssignmentProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAssignmentProgress_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/00000000-0000-0000-0000-000000000001/progress", nil, nil,
|
||||
gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.UpdateAssignmentProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAssignment_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/bad/complete", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.CompleteAssignment(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAssignments_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/assignments/compute", nil, nil, nil)
|
||||
h.ComputeAssignments(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAssignments_MissingUserID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"user_name": "Test", "user_email": "test@test.de", "roles": []string{"R1"}}
|
||||
w, c := newTestContext("POST", "/assignments/compute", body, nil, nil)
|
||||
h.ComputeAssignments(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quiz Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetQuiz_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/quiz/bad", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitQuiz_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/quiz/bad/submit", map[string]interface{}{}, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.SubmitQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitQuiz_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/quiz/00000000-0000-0000-0000-000000000001/submit", nil, nil,
|
||||
gin.Params{{Key: "moduleId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQuizAttempts_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/quiz/attempts/bad", nil, nil, gin.Params{{Key: "assignmentId", Value: "bad"}})
|
||||
h.GetQuizAttempts(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetContent_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/content/bad", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetContent(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishContent_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/publish", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.PublishContent(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateContent_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/generate", nil, nil, nil)
|
||||
h.GenerateContent(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQuiz_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/generate-quiz", nil, nil, nil)
|
||||
h.GenerateQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Media Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetModuleMedia_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/media/module/bad", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetModuleMedia(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMediaURL_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/media/bad/url", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetMediaURL(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMedia_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/media/bad/publish", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.PublishMedia(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMedia_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/media/bad/stream", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.StreamMedia(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAudio_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/generate-audio", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GenerateAudio(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVideo_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/generate-video", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GenerateVideo(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewVideoScript_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/preview-script", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.PreviewVideoScript(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGenerateCertificate_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/certificates/generate/bad", nil, nil, gin.Params{{Key: "assignmentId", Value: "bad"}})
|
||||
h.GenerateCertificate(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadCertificatePDF_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/certificates/bad/pdf", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.DownloadCertificatePDF(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCertificate_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/certificates/bad/verify", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.VerifyCertificate(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCertificates_NilStore_Panics(t *testing.T) {
|
||||
// This tests that a nil store doesn't silently succeed
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("Expected panic with nil store")
|
||||
}
|
||||
}()
|
||||
h := &TrainingHandlers{}
|
||||
_, c := newTestContext("GET", "/certificates", nil, nil, nil)
|
||||
h.ListCertificates(c)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Endpoint Tests (User Journey: Admin generates video)
|
||||
// ============================================================================
|
||||
|
||||
func TestGenerateInteractiveVideo_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content/bad/generate-interactive", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GenerateInteractiveVideo(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateInteractiveVideo_EmptyModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/content//generate-interactive", nil, nil, gin.Params{{Key: "moduleId", Value: ""}})
|
||||
h.GenerateInteractiveVideo(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInteractiveManifest_InvalidModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/content/bad/interactive-manifest", nil, nil, gin.Params{{Key: "moduleId", Value: "bad"}})
|
||||
h.GetInteractiveManifest(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInteractiveManifest_EmptyModuleID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/content//interactive-manifest", nil, nil, gin.Params{{Key: "moduleId", Value: ""}})
|
||||
h.GetInteractiveManifest(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkpoint Quiz Endpoint Tests (User Journey: Learner takes quiz)
|
||||
// ============================================================================
|
||||
|
||||
func TestSubmitCheckpointQuiz_InvalidCheckpointID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "00000000-0000-0000-0000-000000000001",
|
||||
"answers": []int{0, 1, 2},
|
||||
}
|
||||
w, c := newTestContext("POST", "/checkpoints/bad/submit", body, nil, gin.Params{{Key: "checkpointId", Value: "bad"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_EmptyCheckpointID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "00000000-0000-0000-0000-000000000001",
|
||||
"answers": []int{0},
|
||||
}
|
||||
w, c := newTestContext("POST", "/checkpoints//submit", body, nil, gin.Params{{Key: "checkpointId", Value: ""}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/checkpoints/00000000-0000-0000-0000-000000000001/submit", nil, nil,
|
||||
gin.Params{{Key: "checkpointId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "not-a-uuid",
|
||||
"answers": []int{0},
|
||||
}
|
||||
w, c := newTestContext("POST", "/checkpoints/00000000-0000-0000-0000-000000000001/submit", body, nil,
|
||||
gin.Params{{Key: "checkpointId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCheckpointQuiz_ValidIDs_NilStore_Panics(t *testing.T) {
|
||||
// When both IDs are valid, handler reaches store → panic with nil store
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("Expected panic with nil store")
|
||||
}
|
||||
}()
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{
|
||||
"assignment_id": "00000000-0000-0000-0000-000000000001",
|
||||
"answers": []int{0},
|
||||
}
|
||||
_, c := newTestContext("POST", "/checkpoints/00000000-0000-0000-0000-000000000001/submit", body, nil,
|
||||
gin.Params{{Key: "checkpointId", Value: "00000000-0000-0000-0000-000000000001"}})
|
||||
h.SubmitCheckpointQuiz(c)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkpoint Progress Endpoint Tests (User Journey: Learner views progress)
|
||||
// ============================================================================
|
||||
|
||||
func TestGetCheckpointProgress_InvalidAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/checkpoints/progress/bad", nil, nil, gin.Params{{Key: "assignmentId", Value: "bad"}})
|
||||
h.GetCheckpointProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Response should contain 'error' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCheckpointProgress_EmptyAssignmentID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/checkpoints/progress/", nil, nil, gin.Params{{Key: "assignmentId", Value: ""}})
|
||||
h.GetCheckpointProgress(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Error Format Tests (table-driven)
|
||||
// ============================================================================
|
||||
|
||||
func TestInteractiveEndpoints_InvalidID_ResponseContainsErrorKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
handler func(h *TrainingHandlers, c *gin.Context)
|
||||
params gin.Params
|
||||
}{
|
||||
{"GenerateInteractiveVideo", "POST",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.GenerateInteractiveVideo(c) },
|
||||
gin.Params{{Key: "moduleId", Value: "x"}}},
|
||||
{"GetInteractiveManifest", "GET",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.GetInteractiveManifest(c) },
|
||||
gin.Params{{Key: "moduleId", Value: "x"}}},
|
||||
{"SubmitCheckpointQuiz", "POST",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.SubmitCheckpointQuiz(c) },
|
||||
gin.Params{{Key: "checkpointId", Value: "x"}}},
|
||||
{"GetCheckpointProgress", "GET",
|
||||
func(h *TrainingHandlers, c *gin.Context) { h.GetCheckpointProgress(c) },
|
||||
gin.Params{{Key: "assignmentId", Value: "x"}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext(tt.method, "/test", nil, nil, tt.params)
|
||||
tt.handler(h, c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: Expected 400, got %d", tt.name, w.Code)
|
||||
}
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Errorf("%s: response should contain 'error' key", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Block Endpoint Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetBlockConfig_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/blocks/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlockConfig_EmptyBody_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/blocks", nil, nil, nil)
|
||||
h.CreateBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBlockConfig_MissingName_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
body := map[string]interface{}{"regulation_area": "dsgvo", "module_code_prefix": "BLK"}
|
||||
w, c := newTestContext("POST", "/blocks", body, nil, nil)
|
||||
h.CreateBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBlockConfig_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("PUT", "/blocks/bad", map[string]interface{}{"name": "x"}, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.UpdateBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBlockConfig_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("DELETE", "/blocks/bad", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.DeleteBlockConfig(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewBlock_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/blocks/bad/preview", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.PreviewBlock(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBlock_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("POST", "/blocks/bad/generate", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GenerateBlock(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBlockControls_InvalidID_Returns400(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext("GET", "/blocks/bad/controls", nil, nil, gin.Params{{Key: "id", Value: "bad"}})
|
||||
h.GetBlockControls(c)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Error Format Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestInvalidID_ResponseContainsErrorKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
handler func(h *TrainingHandlers, c *gin.Context)
|
||||
params gin.Params
|
||||
}{
|
||||
{"GetModule", "GET", func(h *TrainingHandlers, c *gin.Context) { h.GetModule(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
{"DeleteModule", "DELETE", func(h *TrainingHandlers, c *gin.Context) { h.DeleteModule(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
{"StreamMedia", "GET", func(h *TrainingHandlers, c *gin.Context) { h.StreamMedia(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
{"GenerateCertificate", "POST", func(h *TrainingHandlers, c *gin.Context) { h.GenerateCertificate(c) }, gin.Params{{Key: "assignmentId", Value: "x"}}},
|
||||
{"DownloadCertificatePDF", "GET", func(h *TrainingHandlers, c *gin.Context) { h.DownloadCertificatePDF(c) }, gin.Params{{Key: "id", Value: "x"}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := &TrainingHandlers{}
|
||||
w, c := newTestContext(tt.method, "/test", nil, nil, tt.params)
|
||||
tt.handler(h, c)
|
||||
resp := parseResponse(w)
|
||||
if resp["error"] == nil {
|
||||
t.Errorf("%s: response should contain 'error' key", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
282
ai-compliance-sdk/internal/training/block_generator.go
Normal file
282
ai-compliance-sdk/internal/training/block_generator.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// BlockGenerator orchestrates the Controls → Training Modules pipeline
|
||||
type BlockGenerator struct {
|
||||
store *Store
|
||||
contentGenerator *ContentGenerator
|
||||
}
|
||||
|
||||
// NewBlockGenerator creates a new block generator
|
||||
func NewBlockGenerator(store *Store, contentGenerator *ContentGenerator) *BlockGenerator {
|
||||
return &BlockGenerator{
|
||||
store: store,
|
||||
contentGenerator: contentGenerator,
|
||||
}
|
||||
}
|
||||
|
||||
// Preview performs a dry run: loads matching controls, computes module split and roles
|
||||
func (bg *BlockGenerator) Preview(ctx context.Context, configID uuid.UUID) (*PreviewBlockResponse, error) {
|
||||
config, err := bg.store.GetBlockConfig(ctx, configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load block config: %w", err)
|
||||
}
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("block config not found")
|
||||
}
|
||||
|
||||
controls, err := bg.store.QueryCanonicalControls(ctx,
|
||||
config.DomainFilter, config.CategoryFilter,
|
||||
config.SeverityFilter, config.TargetAudienceFilter,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query controls: %w", err)
|
||||
}
|
||||
|
||||
maxPerModule := config.MaxControlsPerModule
|
||||
if maxPerModule <= 0 {
|
||||
maxPerModule = 20
|
||||
}
|
||||
moduleCount := int(math.Ceil(float64(len(controls)) / float64(maxPerModule)))
|
||||
if moduleCount == 0 && len(controls) > 0 {
|
||||
moduleCount = 1
|
||||
}
|
||||
|
||||
roles := bg.deriveRoles(controls, config.TargetAudienceFilter)
|
||||
|
||||
return &PreviewBlockResponse{
|
||||
ControlCount: len(controls),
|
||||
ModuleCount: moduleCount,
|
||||
Controls: controls,
|
||||
ProposedRoles: roles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Generate executes the full pipeline: Controls → Modules → Links → CTM → Content
|
||||
func (bg *BlockGenerator) Generate(ctx context.Context, configID uuid.UUID, req GenerateBlockRequest) (*GenerateBlockResponse, error) {
|
||||
config, err := bg.store.GetBlockConfig(ctx, configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load block config: %w", err)
|
||||
}
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("block config not found")
|
||||
}
|
||||
|
||||
// 1. Load matching controls
|
||||
controls, err := bg.store.QueryCanonicalControls(ctx,
|
||||
config.DomainFilter, config.CategoryFilter,
|
||||
config.SeverityFilter, config.TargetAudienceFilter,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query controls: %w", err)
|
||||
}
|
||||
|
||||
if len(controls) == 0 {
|
||||
return &GenerateBlockResponse{}, nil
|
||||
}
|
||||
|
||||
// 2. Chunk controls into module-sized groups
|
||||
maxPerModule := config.MaxControlsPerModule
|
||||
if maxPerModule <= 0 {
|
||||
maxPerModule = 20
|
||||
}
|
||||
chunks := chunkControls(controls, maxPerModule)
|
||||
|
||||
// 3. Derive target roles for CTM
|
||||
roles := bg.deriveRoles(controls, config.TargetAudienceFilter)
|
||||
|
||||
// 4. Count existing modules with this prefix for auto-numbering
|
||||
existingCount, err := bg.store.CountModulesWithPrefix(ctx, config.TenantID, config.ModuleCodePrefix)
|
||||
if err != nil {
|
||||
existingCount = 0
|
||||
}
|
||||
|
||||
language := req.Language
|
||||
if language == "" {
|
||||
language = "de"
|
||||
}
|
||||
|
||||
resp := &GenerateBlockResponse{}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
moduleNum := existingCount + i + 1
|
||||
moduleCode := fmt.Sprintf("%s-%02d", config.ModuleCodePrefix, moduleNum)
|
||||
|
||||
// Build a descriptive title from the first few controls
|
||||
title := bg.buildModuleTitle(config, chunk, i+1, len(chunks))
|
||||
|
||||
// a. Create TrainingModule
|
||||
module := &TrainingModule{
|
||||
TenantID: config.TenantID,
|
||||
ModuleCode: moduleCode,
|
||||
Title: title,
|
||||
Description: config.Description,
|
||||
RegulationArea: config.RegulationArea,
|
||||
NIS2Relevant: config.RegulationArea == RegulationNIS2,
|
||||
ISOControls: bg.extractControlIDs(chunk),
|
||||
FrequencyType: config.FrequencyType,
|
||||
ValidityDays: 365,
|
||||
RiskWeight: 2.0,
|
||||
ContentType: "text",
|
||||
DurationMinutes: config.DurationMinutes,
|
||||
PassThreshold: config.PassThreshold,
|
||||
IsActive: true,
|
||||
SortOrder: moduleNum,
|
||||
}
|
||||
|
||||
if err := bg.store.CreateModule(ctx, module); err != nil {
|
||||
resp.Errors = append(resp.Errors, fmt.Sprintf("create module %s: %v", moduleCode, err))
|
||||
continue
|
||||
}
|
||||
resp.ModulesCreated++
|
||||
|
||||
// b. Create control links (traceability)
|
||||
for j, ctrl := range chunk {
|
||||
link := &TrainingBlockControlLink{
|
||||
BlockConfigID: config.ID,
|
||||
ModuleID: module.ID,
|
||||
ControlID: ctrl.ControlID,
|
||||
ControlTitle: ctrl.Title,
|
||||
ControlObjective: ctrl.Objective,
|
||||
ControlRequirements: ctrl.Requirements,
|
||||
SortOrder: j,
|
||||
}
|
||||
if err := bg.store.CreateBlockControlLink(ctx, link); err != nil {
|
||||
resp.Errors = append(resp.Errors, fmt.Sprintf("link %s→%s: %v", moduleCode, ctrl.ControlID, err))
|
||||
continue
|
||||
}
|
||||
resp.ControlsLinked++
|
||||
}
|
||||
|
||||
// c. Create CTM entries (target_audience → roles)
|
||||
if req.AutoMatrix {
|
||||
for _, role := range roles {
|
||||
entry := &TrainingMatrixEntry{
|
||||
TenantID: config.TenantID,
|
||||
RoleCode: role,
|
||||
ModuleID: module.ID,
|
||||
IsMandatory: true,
|
||||
Priority: 1,
|
||||
}
|
||||
if err := bg.store.SetMatrixEntry(ctx, entry); err != nil {
|
||||
resp.Errors = append(resp.Errors, fmt.Sprintf("matrix %s→%s: %v", role, moduleCode, err))
|
||||
continue
|
||||
}
|
||||
resp.MatrixEntriesCreated++
|
||||
}
|
||||
}
|
||||
|
||||
// d. Generate LLM content
|
||||
_, err := bg.contentGenerator.GenerateBlockContent(ctx, *module, chunk, language)
|
||||
if err != nil {
|
||||
resp.Errors = append(resp.Errors, fmt.Sprintf("content %s: %v", moduleCode, err))
|
||||
continue
|
||||
}
|
||||
resp.ContentGenerated++
|
||||
}
|
||||
|
||||
// 5. Update last_generated_at
|
||||
bg.store.UpdateBlockConfigLastGenerated(ctx, config.ID)
|
||||
|
||||
// 6. Audit log
|
||||
bg.store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: config.TenantID,
|
||||
Action: AuditAction("block_generated"),
|
||||
EntityType: AuditEntityModule,
|
||||
Details: map[string]interface{}{
|
||||
"block_config_id": config.ID.String(),
|
||||
"block_name": config.Name,
|
||||
"modules_created": resp.ModulesCreated,
|
||||
"controls_linked": resp.ControlsLinked,
|
||||
"content_generated": resp.ContentGenerated,
|
||||
},
|
||||
})
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// deriveRoles computes which CTM roles should receive the generated modules
|
||||
func (bg *BlockGenerator) deriveRoles(controls []CanonicalControlSummary, audienceFilter string) []string {
|
||||
roleSet := map[string]bool{}
|
||||
|
||||
// If a specific audience filter is set, use the mapping
|
||||
if audienceFilter != "" {
|
||||
if roles, ok := TargetAudienceRoleMapping[audienceFilter]; ok {
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additionally derive roles from control categories
|
||||
for _, ctrl := range controls {
|
||||
if ctrl.Category != "" {
|
||||
if roles, ok := CategoryRoleMapping[ctrl.Category]; ok {
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check per-control target_audience
|
||||
if ctrl.TargetAudience != "" && audienceFilter == "" {
|
||||
if roles, ok := TargetAudienceRoleMapping[ctrl.TargetAudience]; ok {
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing derived, default to R9 (Alle Mitarbeiter)
|
||||
if len(roleSet) == 0 {
|
||||
roleSet[RoleR9] = true
|
||||
}
|
||||
|
||||
roles := make([]string, 0, len(roleSet))
|
||||
for r := range roleSet {
|
||||
roles = append(roles, r)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// buildModuleTitle creates a descriptive module title
|
||||
func (bg *BlockGenerator) buildModuleTitle(config *TrainingBlockConfig, controls []CanonicalControlSummary, partNum, totalParts int) string {
|
||||
base := config.Name
|
||||
if totalParts > 1 {
|
||||
base = fmt.Sprintf("%s (Teil %d/%d)", config.Name, partNum, totalParts)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// extractControlIDs returns the control IDs from a slice of controls
|
||||
func (bg *BlockGenerator) extractControlIDs(controls []CanonicalControlSummary) []string {
|
||||
ids := make([]string, len(controls))
|
||||
for i, c := range controls {
|
||||
ids[i] = c.ControlID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// chunkControls splits controls into groups of maxSize
|
||||
func chunkControls(controls []CanonicalControlSummary, maxSize int) [][]CanonicalControlSummary {
|
||||
if maxSize <= 0 {
|
||||
maxSize = 20
|
||||
}
|
||||
|
||||
var chunks [][]CanonicalControlSummary
|
||||
for i := 0; i < len(controls); i += maxSize {
|
||||
end := i + maxSize
|
||||
if end > len(controls) {
|
||||
end = len(controls)
|
||||
}
|
||||
chunks = append(chunks, controls[i:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
224
ai-compliance-sdk/internal/training/block_generator_test.go
Normal file
224
ai-compliance-sdk/internal/training/block_generator_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChunkControls_EmptySlice(t *testing.T) {
|
||||
chunks := chunkControls(nil, 20)
|
||||
if len(chunks) != 0 {
|
||||
t.Errorf("expected 0 chunks, got %d", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkControls_SingleChunk(t *testing.T) {
|
||||
controls := make([]CanonicalControlSummary, 5)
|
||||
for i := range controls {
|
||||
controls[i].ControlID = "CTRL-" + string(rune('A'+i))
|
||||
}
|
||||
|
||||
chunks := chunkControls(controls, 20)
|
||||
if len(chunks) != 1 {
|
||||
t.Errorf("expected 1 chunk, got %d", len(chunks))
|
||||
}
|
||||
if len(chunks[0]) != 5 {
|
||||
t.Errorf("expected 5 controls in chunk, got %d", len(chunks[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkControls_MultipleChunks(t *testing.T) {
|
||||
controls := make([]CanonicalControlSummary, 25)
|
||||
for i := range controls {
|
||||
controls[i].ControlID = "CTRL-" + string(rune('A'+i%26))
|
||||
}
|
||||
|
||||
chunks := chunkControls(controls, 10)
|
||||
if len(chunks) != 3 {
|
||||
t.Errorf("expected 3 chunks, got %d", len(chunks))
|
||||
}
|
||||
if len(chunks[0]) != 10 {
|
||||
t.Errorf("expected 10 in first chunk, got %d", len(chunks[0]))
|
||||
}
|
||||
if len(chunks[2]) != 5 {
|
||||
t.Errorf("expected 5 in last chunk, got %d", len(chunks[2]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkControls_ExactMultiple(t *testing.T) {
|
||||
controls := make([]CanonicalControlSummary, 20)
|
||||
chunks := chunkControls(controls, 10)
|
||||
if len(chunks) != 2 {
|
||||
t.Errorf("expected 2 chunks, got %d", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_DeriveRoles_EnterpriseAudience(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
controls := []CanonicalControlSummary{
|
||||
{ControlID: "AUTH-001", Category: "authentication", TargetAudience: "enterprise"},
|
||||
}
|
||||
|
||||
roles := bg.deriveRoles(controls, "enterprise")
|
||||
|
||||
// Should include enterprise mapping roles
|
||||
roleSet := map[string]bool{}
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
// Enterprise maps to R1, R4, R5, R6, R7, R9
|
||||
if !roleSet[RoleR1] {
|
||||
t.Error("expected R1 for enterprise audience")
|
||||
}
|
||||
if !roleSet[RoleR9] {
|
||||
t.Error("expected R9 for enterprise audience")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_DeriveRoles_AuthorityAudience(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
controls := []CanonicalControlSummary{
|
||||
{ControlID: "GOV-001", Category: "governance", TargetAudience: "authority"},
|
||||
}
|
||||
|
||||
roles := bg.deriveRoles(controls, "authority")
|
||||
|
||||
roleSet := map[string]bool{}
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
// Authority maps to R10
|
||||
if !roleSet[RoleR10] {
|
||||
t.Error("expected R10 for authority audience")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_DeriveRoles_NoFilter(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
controls := []CanonicalControlSummary{
|
||||
{ControlID: "ENC-001", Category: "encryption", TargetAudience: "provider"},
|
||||
}
|
||||
|
||||
roles := bg.deriveRoles(controls, "")
|
||||
|
||||
roleSet := map[string]bool{}
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
// Without audience filter, should use per-control audience + category
|
||||
// encryption → R2, R8
|
||||
// provider → R2, R8
|
||||
if !roleSet[RoleR2] {
|
||||
t.Error("expected R2 from encryption category + provider audience")
|
||||
}
|
||||
if !roleSet[RoleR8] {
|
||||
t.Error("expected R8 from encryption category + provider audience")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_DeriveRoles_DefaultToR9(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
controls := []CanonicalControlSummary{
|
||||
{ControlID: "UNK-001", Category: "", TargetAudience: ""},
|
||||
}
|
||||
|
||||
roles := bg.deriveRoles(controls, "")
|
||||
|
||||
if len(roles) != 1 || roles[0] != RoleR9 {
|
||||
t.Errorf("expected [R9] default, got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_ExtractControlIDs(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
controls := []CanonicalControlSummary{
|
||||
{ControlID: "AUTH-001"},
|
||||
{ControlID: "AUTH-002"},
|
||||
{ControlID: "ENC-010"},
|
||||
}
|
||||
|
||||
ids := bg.extractControlIDs(controls)
|
||||
if len(ids) != 3 {
|
||||
t.Errorf("expected 3 IDs, got %d", len(ids))
|
||||
}
|
||||
if ids[0] != "AUTH-001" || ids[1] != "AUTH-002" || ids[2] != "ENC-010" {
|
||||
t.Errorf("unexpected IDs: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_BuildModuleTitle_SinglePart(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
config := &TrainingBlockConfig{Name: "Authentifizierung"}
|
||||
controls := []CanonicalControlSummary{{ControlID: "AUTH-001"}}
|
||||
|
||||
title := bg.buildModuleTitle(config, controls, 1, 1)
|
||||
if title != "Authentifizierung" {
|
||||
t.Errorf("expected 'Authentifizierung', got '%s'", title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockGenerator_BuildModuleTitle_MultiPart(t *testing.T) {
|
||||
bg := &BlockGenerator{}
|
||||
config := &TrainingBlockConfig{Name: "Authentifizierung"}
|
||||
controls := []CanonicalControlSummary{{ControlID: "AUTH-001"}}
|
||||
|
||||
title := bg.buildModuleTitle(config, controls, 2, 3)
|
||||
expected := "Authentifizierung (Teil 2/3)"
|
||||
if title != expected {
|
||||
t.Errorf("expected '%s', got '%s'", expected, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool // true = nil result
|
||||
}{
|
||||
{"", true},
|
||||
{" ", true},
|
||||
{"value", false},
|
||||
{" value ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := nilIfEmpty(tt.input)
|
||||
if tt.expected && result != nil {
|
||||
t.Errorf("nilIfEmpty(%q) = %v, expected nil", tt.input, *result)
|
||||
}
|
||||
if !tt.expected && result == nil {
|
||||
t.Errorf("nilIfEmpty(%q) = nil, expected non-nil", tt.input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetAudienceRoleMapping_AllKeys(t *testing.T) {
|
||||
expectedKeys := []string{"enterprise", "authority", "provider", "all"}
|
||||
for _, key := range expectedKeys {
|
||||
roles, ok := TargetAudienceRoleMapping[key]
|
||||
if !ok {
|
||||
t.Errorf("missing key '%s' in TargetAudienceRoleMapping", key)
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
t.Errorf("empty roles for key '%s'", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryRoleMapping_HasEntries(t *testing.T) {
|
||||
if len(CategoryRoleMapping) == 0 {
|
||||
t.Error("CategoryRoleMapping is empty")
|
||||
}
|
||||
|
||||
// Verify some expected entries
|
||||
if _, ok := CategoryRoleMapping["encryption"]; !ok {
|
||||
t.Error("missing 'encryption' in CategoryRoleMapping")
|
||||
}
|
||||
if _, ok := CategoryRoleMapping["authentication"]; !ok {
|
||||
t.Error("missing 'authentication' in CategoryRoleMapping")
|
||||
}
|
||||
if _, ok := CategoryRoleMapping["data_protection"]; !ok {
|
||||
t.Error("missing 'data_protection' in CategoryRoleMapping")
|
||||
}
|
||||
}
|
||||
484
ai-compliance-sdk/internal/training/block_store.go
Normal file
484
ai-compliance-sdk/internal/training/block_store.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Block Config CRUD
|
||||
// ============================================================================
|
||||
|
||||
// CreateBlockConfig creates a new training block configuration
|
||||
func (s *Store) CreateBlockConfig(ctx context.Context, config *TrainingBlockConfig) error {
|
||||
config.ID = uuid.New()
|
||||
config.CreatedAt = time.Now().UTC()
|
||||
config.UpdatedAt = config.CreatedAt
|
||||
if !config.IsActive {
|
||||
config.IsActive = true
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_block_configs (
|
||||
id, tenant_id, name, description,
|
||||
domain_filter, category_filter, severity_filter, target_audience_filter,
|
||||
regulation_area, module_code_prefix, frequency_type,
|
||||
duration_minutes, pass_threshold, max_controls_per_module,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, $10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16, $17
|
||||
)
|
||||
`,
|
||||
config.ID, config.TenantID, config.Name, config.Description,
|
||||
nilIfEmpty(config.DomainFilter), nilIfEmpty(config.CategoryFilter),
|
||||
nilIfEmpty(config.SeverityFilter), nilIfEmpty(config.TargetAudienceFilter),
|
||||
string(config.RegulationArea), config.ModuleCodePrefix, string(config.FrequencyType),
|
||||
config.DurationMinutes, config.PassThreshold, config.MaxControlsPerModule,
|
||||
config.IsActive, config.CreatedAt, config.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetBlockConfig retrieves a block config by ID
|
||||
func (s *Store) GetBlockConfig(ctx context.Context, id uuid.UUID) (*TrainingBlockConfig, error) {
|
||||
var config TrainingBlockConfig
|
||||
var regulationArea, frequencyType string
|
||||
var domainFilter, categoryFilter, severityFilter, targetAudienceFilter *string
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
id, tenant_id, name, description,
|
||||
domain_filter, category_filter, severity_filter, target_audience_filter,
|
||||
regulation_area, module_code_prefix, frequency_type,
|
||||
duration_minutes, pass_threshold, max_controls_per_module,
|
||||
is_active, last_generated_at, created_at, updated_at
|
||||
FROM training_block_configs WHERE id = $1
|
||||
`, id).Scan(
|
||||
&config.ID, &config.TenantID, &config.Name, &config.Description,
|
||||
&domainFilter, &categoryFilter, &severityFilter, &targetAudienceFilter,
|
||||
®ulationArea, &config.ModuleCodePrefix, &frequencyType,
|
||||
&config.DurationMinutes, &config.PassThreshold, &config.MaxControlsPerModule,
|
||||
&config.IsActive, &config.LastGeneratedAt, &config.CreatedAt, &config.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.RegulationArea = RegulationArea(regulationArea)
|
||||
config.FrequencyType = FrequencyType(frequencyType)
|
||||
if domainFilter != nil {
|
||||
config.DomainFilter = *domainFilter
|
||||
}
|
||||
if categoryFilter != nil {
|
||||
config.CategoryFilter = *categoryFilter
|
||||
}
|
||||
if severityFilter != nil {
|
||||
config.SeverityFilter = *severityFilter
|
||||
}
|
||||
if targetAudienceFilter != nil {
|
||||
config.TargetAudienceFilter = *targetAudienceFilter
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ListBlockConfigs returns all block configs for a tenant
|
||||
func (s *Store) ListBlockConfigs(ctx context.Context, tenantID uuid.UUID) ([]TrainingBlockConfig, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
id, tenant_id, name, description,
|
||||
domain_filter, category_filter, severity_filter, target_audience_filter,
|
||||
regulation_area, module_code_prefix, frequency_type,
|
||||
duration_minutes, pass_threshold, max_controls_per_module,
|
||||
is_active, last_generated_at, created_at, updated_at
|
||||
FROM training_block_configs
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []TrainingBlockConfig
|
||||
for rows.Next() {
|
||||
var config TrainingBlockConfig
|
||||
var regulationArea, frequencyType string
|
||||
var domainFilter, categoryFilter, severityFilter, targetAudienceFilter *string
|
||||
|
||||
if err := rows.Scan(
|
||||
&config.ID, &config.TenantID, &config.Name, &config.Description,
|
||||
&domainFilter, &categoryFilter, &severityFilter, &targetAudienceFilter,
|
||||
®ulationArea, &config.ModuleCodePrefix, &frequencyType,
|
||||
&config.DurationMinutes, &config.PassThreshold, &config.MaxControlsPerModule,
|
||||
&config.IsActive, &config.LastGeneratedAt, &config.CreatedAt, &config.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.RegulationArea = RegulationArea(regulationArea)
|
||||
config.FrequencyType = FrequencyType(frequencyType)
|
||||
if domainFilter != nil {
|
||||
config.DomainFilter = *domainFilter
|
||||
}
|
||||
if categoryFilter != nil {
|
||||
config.CategoryFilter = *categoryFilter
|
||||
}
|
||||
if severityFilter != nil {
|
||||
config.SeverityFilter = *severityFilter
|
||||
}
|
||||
if targetAudienceFilter != nil {
|
||||
config.TargetAudienceFilter = *targetAudienceFilter
|
||||
}
|
||||
|
||||
configs = append(configs, config)
|
||||
}
|
||||
|
||||
if configs == nil {
|
||||
configs = []TrainingBlockConfig{}
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// UpdateBlockConfig updates a block config
|
||||
func (s *Store) UpdateBlockConfig(ctx context.Context, config *TrainingBlockConfig) error {
|
||||
config.UpdatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_block_configs SET
|
||||
name = $2, description = $3,
|
||||
domain_filter = $4, category_filter = $5,
|
||||
severity_filter = $6, target_audience_filter = $7,
|
||||
max_controls_per_module = $8, duration_minutes = $9,
|
||||
pass_threshold = $10, is_active = $11, updated_at = $12
|
||||
WHERE id = $1
|
||||
`,
|
||||
config.ID, config.Name, config.Description,
|
||||
nilIfEmpty(config.DomainFilter), nilIfEmpty(config.CategoryFilter),
|
||||
nilIfEmpty(config.SeverityFilter), nilIfEmpty(config.TargetAudienceFilter),
|
||||
config.MaxControlsPerModule, config.DurationMinutes,
|
||||
config.PassThreshold, config.IsActive, config.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBlockConfig deletes a block config (cascades to control links)
|
||||
func (s *Store) DeleteBlockConfig(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_block_configs WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateBlockConfigLastGenerated updates the last_generated_at timestamp
|
||||
func (s *Store) UpdateBlockConfigLastGenerated(ctx context.Context, id uuid.UUID) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_block_configs SET last_generated_at = $2, updated_at = $2 WHERE id = $1
|
||||
`, id, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Block Control Links
|
||||
// ============================================================================
|
||||
|
||||
// CreateBlockControlLink creates a link between a block config, a module, and a control
|
||||
func (s *Store) CreateBlockControlLink(ctx context.Context, link *TrainingBlockControlLink) error {
|
||||
link.ID = uuid.New()
|
||||
link.CreatedAt = time.Now().UTC()
|
||||
|
||||
requirements, _ := json.Marshal(link.ControlRequirements)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_block_control_links (
|
||||
id, block_config_id, module_id, control_id,
|
||||
control_title, control_objective, control_requirements,
|
||||
sort_order, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
link.ID, link.BlockConfigID, link.ModuleID, link.ControlID,
|
||||
link.ControlTitle, link.ControlObjective, requirements,
|
||||
link.SortOrder, link.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetControlLinksForBlock returns all control links for a block config
|
||||
func (s *Store) GetControlLinksForBlock(ctx context.Context, blockConfigID uuid.UUID) ([]TrainingBlockControlLink, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, block_config_id, module_id, control_id,
|
||||
control_title, control_objective, control_requirements,
|
||||
sort_order, created_at
|
||||
FROM training_block_control_links
|
||||
WHERE block_config_id = $1
|
||||
ORDER BY sort_order
|
||||
`, blockConfigID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var links []TrainingBlockControlLink
|
||||
for rows.Next() {
|
||||
var link TrainingBlockControlLink
|
||||
var requirements []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&link.ID, &link.BlockConfigID, &link.ModuleID, &link.ControlID,
|
||||
&link.ControlTitle, &link.ControlObjective, &requirements,
|
||||
&link.SortOrder, &link.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(requirements, &link.ControlRequirements)
|
||||
if link.ControlRequirements == nil {
|
||||
link.ControlRequirements = []string{}
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
|
||||
if links == nil {
|
||||
links = []TrainingBlockControlLink{}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// GetControlLinksForModule returns all control links for a specific module
|
||||
func (s *Store) GetControlLinksForModule(ctx context.Context, moduleID uuid.UUID) ([]TrainingBlockControlLink, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, block_config_id, module_id, control_id,
|
||||
control_title, control_objective, control_requirements,
|
||||
sort_order, created_at
|
||||
FROM training_block_control_links
|
||||
WHERE module_id = $1
|
||||
ORDER BY sort_order
|
||||
`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var links []TrainingBlockControlLink
|
||||
for rows.Next() {
|
||||
var link TrainingBlockControlLink
|
||||
var requirements []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&link.ID, &link.BlockConfigID, &link.ModuleID, &link.ControlID,
|
||||
&link.ControlTitle, &link.ControlObjective, &requirements,
|
||||
&link.SortOrder, &link.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(requirements, &link.ControlRequirements)
|
||||
if link.ControlRequirements == nil {
|
||||
link.ControlRequirements = []string{}
|
||||
}
|
||||
links = append(links, link)
|
||||
}
|
||||
|
||||
if links == nil {
|
||||
links = []TrainingBlockControlLink{}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canonical Controls Query (reads from shared DB table)
|
||||
// ============================================================================
|
||||
|
||||
// QueryCanonicalControls queries canonical_controls with dynamic filters.
|
||||
// Domain is derived from the control_id prefix (e.g. "AUTH" from "AUTH-042").
|
||||
func (s *Store) QueryCanonicalControls(ctx context.Context,
|
||||
domain, category, severity, targetAudience string,
|
||||
) ([]CanonicalControlSummary, error) {
|
||||
query := `SELECT control_id, title, objective, rationale,
|
||||
requirements, severity, COALESCE(category, ''), COALESCE(target_audience, ''), COALESCE(tags, '[]')
|
||||
FROM canonical_controls
|
||||
WHERE release_state NOT IN ('deprecated', 'draft')
|
||||
AND customer_visible = true`
|
||||
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if domain != "" {
|
||||
query += fmt.Sprintf(` AND LEFT(control_id, %d) = $%d`, len(domain), argIdx)
|
||||
args = append(args, domain)
|
||||
argIdx++
|
||||
}
|
||||
if category != "" {
|
||||
query += fmt.Sprintf(` AND category = $%d`, argIdx)
|
||||
args = append(args, category)
|
||||
argIdx++
|
||||
}
|
||||
if severity != "" {
|
||||
query += fmt.Sprintf(` AND severity = $%d`, argIdx)
|
||||
args = append(args, severity)
|
||||
argIdx++
|
||||
}
|
||||
if targetAudience != "" {
|
||||
query += fmt.Sprintf(` AND (target_audience = $%d OR target_audience = 'all')`, argIdx)
|
||||
args = append(args, targetAudience)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += ` ORDER BY control_id`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query canonical controls: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var controls []CanonicalControlSummary
|
||||
for rows.Next() {
|
||||
var c CanonicalControlSummary
|
||||
var requirementsJSON, tagsJSON []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&c.ControlID, &c.Title, &c.Objective, &c.Rationale,
|
||||
&requirementsJSON, &c.Severity, &c.Category, &c.TargetAudience, &tagsJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
json.Unmarshal(requirementsJSON, &c.Requirements)
|
||||
if c.Requirements == nil {
|
||||
c.Requirements = []string{}
|
||||
}
|
||||
json.Unmarshal(tagsJSON, &c.Tags)
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
|
||||
controls = append(controls, c)
|
||||
}
|
||||
|
||||
if controls == nil {
|
||||
controls = []CanonicalControlSummary{}
|
||||
}
|
||||
return controls, nil
|
||||
}
|
||||
|
||||
// GetCanonicalControlMeta returns aggregated metadata about canonical controls
|
||||
func (s *Store) GetCanonicalControlMeta(ctx context.Context) (*CanonicalControlMeta, error) {
|
||||
meta := &CanonicalControlMeta{}
|
||||
|
||||
// Total count
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM canonical_controls
|
||||
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
|
||||
`).Scan(&meta.Total)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count canonical controls: %w", err)
|
||||
}
|
||||
|
||||
// Domains (derived from control_id prefix)
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT LEFT(control_id, POSITION('-' IN control_id) - 1) AS domain, COUNT(*) AS cnt
|
||||
FROM canonical_controls
|
||||
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
|
||||
AND POSITION('-' IN control_id) > 0
|
||||
GROUP BY domain ORDER BY cnt DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var d DomainCount
|
||||
if err := rows.Scan(&d.Domain, &d.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Domains = append(meta.Domains, d)
|
||||
}
|
||||
if meta.Domains == nil {
|
||||
meta.Domains = []DomainCount{}
|
||||
}
|
||||
|
||||
// Categories
|
||||
catRows, err := s.pool.Query(ctx, `
|
||||
SELECT COALESCE(category, 'uncategorized') AS cat, COUNT(*) AS cnt
|
||||
FROM canonical_controls
|
||||
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
|
||||
GROUP BY cat ORDER BY cnt DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer catRows.Close()
|
||||
|
||||
for catRows.Next() {
|
||||
var c CategoryCount
|
||||
if err := catRows.Scan(&c.Category, &c.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Categories = append(meta.Categories, c)
|
||||
}
|
||||
if meta.Categories == nil {
|
||||
meta.Categories = []CategoryCount{}
|
||||
}
|
||||
|
||||
// Target audiences
|
||||
audRows, err := s.pool.Query(ctx, `
|
||||
SELECT COALESCE(target_audience, 'unset') AS aud, COUNT(*) AS cnt
|
||||
FROM canonical_controls
|
||||
WHERE release_state NOT IN ('deprecated', 'draft') AND customer_visible = true
|
||||
GROUP BY aud ORDER BY cnt DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer audRows.Close()
|
||||
|
||||
for audRows.Next() {
|
||||
var a AudienceCount
|
||||
if err := audRows.Scan(&a.Audience, &a.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Audiences = append(meta.Audiences, a)
|
||||
}
|
||||
if meta.Audiences == nil {
|
||||
meta.Audiences = []AudienceCount{}
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
// CountModulesWithPrefix counts existing modules with a given code prefix for auto-numbering
|
||||
func (s *Store) CountModulesWithPrefix(ctx context.Context, tenantID uuid.UUID, prefix string) (int, error) {
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_modules
|
||||
WHERE tenant_id = $1 AND module_code LIKE $2
|
||||
`, tenantID, prefix+"-%").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -294,6 +294,133 @@ func parseQuizResponse(response string, moduleID uuid.UUID) ([]QuizQuestion, err
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// GenerateBlockContent generates training content for a module based on linked canonical controls
|
||||
func (g *ContentGenerator) GenerateBlockContent(
|
||||
ctx context.Context,
|
||||
module TrainingModule,
|
||||
controls []CanonicalControlSummary,
|
||||
language string,
|
||||
) (*ModuleContent, error) {
|
||||
if language == "" {
|
||||
language = "de"
|
||||
}
|
||||
|
||||
prompt := buildBlockContentPrompt(module, controls, language)
|
||||
|
||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: getContentSystemPrompt(language)},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.15,
|
||||
MaxTokens: 8192,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM block content generation failed: %w", err)
|
||||
}
|
||||
|
||||
contentBody := resp.Message.Content
|
||||
|
||||
// PII check
|
||||
if g.piiDetector != nil && g.piiDetector.ContainsPII(contentBody) {
|
||||
findings := g.piiDetector.FindPII(contentBody)
|
||||
for _, f := range findings {
|
||||
contentBody = strings.ReplaceAll(contentBody, f.Match, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
summary := contentBody
|
||||
if len(summary) > 200 {
|
||||
summary = summary[:200] + "..."
|
||||
}
|
||||
|
||||
content := &ModuleContent{
|
||||
ModuleID: module.ID,
|
||||
ContentFormat: ContentFormatMarkdown,
|
||||
ContentBody: contentBody,
|
||||
Summary: summary,
|
||||
GeneratedBy: "llm_block_" + resp.Provider,
|
||||
LLMModel: resp.Model,
|
||||
IsPublished: false,
|
||||
}
|
||||
|
||||
if err := g.store.CreateModuleContent(ctx, content); err != nil {
|
||||
return nil, fmt.Errorf("failed to save block content: %w", err)
|
||||
}
|
||||
|
||||
// Audit log
|
||||
g.store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: module.TenantID,
|
||||
Action: AuditActionContentGenerated,
|
||||
EntityType: AuditEntityModule,
|
||||
EntityID: &module.ID,
|
||||
Details: map[string]interface{}{
|
||||
"module_code": module.ModuleCode,
|
||||
"provider": resp.Provider,
|
||||
"model": resp.Model,
|
||||
"content_id": content.ID.String(),
|
||||
"version": content.Version,
|
||||
"tokens_used": resp.Usage.TotalTokens,
|
||||
"controls_count": len(controls),
|
||||
"source": "block_generator",
|
||||
},
|
||||
})
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// buildBlockContentPrompt creates a prompt that incorporates canonical controls
|
||||
func buildBlockContentPrompt(module TrainingModule, controls []CanonicalControlSummary, language string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if language == "en" {
|
||||
sb.WriteString(fmt.Sprintf("Create training material for the following compliance module:\n\n"))
|
||||
sb.WriteString(fmt.Sprintf("**Module Code:** %s\n", module.ModuleCode))
|
||||
sb.WriteString(fmt.Sprintf("**Title:** %s\n", module.Title))
|
||||
sb.WriteString(fmt.Sprintf("**Duration:** %d minutes\n\n", module.DurationMinutes))
|
||||
sb.WriteString(fmt.Sprintf("This module is based on %d security controls:\n\n", len(controls)))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Erstelle Schulungsmaterial fuer folgendes Compliance-Modul:\n\n"))
|
||||
sb.WriteString(fmt.Sprintf("**Modulcode:** %s\n", module.ModuleCode))
|
||||
sb.WriteString(fmt.Sprintf("**Titel:** %s\n", module.Title))
|
||||
sb.WriteString(fmt.Sprintf("**Dauer:** %d Minuten\n\n", module.DurationMinutes))
|
||||
sb.WriteString(fmt.Sprintf("Dieses Modul basiert auf %d Sicherheits-Controls:\n\n", len(controls)))
|
||||
}
|
||||
|
||||
for i, ctrl := range controls {
|
||||
sb.WriteString(fmt.Sprintf("### Control %d: %s — %s\n", i+1, ctrl.ControlID, ctrl.Title))
|
||||
sb.WriteString(fmt.Sprintf("**Ziel:** %s\n", ctrl.Objective))
|
||||
if len(ctrl.Requirements) > 0 {
|
||||
sb.WriteString("**Anforderungen:**\n")
|
||||
for _, req := range ctrl.Requirements {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", req))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if language == "en" {
|
||||
sb.WriteString(`Create the material as Markdown:
|
||||
1. Introduction: Why are these controls important?
|
||||
2. Per control: Explanation, practical tips, examples
|
||||
3. Summary + action items
|
||||
4. Checklist for daily work
|
||||
|
||||
Use clear, understandable language. Target audience: employees in companies (50-1,500 employees).`)
|
||||
} else {
|
||||
sb.WriteString(`Erstelle das Material als Markdown:
|
||||
1. Einfuehrung: Warum sind diese Controls wichtig?
|
||||
2. Pro Control: Erklaerung, praktische Hinweise, Beispiele
|
||||
3. Zusammenfassung + Handlungsanweisungen
|
||||
4. Checkliste fuer den Alltag
|
||||
|
||||
Verwende klare, verstaendliche Sprache. Zielgruppe sind Mitarbeiter in Unternehmen (50-1.500 MA).
|
||||
Formatiere den Inhalt als Markdown mit Ueberschriften, Aufzaehlungen und Hervorhebungen.`)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
@@ -600,3 +727,252 @@ func truncateText(text string, maxLen int) string {
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video Pipeline
|
||||
// ============================================================================
|
||||
|
||||
const narratorSystemPrompt = `Du bist ein professioneller AI Teacher fuer Compliance-Schulungen.
|
||||
Dein Stil ist foermlich aber freundlich, klar und paedagogisch wertvoll.
|
||||
Du sprichst die Lernenden direkt an ("Sie") und fuehrst sie durch die Schulung.
|
||||
Du erzeugst IMMER deutschsprachige Inhalte.
|
||||
|
||||
Dein Output ist ein JSON-Objekt im Format NarratorScript.
|
||||
Jede Section sollte etwa 3 Minuten Sprechzeit haben (~450 Woerter Narrator-Text).
|
||||
Nach jeder Section kommt ein Checkpoint mit 3-5 Quiz-Fragen.
|
||||
Die Fragen testen das Verstaendnis des gerade Gelernten.
|
||||
Jede Frage hat genau 4 Antwortmoeglichkeiten, wobei correct_index (0-basiert) die richtige Antwort angibt.
|
||||
|
||||
Antworte NUR mit dem JSON-Objekt, ohne Markdown-Codeblock-Wrapper.`
|
||||
|
||||
// GenerateNarratorScript generates a narrator-style video script with checkpoints via LLM
|
||||
func (g *ContentGenerator) GenerateNarratorScript(ctx context.Context, module TrainingModule) (*NarratorScript, error) {
|
||||
content, err := g.store.GetPublishedContent(ctx, module.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get content: %w", err)
|
||||
}
|
||||
|
||||
contentContext := ""
|
||||
if content != nil {
|
||||
contentContext = fmt.Sprintf("\n\n**Vorhandener Schulungsinhalt (als Basis):**\n%s", truncateText(content.ContentBody, 4000))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Erstelle ein interaktives Schulungsvideo-Skript mit Erzaehlerpersona und Checkpoints.
|
||||
|
||||
**Modul:** %s — %s
|
||||
**Verordnung:** %s
|
||||
**Beschreibung:** %s
|
||||
**Dauer:** ca. %d Minuten
|
||||
%s
|
||||
|
||||
Erstelle ein NarratorScript-JSON mit:
|
||||
- "title": Titel der Schulung
|
||||
- "intro": Begruessungstext ("Hallo, ich bin Ihr AI Teacher. Heute lernen Sie...")
|
||||
- "sections": Array mit 3-4 Abschnitten, jeder mit:
|
||||
- "heading": Abschnittsueberschrift
|
||||
- "narrator_text": Fliesstext im Erzaehlstil (~450 Woerter, ~3 Min Sprechzeit)
|
||||
- "bullet_points": 3-5 Kernpunkte fuer die Folie
|
||||
- "transition": Ueberleitung zum naechsten Abschnitt oder Checkpoint
|
||||
- "checkpoint": Quiz-Block mit:
|
||||
- "title": Checkpoint-Titel
|
||||
- "questions": Array mit 3-5 Fragen, je:
|
||||
- "question": Fragetext
|
||||
- "options": Array mit 4 Antworten
|
||||
- "correct_index": Index der richtigen Antwort (0-basiert)
|
||||
- "explanation": Erklaerung der richtigen Antwort
|
||||
- "outro": Abschlussworte
|
||||
- "total_duration_estimate": geschaetzte Gesamtdauer in Sekunden
|
||||
|
||||
Antworte NUR mit dem JSON-Objekt.`,
|
||||
module.ModuleCode, module.Title,
|
||||
string(module.RegulationArea),
|
||||
module.Description,
|
||||
module.DurationMinutes,
|
||||
contentContext,
|
||||
)
|
||||
|
||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: narratorSystemPrompt},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.2,
|
||||
MaxTokens: 8192,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM narrator script generation failed: %w", err)
|
||||
}
|
||||
|
||||
return parseNarratorScript(resp.Message.Content)
|
||||
}
|
||||
|
||||
// parseNarratorScript extracts a NarratorScript from LLM output
|
||||
func parseNarratorScript(content string) (*NarratorScript, error) {
|
||||
// Find JSON object in response
|
||||
start := strings.Index(content, "{")
|
||||
end := strings.LastIndex(content, "}")
|
||||
if start < 0 || end <= start {
|
||||
return nil, fmt.Errorf("no JSON object found in LLM response")
|
||||
}
|
||||
jsonStr := content[start : end+1]
|
||||
|
||||
var script NarratorScript
|
||||
if err := json.Unmarshal([]byte(jsonStr), &script); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse narrator script JSON: %w", err)
|
||||
}
|
||||
|
||||
if len(script.Sections) == 0 {
|
||||
return nil, fmt.Errorf("narrator script has no sections")
|
||||
}
|
||||
|
||||
return &script, nil
|
||||
}
|
||||
|
||||
// GenerateInteractiveVideo orchestrates the full interactive video pipeline:
|
||||
// NarratorScript → TTS Audio → Slides+Video → DB Checkpoints + Quiz Questions
|
||||
func (g *ContentGenerator) GenerateInteractiveVideo(ctx context.Context, module TrainingModule) (*TrainingMedia, error) {
|
||||
if g.ttsClient == nil {
|
||||
return nil, fmt.Errorf("TTS client not configured")
|
||||
}
|
||||
|
||||
// 1. Generate NarratorScript via LLM
|
||||
script, err := g.GenerateNarratorScript(ctx, module)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("narrator script generation failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. Synthesize audio per section via TTS service
|
||||
sections := make([]SectionAudio, len(script.Sections))
|
||||
for i, s := range script.Sections {
|
||||
// Combine narrator text with intro/outro for first/last section
|
||||
text := s.NarratorText
|
||||
if i == 0 && script.Intro != "" {
|
||||
text = script.Intro + "\n\n" + text
|
||||
}
|
||||
if i == len(script.Sections)-1 && script.Outro != "" {
|
||||
text = text + "\n\n" + script.Outro
|
||||
}
|
||||
sections[i] = SectionAudio{
|
||||
Text: text,
|
||||
Heading: s.Heading,
|
||||
}
|
||||
}
|
||||
|
||||
audioResp, err := g.ttsClient.SynthesizeSections(ctx, &SynthesizeSectionsRequest{
|
||||
Sections: sections,
|
||||
Voice: "de_DE-thorsten-high",
|
||||
ModuleID: module.ID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("section audio synthesis failed: %w", err)
|
||||
}
|
||||
|
||||
// 3. Generate interactive video via TTS service
|
||||
videoResp, err := g.ttsClient.GenerateInteractiveVideo(ctx, &GenerateInteractiveVideoRequest{
|
||||
Script: script,
|
||||
Audio: audioResp,
|
||||
ModuleID: module.ID.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("interactive video generation failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. Save TrainingMedia record
|
||||
scriptJSON, _ := json.Marshal(script)
|
||||
media := &TrainingMedia{
|
||||
ModuleID: module.ID,
|
||||
MediaType: MediaTypeInteractiveVideo,
|
||||
Status: MediaStatusProcessing,
|
||||
Bucket: "compliance-training-video",
|
||||
ObjectKey: fmt.Sprintf("video/%s/interactive.mp4", module.ID.String()),
|
||||
MimeType: "video/mp4",
|
||||
Language: "de",
|
||||
GeneratedBy: "tts_ffmpeg_interactive",
|
||||
Metadata: scriptJSON,
|
||||
}
|
||||
|
||||
if err := g.store.CreateMedia(ctx, media); err != nil {
|
||||
return nil, fmt.Errorf("failed to create media record: %w", err)
|
||||
}
|
||||
|
||||
// Update media with video result
|
||||
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, "")
|
||||
|
||||
// Auto-publish
|
||||
g.store.PublishMedia(ctx, media.ID, true)
|
||||
|
||||
// 5. Create Checkpoints + Quiz Questions in DB
|
||||
// Clear old checkpoints first
|
||||
g.store.DeleteCheckpointsForModule(ctx, module.ID)
|
||||
|
||||
for i, section := range script.Sections {
|
||||
if section.Checkpoint == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate timestamp from cumulative audio durations
|
||||
var timestamp float64
|
||||
if i < len(audioResp.Sections) {
|
||||
// Checkpoint timestamp = end of this section's audio
|
||||
timestamp = audioResp.Sections[i].StartTimestamp + audioResp.Sections[i].Duration
|
||||
}
|
||||
|
||||
cp := &Checkpoint{
|
||||
ModuleID: module.ID,
|
||||
CheckpointIndex: i,
|
||||
Title: section.Checkpoint.Title,
|
||||
TimestampSeconds: timestamp,
|
||||
}
|
||||
if err := g.store.CreateCheckpoint(ctx, cp); err != nil {
|
||||
return nil, fmt.Errorf("failed to create checkpoint %d: %w", i, err)
|
||||
}
|
||||
|
||||
// Save quiz questions for this checkpoint
|
||||
for j, q := range section.Checkpoint.Questions {
|
||||
question := &QuizQuestion{
|
||||
ModuleID: module.ID,
|
||||
Question: q.Question,
|
||||
Options: q.Options,
|
||||
CorrectIndex: q.CorrectIndex,
|
||||
Explanation: q.Explanation,
|
||||
Difficulty: DifficultyMedium,
|
||||
SortOrder: j,
|
||||
}
|
||||
if err := g.store.CreateCheckpointQuizQuestion(ctx, question, cp.ID); err != nil {
|
||||
return nil, fmt.Errorf("failed to create checkpoint question: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Audit log
|
||||
g.store.LogAction(ctx, &AuditLogEntry{
|
||||
TenantID: module.TenantID,
|
||||
Action: AuditAction("interactive_video_generated"),
|
||||
EntityType: AuditEntityModule,
|
||||
EntityID: &module.ID,
|
||||
Details: map[string]interface{}{
|
||||
"module_code": module.ModuleCode,
|
||||
"media_id": media.ID.String(),
|
||||
"duration_seconds": videoResp.DurationSeconds,
|
||||
"sections": len(script.Sections),
|
||||
"checkpoints": countCheckpoints(script),
|
||||
},
|
||||
})
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func countCheckpoints(script *NarratorScript) int {
|
||||
count := 0
|
||||
for _, s := range script.Sections {
|
||||
if s.Checkpoint != nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
552
ai-compliance-sdk/internal/training/content_generator_test.go
Normal file
552
ai-compliance-sdk/internal/training/content_generator_test.go
Normal file
@@ -0,0 +1,552 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// buildContentPrompt Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestBuildContentPrompt_ContainsModuleCode(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-TRAIN-001",
|
||||
Title: "DSGVO Grundlagen",
|
||||
Description: "Basis-Schulung",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
|
||||
prompt := buildContentPrompt(module, "de")
|
||||
|
||||
if !containsSubstring(prompt, "CP-TRAIN-001") {
|
||||
t.Error("Prompt should contain module code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentPrompt_ContainsTitle(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "DSGVO Grundlagen",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
|
||||
prompt := buildContentPrompt(module, "de")
|
||||
|
||||
if !containsSubstring(prompt, "DSGVO Grundlagen") {
|
||||
t.Error("Prompt should contain module title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentPrompt_ContainsRegulationLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
area RegulationArea
|
||||
expected string
|
||||
}{
|
||||
{"DSGVO", RegulationDSGVO, "Datenschutz-Grundverordnung"},
|
||||
{"NIS2", RegulationNIS2, "NIS-2-Richtlinie"},
|
||||
{"ISO27001", RegulationISO27001, "ISO 27001"},
|
||||
{"AIAct", RegulationAIAct, "AI Act"},
|
||||
{"GeschGehG", RegulationGeschGehG, "Geschaeftsgeheimnisgesetz"},
|
||||
{"HinSchG", RegulationHinSchG, "Hinweisgeberschutzgesetz"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test Module",
|
||||
RegulationArea: tt.area,
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
|
||||
prompt := buildContentPrompt(module, "de")
|
||||
|
||||
if !containsSubstring(prompt, tt.expected) {
|
||||
t.Errorf("Prompt should contain regulation label '%s' for area '%s'", tt.expected, tt.area)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentPrompt_ContainsDuration(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
DurationMinutes: 45,
|
||||
}
|
||||
|
||||
prompt := buildContentPrompt(module, "de")
|
||||
|
||||
if !containsSubstring(prompt, "45 Minuten") {
|
||||
t.Error("Prompt should contain duration in minutes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentPrompt_UnknownRegulationArea(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test",
|
||||
RegulationArea: RegulationArea("custom_regulation"),
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
|
||||
prompt := buildContentPrompt(module, "de")
|
||||
|
||||
if !containsSubstring(prompt, "custom_regulation") {
|
||||
t.Error("Unknown regulation area should fall back to raw string")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// buildQuizPrompt Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestBuildQuizPrompt_ContainsQuestionCount(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test Module",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
}
|
||||
|
||||
prompt := buildQuizPrompt(module, "", 10)
|
||||
|
||||
if !containsSubstring(prompt, "10") {
|
||||
t.Error("Quiz prompt should contain question count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuizPrompt_ContainsContentContext(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
}
|
||||
|
||||
prompt := buildQuizPrompt(module, "This is the module content about DSGVO.", 5)
|
||||
|
||||
if !containsSubstring(prompt, "This is the module content about DSGVO.") {
|
||||
t.Error("Quiz prompt should include content context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuizPrompt_TruncatesLongContent(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
}
|
||||
|
||||
// Create content longer than 3000 chars
|
||||
longContent := ""
|
||||
for i := 0; i < 400; i++ {
|
||||
longContent += "ABCDEFGHIJ" // 10 chars * 400 = 4000 chars
|
||||
}
|
||||
|
||||
prompt := buildQuizPrompt(module, longContent, 5)
|
||||
|
||||
if containsSubstring(prompt, longContent) {
|
||||
t.Error("Quiz prompt should truncate content longer than 3000 chars")
|
||||
}
|
||||
if !containsSubstring(prompt, "...") {
|
||||
t.Error("Truncated content should end with '...'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuizPrompt_EmptyContent(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "CP-001",
|
||||
Title: "Test",
|
||||
RegulationArea: RegulationDSGVO,
|
||||
}
|
||||
|
||||
prompt := buildQuizPrompt(module, "", 5)
|
||||
|
||||
if containsSubstring(prompt, "Schulungsinhalt als Kontext") {
|
||||
t.Error("Empty content should not add context section")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// parseQuizResponse Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestParseQuizResponse_ValidJSON(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `[
|
||||
{
|
||||
"question": "Was ist die DSGVO?",
|
||||
"options": ["EU-Verordnung", "Bundesgesetz", "Landesgesetz", "Internationale Konvention"],
|
||||
"correct_index": 0,
|
||||
"explanation": "Die DSGVO ist eine EU-Verordnung.",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
]`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 1 {
|
||||
t.Fatalf("Expected 1 question, got %d", len(questions))
|
||||
}
|
||||
if questions[0].Question != "Was ist die DSGVO?" {
|
||||
t.Errorf("Expected question text, got '%s'", questions[0].Question)
|
||||
}
|
||||
if questions[0].CorrectIndex != 0 {
|
||||
t.Errorf("Expected correct_index 0, got %d", questions[0].CorrectIndex)
|
||||
}
|
||||
if questions[0].Difficulty != DifficultyEasy {
|
||||
t.Errorf("Expected difficulty 'easy', got '%s'", questions[0].Difficulty)
|
||||
}
|
||||
if questions[0].ModuleID != moduleID {
|
||||
t.Error("Module ID should be set on parsed question")
|
||||
}
|
||||
if !questions[0].IsActive {
|
||||
t.Error("Parsed questions should be active by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_InvalidJSON(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
_, err := parseQuizResponse("not valid json at all", moduleID)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_JSONWithSurroundingText(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `Here are the questions:
|
||||
[
|
||||
{
|
||||
"question": "Test?",
|
||||
"options": ["A", "B", "C", "D"],
|
||||
"correct_index": 1,
|
||||
"explanation": "B is correct.",
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]
|
||||
I hope these are helpful!`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 1 {
|
||||
t.Fatalf("Expected 1 question, got %d", len(questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_SkipsMalformedOptions(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `[
|
||||
{
|
||||
"question": "Good question?",
|
||||
"options": ["A", "B", "C", "D"],
|
||||
"correct_index": 0,
|
||||
"explanation": "A is correct.",
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"question": "Bad question?",
|
||||
"options": ["A", "B"],
|
||||
"correct_index": 0,
|
||||
"explanation": "Only 2 options.",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
]`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 1 {
|
||||
t.Errorf("Expected 1 valid question (malformed should be skipped), got %d", len(questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_SkipsInvalidCorrectIndex(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `[
|
||||
{
|
||||
"question": "Bad index?",
|
||||
"options": ["A", "B", "C", "D"],
|
||||
"correct_index": 5,
|
||||
"explanation": "Index out of range.",
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 0 {
|
||||
t.Errorf("Expected 0 questions (invalid index should be skipped), got %d", len(questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_NegativeCorrectIndex(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `[
|
||||
{
|
||||
"question": "Negative index?",
|
||||
"options": ["A", "B", "C", "D"],
|
||||
"correct_index": -1,
|
||||
"explanation": "Negative index.",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
]`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 0 {
|
||||
t.Errorf("Expected 0 questions (negative index should be skipped), got %d", len(questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_DefaultsDifficultyToMedium(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `[
|
||||
{
|
||||
"question": "Test?",
|
||||
"options": ["A", "B", "C", "D"],
|
||||
"correct_index": 0,
|
||||
"explanation": "A is correct.",
|
||||
"difficulty": "unknown_difficulty"
|
||||
}
|
||||
]`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 1 {
|
||||
t.Fatalf("Expected 1 question, got %d", len(questions))
|
||||
}
|
||||
if questions[0].Difficulty != DifficultyMedium {
|
||||
t.Errorf("Expected difficulty to default to 'medium', got '%s'", questions[0].Difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_MultipleQuestions(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
response := `[
|
||||
{"question":"Q1?","options":["A","B","C","D"],"correct_index":0,"explanation":"","difficulty":"easy"},
|
||||
{"question":"Q2?","options":["A","B","C","D"],"correct_index":1,"explanation":"","difficulty":"medium"},
|
||||
{"question":"Q3?","options":["A","B","C","D"],"correct_index":2,"explanation":"","difficulty":"hard"}
|
||||
]`
|
||||
|
||||
questions, err := parseQuizResponse(response, moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 3 {
|
||||
t.Errorf("Expected 3 questions, got %d", len(questions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuizResponse_EmptyArray(t *testing.T) {
|
||||
moduleID := uuid.New()
|
||||
questions, err := parseQuizResponse("[]", moduleID)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(questions) != 0 {
|
||||
t.Errorf("Expected 0 questions, got %d", len(questions))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// truncateText Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestTruncateText_ShortText(t *testing.T) {
|
||||
result := truncateText("hello", 100)
|
||||
if result != "hello" {
|
||||
t.Errorf("Short text should not be truncated, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateText_ExactLength(t *testing.T) {
|
||||
result := truncateText("12345", 5)
|
||||
if result != "12345" {
|
||||
t.Errorf("Text at exact max length should not be truncated, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateText_LongText(t *testing.T) {
|
||||
result := truncateText("1234567890", 5)
|
||||
if result != "12345..." {
|
||||
t.Errorf("Expected '12345...', got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateText_EmptyString(t *testing.T) {
|
||||
result := truncateText("", 10)
|
||||
if result != "" {
|
||||
t.Errorf("Empty string should remain empty, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// System Prompt Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestGetContentSystemPrompt_German(t *testing.T) {
|
||||
prompt := getContentSystemPrompt("de")
|
||||
if !containsSubstring(prompt, "Compliance-Schulungsinhalte") {
|
||||
t.Error("German system prompt should mention Compliance-Schulungsinhalte")
|
||||
}
|
||||
if !containsSubstring(prompt, "Markdown") {
|
||||
t.Error("System prompt should mention Markdown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentSystemPrompt_English(t *testing.T) {
|
||||
prompt := getContentSystemPrompt("en")
|
||||
if !containsSubstring(prompt, "compliance training content") {
|
||||
t.Error("English system prompt should mention compliance training content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQuizSystemPrompt_ContainsJSONFormat(t *testing.T) {
|
||||
prompt := getQuizSystemPrompt()
|
||||
if !containsSubstring(prompt, "JSON") {
|
||||
t.Error("Quiz system prompt should mention JSON format")
|
||||
}
|
||||
if !containsSubstring(prompt, "correct_index") {
|
||||
t.Error("Quiz system prompt should show correct_index field")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// buildBlockContentPrompt Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestBuildBlockContentPrompt_ContainsModuleInfo(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "BLK-AUTH-001",
|
||||
Title: "Authentication Controls",
|
||||
DurationMinutes: 45,
|
||||
}
|
||||
controls := []CanonicalControlSummary{
|
||||
{
|
||||
ControlID: "AUTH-001",
|
||||
Title: "Multi-Factor Authentication",
|
||||
Objective: "Ensure MFA is enabled",
|
||||
Requirements: []string{"Enable MFA for all users"},
|
||||
},
|
||||
}
|
||||
|
||||
prompt := buildBlockContentPrompt(module, controls, "de")
|
||||
|
||||
if !containsSubstring(prompt, "BLK-AUTH-001") {
|
||||
t.Error("Block prompt should contain module code")
|
||||
}
|
||||
if !containsSubstring(prompt, "Authentication Controls") {
|
||||
t.Error("Block prompt should contain module title")
|
||||
}
|
||||
if !containsSubstring(prompt, "45 Minuten") {
|
||||
t.Error("Block prompt should contain duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlockContentPrompt_ContainsControlDetails(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "BLK-001",
|
||||
Title: "Test",
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
controls := []CanonicalControlSummary{
|
||||
{
|
||||
ControlID: "CTRL-001",
|
||||
Title: "Test Control",
|
||||
Objective: "Test objective",
|
||||
Requirements: []string{"Req 1", "Req 2"},
|
||||
},
|
||||
}
|
||||
|
||||
prompt := buildBlockContentPrompt(module, controls, "de")
|
||||
|
||||
if !containsSubstring(prompt, "CTRL-001") {
|
||||
t.Error("Prompt should contain control ID")
|
||||
}
|
||||
if !containsSubstring(prompt, "Test Control") {
|
||||
t.Error("Prompt should contain control title")
|
||||
}
|
||||
if !containsSubstring(prompt, "Test objective") {
|
||||
t.Error("Prompt should contain control objective")
|
||||
}
|
||||
if !containsSubstring(prompt, "Req 1") {
|
||||
t.Error("Prompt should contain control requirements")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlockContentPrompt_EnglishVersion(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "BLK-001",
|
||||
Title: "Test",
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
controls := []CanonicalControlSummary{}
|
||||
|
||||
prompt := buildBlockContentPrompt(module, controls, "en")
|
||||
|
||||
if !containsSubstring(prompt, "Create training material") {
|
||||
t.Error("English prompt should use English text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBlockContentPrompt_MultipleControls(t *testing.T) {
|
||||
module := TrainingModule{
|
||||
ModuleCode: "BLK-001",
|
||||
Title: "Test",
|
||||
DurationMinutes: 30,
|
||||
}
|
||||
controls := []CanonicalControlSummary{
|
||||
{ControlID: "CTRL-001", Title: "First Control", Objective: "Obj 1"},
|
||||
{ControlID: "CTRL-002", Title: "Second Control", Objective: "Obj 2"},
|
||||
{ControlID: "CTRL-003", Title: "Third Control", Objective: "Obj 3"},
|
||||
}
|
||||
|
||||
prompt := buildBlockContentPrompt(module, controls, "de")
|
||||
|
||||
if !containsSubstring(prompt, "3 Sicherheits-Controls") {
|
||||
t.Error("Prompt should mention the count of controls")
|
||||
}
|
||||
if !containsSubstring(prompt, "Control 1") {
|
||||
t.Error("Prompt should number controls")
|
||||
}
|
||||
if !containsSubstring(prompt, "Control 3") {
|
||||
t.Error("Prompt should include all controls")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
func containsSubstring(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchSubstring(s, substr)
|
||||
}
|
||||
|
||||
func searchSubstring(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
159
ai-compliance-sdk/internal/training/escalation_test.go
Normal file
159
ai-compliance-sdk/internal/training/escalation_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Escalation Threshold Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestEscalationThresholds_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold int
|
||||
expected int
|
||||
}{
|
||||
{"L1 is 7 days", EscalationThresholdL1, 7},
|
||||
{"L2 is 14 days", EscalationThresholdL2, 14},
|
||||
{"L3 is 30 days", EscalationThresholdL3, 30},
|
||||
{"L4 is 45 days", EscalationThresholdL4, 45},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.threshold != tt.expected {
|
||||
t.Errorf("Expected %d, got %d", tt.expected, tt.threshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalationThresholds_Ascending(t *testing.T) {
|
||||
if EscalationThresholdL1 >= EscalationThresholdL2 {
|
||||
t.Errorf("L1 (%d) should be < L2 (%d)", EscalationThresholdL1, EscalationThresholdL2)
|
||||
}
|
||||
if EscalationThresholdL2 >= EscalationThresholdL3 {
|
||||
t.Errorf("L2 (%d) should be < L3 (%d)", EscalationThresholdL2, EscalationThresholdL3)
|
||||
}
|
||||
if EscalationThresholdL3 >= EscalationThresholdL4 {
|
||||
t.Errorf("L3 (%d) should be < L4 (%d)", EscalationThresholdL3, EscalationThresholdL4)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Escalation Label Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestEscalationLabels_AllLevelsPresent(t *testing.T) {
|
||||
expectedLevels := []int{0, 1, 2, 3, 4}
|
||||
for _, level := range expectedLevels {
|
||||
label, ok := EscalationLabels[level]
|
||||
if !ok {
|
||||
t.Errorf("Missing label for escalation level %d", level)
|
||||
}
|
||||
if label == "" {
|
||||
t.Errorf("Empty label for escalation level %d", level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalationLabels_Level0_NoEscalation(t *testing.T) {
|
||||
label := EscalationLabels[0]
|
||||
if label != "Keine Eskalation" {
|
||||
t.Errorf("Expected 'Keine Eskalation', got '%s'", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalationLabels_Level4_ComplianceOfficer(t *testing.T) {
|
||||
label := EscalationLabels[4]
|
||||
if label != "Benachrichtigung Compliance Officer" {
|
||||
t.Errorf("Expected 'Benachrichtigung Compliance Officer', got '%s'", label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalationLabels_NoExtraLevels(t *testing.T) {
|
||||
if len(EscalationLabels) != 5 {
|
||||
t.Errorf("Expected exactly 5 escalation levels (0-4), got %d", len(EscalationLabels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscalationLabels_LevelContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
level int
|
||||
contains string
|
||||
}{
|
||||
{1, "Mitarbeiter"},
|
||||
{2, "Teamleitung"},
|
||||
{3, "Management"},
|
||||
{4, "Compliance Officer"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(EscalationLabels[tt.level], func(t *testing.T) {
|
||||
label := EscalationLabels[tt.level]
|
||||
if label == "" {
|
||||
t.Fatalf("Label for level %d is empty", tt.level)
|
||||
}
|
||||
found := false
|
||||
for i := 0; i <= len(label)-len(tt.contains); i++ {
|
||||
if label[i:i+len(tt.contains)] == tt.contains {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Label '%s' should contain '%s'", label, tt.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Role Constants and Labels Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestRoleLabels_AllRolesHaveLabels(t *testing.T) {
|
||||
roles := []string{RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10}
|
||||
for _, role := range roles {
|
||||
label, ok := RoleLabels[role]
|
||||
if !ok {
|
||||
t.Errorf("Missing label for role %s", role)
|
||||
}
|
||||
if label == "" {
|
||||
t.Errorf("Empty label for role %s", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNIS2RoleMapping_AllRolesMapped(t *testing.T) {
|
||||
roles := []string{RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10}
|
||||
for _, role := range roles {
|
||||
nis2Level, ok := NIS2RoleMapping[role]
|
||||
if !ok {
|
||||
t.Errorf("Missing NIS2 mapping for role %s", role)
|
||||
}
|
||||
if nis2Level == "" {
|
||||
t.Errorf("Empty NIS2 level for role %s", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetAudienceRoleMapping_AllAudiencesPresent(t *testing.T) {
|
||||
audiences := []string{"enterprise", "authority", "provider", "all"}
|
||||
for _, aud := range audiences {
|
||||
roles, ok := TargetAudienceRoleMapping[aud]
|
||||
if !ok {
|
||||
t.Errorf("Missing audience mapping for '%s'", aud)
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
t.Errorf("Empty roles for audience '%s'", aud)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetAudienceRoleMapping_AllContainsAllRoles(t *testing.T) {
|
||||
allRoles := TargetAudienceRoleMapping["all"]
|
||||
if len(allRoles) != 10 {
|
||||
t.Errorf("Expected 'all' audience to map to 10 roles, got %d", len(allRoles))
|
||||
}
|
||||
}
|
||||
801
ai-compliance-sdk/internal/training/interactive_video_test.go
Normal file
801
ai-compliance-sdk/internal/training/interactive_video_test.go
Normal file
@@ -0,0 +1,801 @@
|
||||
package training
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// parseNarratorScript Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestParseNarratorScript_ValidJSON(t *testing.T) {
|
||||
input := `{
|
||||
"title": "DSGVO Grundlagen",
|
||||
"intro": "Hallo, ich bin Ihr AI Teacher.",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Einfuehrung",
|
||||
"narrator_text": "Willkommen zur Schulung ueber die DSGVO.",
|
||||
"bullet_points": ["Punkt 1", "Punkt 2"],
|
||||
"transition": "Bevor wir fortfahren...",
|
||||
"checkpoint": {
|
||||
"title": "Checkpoint 1",
|
||||
"questions": [
|
||||
{
|
||||
"question": "Was ist die DSGVO?",
|
||||
"options": ["EU-Verordnung", "Bundesgesetz", "Landesgesetz", "Internationale Konvention"],
|
||||
"correct_index": 0,
|
||||
"explanation": "Die DSGVO ist eine EU-Verordnung."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outro": "Vielen Dank fuer Ihre Aufmerksamkeit.",
|
||||
"total_duration_estimate": 600
|
||||
}`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if script.Title != "DSGVO Grundlagen" {
|
||||
t.Errorf("Expected title 'DSGVO Grundlagen', got '%s'", script.Title)
|
||||
}
|
||||
if script.Intro != "Hallo, ich bin Ihr AI Teacher." {
|
||||
t.Errorf("Expected intro text, got '%s'", script.Intro)
|
||||
}
|
||||
if len(script.Sections) != 1 {
|
||||
t.Fatalf("Expected 1 section, got %d", len(script.Sections))
|
||||
}
|
||||
if script.Sections[0].Heading != "Einfuehrung" {
|
||||
t.Errorf("Expected heading 'Einfuehrung', got '%s'", script.Sections[0].Heading)
|
||||
}
|
||||
if script.Sections[0].Checkpoint == nil {
|
||||
t.Fatal("Expected checkpoint, got nil")
|
||||
}
|
||||
if len(script.Sections[0].Checkpoint.Questions) != 1 {
|
||||
t.Fatalf("Expected 1 question, got %d", len(script.Sections[0].Checkpoint.Questions))
|
||||
}
|
||||
if script.Sections[0].Checkpoint.Questions[0].CorrectIndex != 0 {
|
||||
t.Errorf("Expected correct_index 0, got %d", script.Sections[0].Checkpoint.Questions[0].CorrectIndex)
|
||||
}
|
||||
if script.Outro != "Vielen Dank fuer Ihre Aufmerksamkeit." {
|
||||
t.Errorf("Expected outro text, got '%s'", script.Outro)
|
||||
}
|
||||
if script.TotalDurationEstimate != 600 {
|
||||
t.Errorf("Expected 600 seconds estimate, got %d", script.TotalDurationEstimate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_WithSurroundingText(t *testing.T) {
|
||||
input := `Here is the narrator script:
|
||||
{
|
||||
"title": "NIS-2 Schulung",
|
||||
"intro": "Willkommen",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Abschnitt 1",
|
||||
"narrator_text": "Text hier.",
|
||||
"bullet_points": ["BP1"],
|
||||
"transition": "Weiter"
|
||||
}
|
||||
],
|
||||
"outro": "Ende",
|
||||
"total_duration_estimate": 300
|
||||
}
|
||||
I hope this helps!`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if script.Title != "NIS-2 Schulung" {
|
||||
t.Errorf("Expected title 'NIS-2 Schulung', got '%s'", script.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_InvalidJSON(t *testing.T) {
|
||||
_, err := parseNarratorScript("not valid json")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_NoSections(t *testing.T) {
|
||||
input := `{"title": "Test", "intro": "Hi", "sections": [], "outro": "Bye", "total_duration_estimate": 0}`
|
||||
_, err := parseNarratorScript(input)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty sections")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_NoJSON(t *testing.T) {
|
||||
_, err := parseNarratorScript("Just plain text without any JSON")
|
||||
if err == nil {
|
||||
t.Error("Expected error when no JSON object found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_SectionWithoutCheckpoint(t *testing.T) {
|
||||
input := `{
|
||||
"title": "Test",
|
||||
"intro": "Hi",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Section 1",
|
||||
"narrator_text": "Some text",
|
||||
"bullet_points": ["P1"],
|
||||
"transition": "Next"
|
||||
}
|
||||
],
|
||||
"outro": "Bye",
|
||||
"total_duration_estimate": 180
|
||||
}`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if script.Sections[0].Checkpoint != nil {
|
||||
t.Error("Section without checkpoint definition should have nil Checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNarratorScript_MultipleSectionsWithCheckpoints(t *testing.T) {
|
||||
input := `{
|
||||
"title": "Multi-Section",
|
||||
"intro": "Start",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "S1",
|
||||
"narrator_text": "Text 1",
|
||||
"bullet_points": [],
|
||||
"transition": "T1",
|
||||
"checkpoint": {
|
||||
"title": "CP1",
|
||||
"questions": [
|
||||
{"question": "Q1?", "options": ["A", "B", "C", "D"], "correct_index": 0, "explanation": "E1"},
|
||||
{"question": "Q2?", "options": ["A", "B", "C", "D"], "correct_index": 1, "explanation": "E2"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"heading": "S2",
|
||||
"narrator_text": "Text 2",
|
||||
"bullet_points": ["BP"],
|
||||
"transition": "T2",
|
||||
"checkpoint": {
|
||||
"title": "CP2",
|
||||
"questions": [
|
||||
{"question": "Q3?", "options": ["A", "B", "C", "D"], "correct_index": 2, "explanation": "E3"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"heading": "S3",
|
||||
"narrator_text": "Text 3",
|
||||
"bullet_points": [],
|
||||
"transition": "T3"
|
||||
}
|
||||
],
|
||||
"outro": "End",
|
||||
"total_duration_estimate": 900
|
||||
}`
|
||||
|
||||
script, err := parseNarratorScript(input)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
if len(script.Sections) != 3 {
|
||||
t.Fatalf("Expected 3 sections, got %d", len(script.Sections))
|
||||
}
|
||||
if script.Sections[0].Checkpoint == nil {
|
||||
t.Error("Section 0 should have a checkpoint")
|
||||
}
|
||||
if len(script.Sections[0].Checkpoint.Questions) != 2 {
|
||||
t.Errorf("Section 0 checkpoint should have 2 questions, got %d", len(script.Sections[0].Checkpoint.Questions))
|
||||
}
|
||||
if script.Sections[1].Checkpoint == nil {
|
||||
t.Error("Section 1 should have a checkpoint")
|
||||
}
|
||||
if script.Sections[2].Checkpoint != nil {
|
||||
t.Error("Section 2 should not have a checkpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// countCheckpoints Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestCountCheckpoints_WithCheckpoints(t *testing.T) {
|
||||
script := &NarratorScript{
|
||||
Sections: []NarratorSection{
|
||||
{Checkpoint: &CheckpointDefinition{Title: "CP1"}},
|
||||
{Checkpoint: nil},
|
||||
{Checkpoint: &CheckpointDefinition{Title: "CP3"}},
|
||||
},
|
||||
}
|
||||
|
||||
count := countCheckpoints(script)
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 checkpoints, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountCheckpoints_NoCheckpoints(t *testing.T) {
|
||||
script := &NarratorScript{
|
||||
Sections: []NarratorSection{
|
||||
{Heading: "S1"},
|
||||
{Heading: "S2"},
|
||||
},
|
||||
}
|
||||
|
||||
count := countCheckpoints(script)
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 checkpoints, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountCheckpoints_EmptySections(t *testing.T) {
|
||||
script := &NarratorScript{}
|
||||
count := countCheckpoints(script)
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 checkpoints, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NarratorScript JSON Serialization Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNarratorScript_JSONRoundTrip(t *testing.T) {
|
||||
original := NarratorScript{
|
||||
Title: "Test",
|
||||
Intro: "Hello",
|
||||
Sections: []NarratorSection{
|
||||
{
|
||||
Heading: "H1",
|
||||
NarratorText: "NT1",
|
||||
BulletPoints: []string{"BP1"},
|
||||
Transition: "T1",
|
||||
Checkpoint: &CheckpointDefinition{
|
||||
Title: "CP1",
|
||||
Questions: []CheckpointQuestion{
|
||||
{
|
||||
Question: "Q?",
|
||||
Options: []string{"A", "B", "C", "D"},
|
||||
CorrectIndex: 2,
|
||||
Explanation: "C is correct",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Outro: "Bye",
|
||||
TotalDurationEstimate: 600,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded NarratorScript
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Title != original.Title {
|
||||
t.Errorf("Title mismatch: %s != %s", decoded.Title, original.Title)
|
||||
}
|
||||
if len(decoded.Sections) != 1 {
|
||||
t.Fatalf("Expected 1 section, got %d", len(decoded.Sections))
|
||||
}
|
||||
if decoded.Sections[0].Checkpoint == nil {
|
||||
t.Fatal("Checkpoint should not be nil after round-trip")
|
||||
}
|
||||
if decoded.Sections[0].Checkpoint.Questions[0].CorrectIndex != 2 {
|
||||
t.Errorf("CorrectIndex mismatch: got %d", decoded.Sections[0].Checkpoint.Questions[0].CorrectIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// InteractiveVideoManifest Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestInteractiveVideoManifest_JSON(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
StreamURL: "https://example.com/video.mp4",
|
||||
Checkpoints: []CheckpointManifestEntry{
|
||||
{
|
||||
Index: 0,
|
||||
Title: "CP1",
|
||||
TimestampSeconds: 180.5,
|
||||
Questions: []CheckpointQuestion{
|
||||
{
|
||||
Question: "Q?",
|
||||
Options: []string{"A", "B", "C", "D"},
|
||||
CorrectIndex: 1,
|
||||
Explanation: "B",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded InteractiveVideoManifest
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Checkpoints) != 1 {
|
||||
t.Fatalf("Expected 1 checkpoint, got %d", len(decoded.Checkpoints))
|
||||
}
|
||||
if decoded.Checkpoints[0].TimestampSeconds != 180.5 {
|
||||
t.Errorf("Timestamp mismatch: got %f", decoded.Checkpoints[0].TimestampSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SubmitCheckpointQuizRequest/Response Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestSubmitCheckpointQuizResponse_JSON(t *testing.T) {
|
||||
resp := SubmitCheckpointQuizResponse{
|
||||
Passed: true,
|
||||
Score: 80.0,
|
||||
Feedback: []CheckpointQuizFeedback{
|
||||
{Question: "Q1?", Correct: true, Explanation: "Correct!"},
|
||||
{Question: "Q2?", Correct: false, Explanation: "Wrong answer."},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var decoded SubmitCheckpointQuizResponse
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
|
||||
if !decoded.Passed {
|
||||
t.Error("Expected passed=true")
|
||||
}
|
||||
if decoded.Score != 80.0 {
|
||||
t.Errorf("Expected score 80.0, got %f", decoded.Score)
|
||||
}
|
||||
if len(decoded.Feedback) != 2 {
|
||||
t.Fatalf("Expected 2 feedback items, got %d", len(decoded.Feedback))
|
||||
}
|
||||
if decoded.Feedback[1].Correct {
|
||||
t.Error("Second feedback should be incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// narratorSystemPrompt Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNarratorSystemPrompt_ContainsKeyPhrases(t *testing.T) {
|
||||
if !containsSubstring(narratorSystemPrompt, "AI Teacher") {
|
||||
t.Error("System prompt should mention AI Teacher")
|
||||
}
|
||||
if !containsSubstring(narratorSystemPrompt, "Checkpoint") {
|
||||
t.Error("System prompt should mention Checkpoint")
|
||||
}
|
||||
if !containsSubstring(narratorSystemPrompt, "JSON") {
|
||||
t.Error("System prompt should mention JSON format")
|
||||
}
|
||||
if !containsSubstring(narratorSystemPrompt, "correct_index") {
|
||||
t.Error("System prompt should mention correct_index")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Checkpoint Grading Logic Tests (User Journey: Learner scores quiz)
|
||||
// =============================================================================
|
||||
|
||||
func TestCheckpointGrading_AllCorrect_ScoreIs100(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{0, 1, 2}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if score != 100.0 {
|
||||
t.Errorf("Expected score 100, got %f", score)
|
||||
}
|
||||
if !passed {
|
||||
t.Error("Expected passed=true with 100% score")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_NoneCorrect_ScoreIs0(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{3, 3, 3}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if score != 0.0 {
|
||||
t.Errorf("Expected score 0, got %f", score)
|
||||
}
|
||||
if passed {
|
||||
t.Error("Expected passed=false with 0% score")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_ExactlyAt70Percent_Passes(t *testing.T) {
|
||||
// 7 out of 10 correct = 70% — exactly at threshold
|
||||
questions := make([]CheckpointQuestion, 10)
|
||||
answers := make([]int, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
questions[i] = CheckpointQuestion{
|
||||
Question: "Q?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0,
|
||||
}
|
||||
if i < 7 {
|
||||
answers[i] = 0 // correct
|
||||
} else {
|
||||
answers[i] = 1 // wrong
|
||||
}
|
||||
}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if score != 70.0 {
|
||||
t.Errorf("Expected score 70, got %f", score)
|
||||
}
|
||||
if !passed {
|
||||
t.Error("Expected passed=true at exactly 70%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_JustBelow70Percent_Fails(t *testing.T) {
|
||||
// 2 out of 3 correct = 66.67% — below threshold
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{0, 1, 3} // 2 correct, 1 wrong
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
passed := score >= 70
|
||||
|
||||
if passed {
|
||||
t.Errorf("Expected passed=false at %.2f%%", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_FewerAnswersThanQuestions_MarksUnansweredWrong(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 1},
|
||||
{Question: "Q3?", Options: []string{"A", "B", "C", "D"}, CorrectIndex: 2},
|
||||
}
|
||||
answers := []int{0} // Only 1 answer for 3 questions
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
|
||||
if correctCount != 1 {
|
||||
t.Errorf("Expected 1 correct, got %d", correctCount)
|
||||
}
|
||||
score := float64(correctCount) / float64(len(questions)) * 100
|
||||
if score > 34 {
|
||||
t.Errorf("Expected score ~33.3%%, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointGrading_EmptyAnswers_AllWrong(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Q1?", Options: []string{"A", "B"}, CorrectIndex: 0},
|
||||
{Question: "Q2?", Options: []string{"A", "B"}, CorrectIndex: 1},
|
||||
}
|
||||
answers := []int{}
|
||||
|
||||
correctCount := 0
|
||||
for i, q := range questions {
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
|
||||
if correctCount != 0 {
|
||||
t.Errorf("Expected 0 correct with empty answers, got %d", correctCount)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Feedback Generation Tests (User Journey: Learner sees feedback)
|
||||
// =============================================================================
|
||||
|
||||
func TestCheckpointFeedback_CorrectAnswerGetsCorrectFlag(t *testing.T) {
|
||||
questions := []CheckpointQuestion{
|
||||
{Question: "Was ist DSGVO?", Options: []string{"EU-Verordnung", "Bundesgesetz"}, CorrectIndex: 0, Explanation: "EU-Verordnung"},
|
||||
{Question: "Wer ist DSB?", Options: []string{"IT-Leiter", "Datenschutzbeauftragter"}, CorrectIndex: 1, Explanation: "DSB Rolle"},
|
||||
}
|
||||
answers := []int{0, 0} // First correct, second wrong
|
||||
|
||||
feedback := make([]CheckpointQuizFeedback, len(questions))
|
||||
for i, q := range questions {
|
||||
isCorrect := false
|
||||
if i < len(answers) && answers[i] == q.CorrectIndex {
|
||||
isCorrect = true
|
||||
}
|
||||
feedback[i] = CheckpointQuizFeedback{
|
||||
Question: q.Question,
|
||||
Correct: isCorrect,
|
||||
Explanation: q.Explanation,
|
||||
}
|
||||
}
|
||||
|
||||
if !feedback[0].Correct {
|
||||
t.Error("First answer should be marked correct")
|
||||
}
|
||||
if feedback[1].Correct {
|
||||
t.Error("Second answer should be marked incorrect")
|
||||
}
|
||||
if feedback[0].Question != "Was ist DSGVO?" {
|
||||
t.Errorf("Unexpected question text: %s", feedback[0].Question)
|
||||
}
|
||||
if feedback[1].Explanation != "DSB Rolle" {
|
||||
t.Errorf("Explanation should be preserved: got %s", feedback[1].Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NarratorScript Pipeline Tests (User Journey: Admin generates video)
|
||||
// =============================================================================
|
||||
|
||||
func TestNarratorScript_SectionCounting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sectionCount int
|
||||
checkpointCount int
|
||||
}{
|
||||
{"3 sections, all with checkpoints", 3, 3},
|
||||
{"4 sections, 2 with checkpoints", 4, 2},
|
||||
{"1 section, no checkpoint", 1, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sections := make([]NarratorSection, tt.sectionCount)
|
||||
cpAdded := 0
|
||||
for i := 0; i < tt.sectionCount; i++ {
|
||||
sections[i] = NarratorSection{
|
||||
Heading: "Section",
|
||||
NarratorText: "Text",
|
||||
BulletPoints: []string{},
|
||||
Transition: "Next",
|
||||
}
|
||||
if cpAdded < tt.checkpointCount {
|
||||
sections[i].Checkpoint = &CheckpointDefinition{
|
||||
Title: "CP",
|
||||
Questions: []CheckpointQuestion{{Question: "Q?", Options: []string{"A", "B"}, CorrectIndex: 0}},
|
||||
}
|
||||
cpAdded++
|
||||
}
|
||||
}
|
||||
|
||||
script := &NarratorScript{
|
||||
Title: "Test",
|
||||
Intro: "Hi",
|
||||
Sections: sections,
|
||||
Outro: "Bye",
|
||||
}
|
||||
|
||||
if len(script.Sections) != tt.sectionCount {
|
||||
t.Errorf("Expected %d sections, got %d", tt.sectionCount, len(script.Sections))
|
||||
}
|
||||
if countCheckpoints(script) != tt.checkpointCount {
|
||||
t.Errorf("Expected %d checkpoints, got %d", tt.checkpointCount, countCheckpoints(script))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarratorScript_SectionAudioConversion(t *testing.T) {
|
||||
// Verify NarratorSection can be converted to SectionAudio for TTS
|
||||
sections := []NarratorSection{
|
||||
{Heading: "Einleitung", NarratorText: "Willkommen zur Schulung."},
|
||||
{Heading: "Hauptteil", NarratorText: "Hier lernen Sie die Grundlagen."},
|
||||
}
|
||||
|
||||
audioSections := make([]SectionAudio, len(sections))
|
||||
for i, s := range sections {
|
||||
audioSections[i] = SectionAudio{
|
||||
Text: s.NarratorText,
|
||||
Heading: s.Heading,
|
||||
}
|
||||
}
|
||||
|
||||
if len(audioSections) != 2 {
|
||||
t.Fatalf("Expected 2 audio sections, got %d", len(audioSections))
|
||||
}
|
||||
if audioSections[0].Heading != "Einleitung" {
|
||||
t.Errorf("Expected heading 'Einleitung', got '%s'", audioSections[0].Heading)
|
||||
}
|
||||
if audioSections[1].Text != "Hier lernen Sie die Grundlagen." {
|
||||
t.Errorf("Unexpected text: '%s'", audioSections[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// InteractiveVideoManifest Progress Tests (User Journey: Learner resumes)
|
||||
// =============================================================================
|
||||
|
||||
func TestManifest_IdentifiesNextUnpassedCheckpoint(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
StreamURL: "https://example.com/video.mp4",
|
||||
Checkpoints: []CheckpointManifestEntry{
|
||||
{Index: 0, Title: "CP1", TimestampSeconds: 180, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, Title: "CP2", TimestampSeconds: 360, Progress: &CheckpointProgress{Passed: false}},
|
||||
{Index: 2, Title: "CP3", TimestampSeconds: 540, Progress: nil},
|
||||
},
|
||||
}
|
||||
|
||||
var nextUnpassed *CheckpointManifestEntry
|
||||
for i := range manifest.Checkpoints {
|
||||
cp := &manifest.Checkpoints[i]
|
||||
if cp.Progress == nil || !cp.Progress.Passed {
|
||||
nextUnpassed = cp
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if nextUnpassed == nil {
|
||||
t.Fatal("Expected to find an unpassed checkpoint")
|
||||
}
|
||||
if nextUnpassed.Index != 1 {
|
||||
t.Errorf("Expected next unpassed at index 1, got %d", nextUnpassed.Index)
|
||||
}
|
||||
if nextUnpassed.Title != "CP2" {
|
||||
t.Errorf("Expected CP2, got %s", nextUnpassed.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_AllCheckpointsPassed(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
Checkpoints: []CheckpointManifestEntry{
|
||||
{Index: 0, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, Progress: &CheckpointProgress{Passed: true}},
|
||||
},
|
||||
}
|
||||
|
||||
allPassed := true
|
||||
for _, cp := range manifest.Checkpoints {
|
||||
if cp.Progress == nil || !cp.Progress.Passed {
|
||||
allPassed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allPassed {
|
||||
t.Error("Expected all checkpoints to be passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_NoCheckpoints_AllPassedIsTrue(t *testing.T) {
|
||||
manifest := InteractiveVideoManifest{
|
||||
Checkpoints: []CheckpointManifestEntry{},
|
||||
}
|
||||
|
||||
allPassed := true
|
||||
for _, cp := range manifest.Checkpoints {
|
||||
if cp.Progress == nil || !cp.Progress.Passed {
|
||||
allPassed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allPassed {
|
||||
t.Error("Empty checkpoint list should be considered all-passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_SeekProtection_BlocksSkippingPastUnpassed(t *testing.T) {
|
||||
// Simulates seek protection logic from InteractiveVideoPlayer
|
||||
checkpoints := []CheckpointManifestEntry{
|
||||
{Index: 0, TimestampSeconds: 180, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, TimestampSeconds: 360, Progress: nil}, // Not yet attempted
|
||||
{Index: 2, TimestampSeconds: 540, Progress: nil},
|
||||
}
|
||||
|
||||
seekTarget := 500.0 // User tries to seek to 500s
|
||||
|
||||
// Find first unpassed checkpoint
|
||||
var firstUnpassed *CheckpointManifestEntry
|
||||
for i := range checkpoints {
|
||||
if checkpoints[i].Progress == nil || !checkpoints[i].Progress.Passed {
|
||||
firstUnpassed = &checkpoints[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
blocked := false
|
||||
if firstUnpassed != nil && seekTarget > firstUnpassed.TimestampSeconds {
|
||||
blocked = true
|
||||
}
|
||||
|
||||
if !blocked {
|
||||
t.Error("Seek past unpassed checkpoint should be blocked")
|
||||
}
|
||||
if firstUnpassed.TimestampSeconds != 360 {
|
||||
t.Errorf("Expected block at 360s, got %f", firstUnpassed.TimestampSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifest_SeekProtection_AllowsSeekBeforeFirstUnpassed(t *testing.T) {
|
||||
checkpoints := []CheckpointManifestEntry{
|
||||
{Index: 0, TimestampSeconds: 180, Progress: &CheckpointProgress{Passed: true}},
|
||||
{Index: 1, TimestampSeconds: 360, Progress: nil},
|
||||
}
|
||||
|
||||
seekTarget := 200.0 // User seeks to 200s — before unpassed checkpoint at 360s
|
||||
|
||||
var firstUnpassed *CheckpointManifestEntry
|
||||
for i := range checkpoints {
|
||||
if checkpoints[i].Progress == nil || !checkpoints[i].Progress.Passed {
|
||||
firstUnpassed = &checkpoints[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
blocked := false
|
||||
if firstUnpassed != nil && seekTarget > firstUnpassed.TimestampSeconds {
|
||||
blocked = true
|
||||
}
|
||||
|
||||
if blocked {
|
||||
t.Error("Seek before unpassed checkpoint should be allowed")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type MediaType string
|
||||
const (
|
||||
MediaTypeAudio MediaType = "audio"
|
||||
MediaTypeVideo MediaType = "video"
|
||||
MediaTypeInteractiveVideo MediaType = "interactive_video"
|
||||
)
|
||||
|
||||
// MediaStatus represents the processing status
|
||||
@@ -169,6 +170,57 @@ func (c *TTSClient) GenerateVideo(ctx context.Context, req *TTSGenerateVideoRequ
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// PresignedURLRequest is the request to get a presigned URL
|
||||
type PresignedURLRequest struct {
|
||||
Bucket string `json:"bucket"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Expires int `json:"expires"`
|
||||
}
|
||||
|
||||
// PresignedURLResponse is the response containing a presigned URL
|
||||
type PresignedURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// GetPresignedURL requests a presigned URL from the TTS service
|
||||
func (c *TTSClient) GetPresignedURL(ctx context.Context, bucket, objectKey string) (string, error) {
|
||||
reqBody := PresignedURLRequest{
|
||||
Bucket: bucket,
|
||||
ObjectKey: objectKey,
|
||||
Expires: 3600,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/presigned-url", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("TTS presigned URL request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TTS presigned URL error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result PresignedURLResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse presigned URL response: %w", err)
|
||||
}
|
||||
|
||||
return result.URL, 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)
|
||||
@@ -184,3 +236,115 @@ func (c *TTSClient) IsHealthy(ctx context.Context) bool {
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video TTS Client Methods
|
||||
// ============================================================================
|
||||
|
||||
// SynthesizeSectionsRequest is the request for batch section audio synthesis
|
||||
type SynthesizeSectionsRequest struct {
|
||||
Sections []SectionAudio `json:"sections"`
|
||||
Voice string `json:"voice"`
|
||||
ModuleID string `json:"module_id"`
|
||||
}
|
||||
|
||||
// SectionAudio represents one section's text for audio synthesis
|
||||
type SectionAudio struct {
|
||||
Text string `json:"text"`
|
||||
Heading string `json:"heading"`
|
||||
}
|
||||
|
||||
// SynthesizeSectionsResponse is the response from batch section synthesis
|
||||
type SynthesizeSectionsResponse struct {
|
||||
Sections []SectionResult `json:"sections"`
|
||||
TotalDuration float64 `json:"total_duration"`
|
||||
}
|
||||
|
||||
// SectionResult is the result for one section's audio
|
||||
type SectionResult struct {
|
||||
Heading string `json:"heading"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
AudioObjectKey string `json:"audio_object_key"`
|
||||
Duration float64 `json:"duration"`
|
||||
StartTimestamp float64 `json:"start_timestamp"`
|
||||
}
|
||||
|
||||
// GenerateInteractiveVideoRequest is the request for interactive video generation
|
||||
type GenerateInteractiveVideoRequest struct {
|
||||
Script *NarratorScript `json:"script"`
|
||||
Audio *SynthesizeSectionsResponse `json:"audio"`
|
||||
ModuleID string `json:"module_id"`
|
||||
}
|
||||
|
||||
// GenerateInteractiveVideoResponse is the response from interactive video generation
|
||||
type GenerateInteractiveVideoResponse 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"`
|
||||
}
|
||||
|
||||
// SynthesizeSections calls the TTS service to synthesize audio for multiple sections
|
||||
func (c *TTSClient) SynthesizeSections(ctx context.Context, req *SynthesizeSectionsRequest) (*SynthesizeSectionsResponse, 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-sections", 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 synthesize-sections request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TTS synthesize-sections error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result SynthesizeSectionsResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse TTS synthesize-sections response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GenerateInteractiveVideo calls the TTS service to create an interactive video with checkpoint slides
|
||||
func (c *TTSClient) GenerateInteractiveVideo(ctx context.Context, req *GenerateInteractiveVideoRequest) (*GenerateInteractiveVideoResponse, 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-interactive-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 interactive video request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TTS interactive video error (%d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result GenerateInteractiveVideoResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse TTS interactive video response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ const (
|
||||
RoleR7 = "R7" // Fachabteilung
|
||||
RoleR8 = "R8" // IT-Admin
|
||||
RoleR9 = "R9" // Alle Mitarbeiter
|
||||
RoleR10 = "R10" // Behoerden / Oeffentlicher Dienst
|
||||
)
|
||||
|
||||
// RoleLabels maps role codes to human-readable labels
|
||||
@@ -120,6 +121,7 @@ var RoleLabels = map[string]string{
|
||||
RoleR7: "Fachabteilung",
|
||||
RoleR8: "IT-Administration",
|
||||
RoleR9: "Alle Mitarbeiter",
|
||||
RoleR10: "Behoerden / Oeffentlicher Dienst",
|
||||
}
|
||||
|
||||
// NIS2RoleMapping maps internal roles to NIS2 levels
|
||||
@@ -133,6 +135,36 @@ var NIS2RoleMapping = map[string]string{
|
||||
RoleR7: "N5", // Fachabteilung
|
||||
RoleR8: "N2", // IT-Admin
|
||||
RoleR9: "N5", // Alle Mitarbeiter
|
||||
RoleR10: "N4", // Behoerden
|
||||
}
|
||||
|
||||
// TargetAudienceRoleMapping maps canonical control target_audience values to CTM roles
|
||||
var TargetAudienceRoleMapping = map[string][]string{
|
||||
"enterprise": {RoleR1, RoleR4, RoleR5, RoleR6, RoleR7, RoleR9}, // Unternehmen
|
||||
"authority": {RoleR10}, // Behoerden
|
||||
"provider": {RoleR2, RoleR8}, // IT-Dienstleister
|
||||
"all": {RoleR1, RoleR2, RoleR3, RoleR4, RoleR5, RoleR6, RoleR7, RoleR8, RoleR9, RoleR10},
|
||||
}
|
||||
|
||||
// CategoryRoleMapping provides additional role hints based on control category
|
||||
var CategoryRoleMapping = map[string][]string{
|
||||
"encryption": {RoleR2, RoleR8},
|
||||
"authentication": {RoleR2, RoleR8, RoleR9},
|
||||
"network": {RoleR2, RoleR8},
|
||||
"data_protection": {RoleR3, RoleR5, RoleR9},
|
||||
"logging": {RoleR2, RoleR4, RoleR8},
|
||||
"incident": {RoleR1, RoleR4},
|
||||
"continuity": {RoleR1, RoleR2, RoleR4},
|
||||
"compliance": {RoleR1, RoleR3, RoleR4},
|
||||
"supply_chain": {RoleR6},
|
||||
"physical": {RoleR7},
|
||||
"personnel": {RoleR5, RoleR9},
|
||||
"application": {RoleR8},
|
||||
"system": {RoleR2, RoleR8},
|
||||
"risk": {RoleR1, RoleR4},
|
||||
"governance": {RoleR1, RoleR4},
|
||||
"hardware": {RoleR2, RoleR8},
|
||||
"identity": {RoleR2, RoleR3, RoleR8},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -498,3 +530,228 @@ type BulkResult struct {
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Training Block Types (Controls → Schulungsmodule Pipeline)
|
||||
// ============================================================================
|
||||
|
||||
// TrainingBlockConfig defines how canonical controls are grouped into training modules
|
||||
type TrainingBlockConfig struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DomainFilter string `json:"domain_filter,omitempty"` // "AUTH", "CRYP", etc.
|
||||
CategoryFilter string `json:"category_filter,omitempty"` // "authentication", etc.
|
||||
SeverityFilter string `json:"severity_filter,omitempty"` // "high", "critical"
|
||||
TargetAudienceFilter string `json:"target_audience_filter,omitempty"` // "enterprise", "authority", "provider", "all"
|
||||
RegulationArea RegulationArea `json:"regulation_area"`
|
||||
ModuleCodePrefix string `json:"module_code_prefix"`
|
||||
FrequencyType FrequencyType `json:"frequency_type"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
PassThreshold int `json:"pass_threshold"`
|
||||
MaxControlsPerModule int `json:"max_controls_per_module"`
|
||||
IsActive bool `json:"is_active"`
|
||||
LastGeneratedAt *time.Time `json:"last_generated_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TrainingBlockControlLink tracks which canonical controls are linked to which module
|
||||
type TrainingBlockControlLink struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
BlockConfigID uuid.UUID `json:"block_config_id"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
ControlID string `json:"control_id"`
|
||||
ControlTitle string `json:"control_title"`
|
||||
ControlObjective string `json:"control_objective"`
|
||||
ControlRequirements []string `json:"control_requirements"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CanonicalControlSummary is a lightweight view on canonical_controls for the training pipeline
|
||||
type CanonicalControlSummary struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Title string `json:"title"`
|
||||
Objective string `json:"objective"`
|
||||
Rationale string `json:"rationale"`
|
||||
Requirements []string `json:"requirements"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
TargetAudience string `json:"target_audience"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
// CanonicalControlMeta provides aggregated metadata about canonical controls
|
||||
type CanonicalControlMeta struct {
|
||||
Domains []DomainCount `json:"domains"`
|
||||
Categories []CategoryCount `json:"categories"`
|
||||
Audiences []AudienceCount `json:"audiences"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// DomainCount is a domain with its control count
|
||||
type DomainCount struct {
|
||||
Domain string `json:"domain"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// CategoryCount is a category with its control count
|
||||
type CategoryCount struct {
|
||||
Category string `json:"category"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// AudienceCount is a target audience with its control count
|
||||
type AudienceCount struct {
|
||||
Audience string `json:"audience"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// CreateBlockConfigRequest is the API request for creating a block config
|
||||
type CreateBlockConfigRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DomainFilter string `json:"domain_filter,omitempty"`
|
||||
CategoryFilter string `json:"category_filter,omitempty"`
|
||||
SeverityFilter string `json:"severity_filter,omitempty"`
|
||||
TargetAudienceFilter string `json:"target_audience_filter,omitempty"`
|
||||
RegulationArea RegulationArea `json:"regulation_area" binding:"required"`
|
||||
ModuleCodePrefix string `json:"module_code_prefix" binding:"required"`
|
||||
FrequencyType FrequencyType `json:"frequency_type"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
PassThreshold int `json:"pass_threshold"`
|
||||
MaxControlsPerModule int `json:"max_controls_per_module"`
|
||||
}
|
||||
|
||||
// UpdateBlockConfigRequest is the API request for updating a block config
|
||||
type UpdateBlockConfigRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DomainFilter *string `json:"domain_filter,omitempty"`
|
||||
CategoryFilter *string `json:"category_filter,omitempty"`
|
||||
SeverityFilter *string `json:"severity_filter,omitempty"`
|
||||
TargetAudienceFilter *string `json:"target_audience_filter,omitempty"`
|
||||
MaxControlsPerModule *int `json:"max_controls_per_module,omitempty"`
|
||||
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
||||
PassThreshold *int `json:"pass_threshold,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Video / Checkpoint Types
|
||||
// ============================================================================
|
||||
|
||||
// NarratorScript is an extended VideoScript with narrator persona and checkpoints
|
||||
type NarratorScript struct {
|
||||
Title string `json:"title"`
|
||||
Intro string `json:"intro"`
|
||||
Sections []NarratorSection `json:"sections"`
|
||||
Outro string `json:"outro"`
|
||||
TotalDurationEstimate int `json:"total_duration_estimate"`
|
||||
}
|
||||
|
||||
// NarratorSection is one narrative section with optional checkpoint
|
||||
type NarratorSection struct {
|
||||
Heading string `json:"heading"`
|
||||
NarratorText string `json:"narrator_text"`
|
||||
BulletPoints []string `json:"bullet_points"`
|
||||
Transition string `json:"transition"`
|
||||
Checkpoint *CheckpointDefinition `json:"checkpoint,omitempty"`
|
||||
}
|
||||
|
||||
// CheckpointDefinition defines a quiz checkpoint within a video
|
||||
type CheckpointDefinition struct {
|
||||
Title string `json:"title"`
|
||||
Questions []CheckpointQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
// CheckpointQuestion is a quiz question within a checkpoint
|
||||
type CheckpointQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
CorrectIndex int `json:"correct_index"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// Checkpoint is a DB record for a video checkpoint
|
||||
type Checkpoint struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ModuleID uuid.UUID `json:"module_id"`
|
||||
CheckpointIndex int `json:"checkpoint_index"`
|
||||
Title string `json:"title"`
|
||||
TimestampSeconds float64 `json:"timestamp_seconds"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CheckpointProgress tracks a user's progress on a checkpoint
|
||||
type CheckpointProgress struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AssignmentID uuid.UUID `json:"assignment_id"`
|
||||
CheckpointID uuid.UUID `json:"checkpoint_id"`
|
||||
Passed bool `json:"passed"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// InteractiveVideoManifest is returned to the frontend player
|
||||
type InteractiveVideoManifest struct {
|
||||
MediaID uuid.UUID `json:"media_id"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
Checkpoints []CheckpointManifestEntry `json:"checkpoints"`
|
||||
}
|
||||
|
||||
// CheckpointManifestEntry is one checkpoint in the manifest
|
||||
type CheckpointManifestEntry struct {
|
||||
CheckpointID uuid.UUID `json:"checkpoint_id"`
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
TimestampSeconds float64 `json:"timestamp_seconds"`
|
||||
Questions []CheckpointQuestion `json:"questions"`
|
||||
Progress *CheckpointProgress `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
// SubmitCheckpointQuizRequest is the API request for submitting a checkpoint quiz
|
||||
type SubmitCheckpointQuizRequest struct {
|
||||
AssignmentID string `json:"assignment_id"`
|
||||
Answers []int `json:"answers"`
|
||||
}
|
||||
|
||||
// SubmitCheckpointQuizResponse is the API response for a checkpoint quiz submission
|
||||
type SubmitCheckpointQuizResponse struct {
|
||||
Passed bool `json:"passed"`
|
||||
Score float64 `json:"score"`
|
||||
Feedback []CheckpointQuizFeedback `json:"feedback"`
|
||||
}
|
||||
|
||||
// CheckpointQuizFeedback is feedback for a single question
|
||||
type CheckpointQuizFeedback struct {
|
||||
Question string `json:"question"`
|
||||
Correct bool `json:"correct"`
|
||||
Explanation string `json:"explanation"`
|
||||
}
|
||||
|
||||
// GenerateBlockRequest is the API request for generating modules from a block config
|
||||
type GenerateBlockRequest struct {
|
||||
Language string `json:"language"`
|
||||
AutoMatrix bool `json:"auto_matrix"`
|
||||
}
|
||||
|
||||
// PreviewBlockResponse shows what would be generated without writing to DB
|
||||
type PreviewBlockResponse struct {
|
||||
ControlCount int `json:"control_count"`
|
||||
ModuleCount int `json:"module_count"`
|
||||
Controls []CanonicalControlSummary `json:"controls"`
|
||||
ProposedRoles []string `json:"proposed_roles"`
|
||||
}
|
||||
|
||||
// GenerateBlockResponse shows the result of a block generation
|
||||
type GenerateBlockResponse struct {
|
||||
ModulesCreated int `json:"modules_created"`
|
||||
ControlsLinked int `json:"controls_linked"`
|
||||
MatrixEntriesCreated int `json:"matrix_entries_created"`
|
||||
ContentGenerated int `json:"content_generated"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
@@ -235,6 +235,12 @@ func (s *Store) UpdateModule(ctx context.Context, module *TrainingModule) error
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteModule deletes a training module by ID
|
||||
func (s *Store) DeleteModule(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_modules WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetAcademyCourseID links a training module to an academy course
|
||||
func (s *Store) SetAcademyCourseID(ctx context.Context, moduleID, courseID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
@@ -570,6 +576,18 @@ func (s *Store) UpdateAssignmentStatus(ctx context.Context, id uuid.UUID, status
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAssignmentDeadline updates the deadline of an assignment
|
||||
func (s *Store) UpdateAssignmentDeadline(ctx context.Context, id uuid.UUID, deadline time.Time) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_assignments SET
|
||||
deadline = $2,
|
||||
updated_at = $3
|
||||
WHERE id = $1
|
||||
`, id, deadline, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAssignmentQuizResult updates quiz-related fields on an assignment
|
||||
func (s *Store) UpdateAssignmentQuizResult(ctx context.Context, id uuid.UUID, score float64, passed bool, attempts int) error {
|
||||
now := time.Now().UTC()
|
||||
@@ -1252,6 +1270,80 @@ func (s *Store) GetPublishedAudio(ctx context.Context, moduleID uuid.UUID) (*Tra
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// SetCertificateID sets the certificate ID on an assignment
|
||||
func (s *Store) SetCertificateID(ctx context.Context, assignmentID, certID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE training_assignments SET certificate_id = $2, updated_at = NOW() WHERE id = $1
|
||||
`, assignmentID, certID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAssignmentByCertificateID finds an assignment by its certificate ID
|
||||
func (s *Store) GetAssignmentByCertificateID(ctx context.Context, certID uuid.UUID) (*TrainingAssignment, error) {
|
||||
var assignmentID uuid.UUID
|
||||
err := s.pool.QueryRow(ctx,
|
||||
"SELECT id FROM training_assignments WHERE certificate_id = $1",
|
||||
certID).Scan(&assignmentID)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetAssignment(ctx, assignmentID)
|
||||
}
|
||||
|
||||
// ListCertificates lists assignments that have certificates for a tenant
|
||||
func (s *Store) ListCertificates(ctx context.Context, tenantID uuid.UUID) ([]TrainingAssignment, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
ta.id, ta.tenant_id, ta.module_id, ta.user_id, ta.user_name, ta.user_email,
|
||||
ta.role_code, ta.trigger_type, ta.trigger_event, ta.status, ta.progress_percent,
|
||||
ta.quiz_score, ta.quiz_passed, ta.quiz_attempts,
|
||||
ta.started_at, ta.completed_at, ta.deadline, ta.certificate_id,
|
||||
ta.escalation_level, ta.last_escalation_at, ta.enrollment_id,
|
||||
ta.created_at, ta.updated_at,
|
||||
m.module_code, m.title
|
||||
FROM training_assignments ta
|
||||
JOIN training_modules m ON m.id = ta.module_id
|
||||
WHERE ta.tenant_id = $1 AND ta.certificate_id IS NOT NULL
|
||||
ORDER BY ta.completed_at DESC
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assignments []TrainingAssignment
|
||||
for rows.Next() {
|
||||
var a TrainingAssignment
|
||||
var status, triggerType string
|
||||
|
||||
err := rows.Scan(
|
||||
&a.ID, &a.TenantID, &a.ModuleID, &a.UserID, &a.UserName, &a.UserEmail,
|
||||
&a.RoleCode, &triggerType, &a.TriggerEvent, &status, &a.ProgressPercent,
|
||||
&a.QuizScore, &a.QuizPassed, &a.QuizAttempts,
|
||||
&a.StartedAt, &a.CompletedAt, &a.Deadline, &a.CertificateID,
|
||||
&a.EscalationLevel, &a.LastEscalationAt, &a.EnrollmentID,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
&a.ModuleCode, &a.ModuleTitle,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Status = AssignmentStatus(status)
|
||||
a.TriggerType = TriggerType(triggerType)
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
|
||||
if assignments == nil {
|
||||
assignments = []TrainingAssignment{}
|
||||
}
|
||||
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// GetPublishedVideo gets the published video for a module
|
||||
func (s *Store) GetPublishedVideo(ctx context.Context, moduleID uuid.UUID) (*TrainingMedia, error) {
|
||||
var media TrainingMedia
|
||||
@@ -1283,3 +1375,195 @@ func (s *Store) GetPublishedVideo(ctx context.Context, moduleID uuid.UUID) (*Tra
|
||||
media.Status = MediaStatus(status)
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkpoint Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateCheckpoint inserts a new checkpoint
|
||||
func (s *Store) CreateCheckpoint(ctx context.Context, cp *Checkpoint) error {
|
||||
cp.ID = uuid.New()
|
||||
cp.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_checkpoints (id, module_id, checkpoint_index, title, timestamp_seconds, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, cp.ID, cp.ModuleID, cp.CheckpointIndex, cp.Title, cp.TimestampSeconds, cp.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListCheckpoints returns all checkpoints for a module ordered by index
|
||||
func (s *Store) ListCheckpoints(ctx context.Context, moduleID uuid.UUID) ([]Checkpoint, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, module_id, checkpoint_index, title, timestamp_seconds, created_at
|
||||
FROM training_checkpoints
|
||||
WHERE module_id = $1
|
||||
ORDER BY checkpoint_index
|
||||
`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var checkpoints []Checkpoint
|
||||
for rows.Next() {
|
||||
var cp Checkpoint
|
||||
if err := rows.Scan(&cp.ID, &cp.ModuleID, &cp.CheckpointIndex, &cp.Title, &cp.TimestampSeconds, &cp.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
checkpoints = append(checkpoints, cp)
|
||||
}
|
||||
|
||||
if checkpoints == nil {
|
||||
checkpoints = []Checkpoint{}
|
||||
}
|
||||
return checkpoints, nil
|
||||
}
|
||||
|
||||
// DeleteCheckpointsForModule removes all checkpoints for a module (used before regenerating)
|
||||
func (s *Store) DeleteCheckpointsForModule(ctx context.Context, moduleID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM training_checkpoints WHERE module_id = $1`, moduleID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCheckpointProgress retrieves progress for a specific checkpoint+assignment
|
||||
func (s *Store) GetCheckpointProgress(ctx context.Context, assignmentID, checkpointID uuid.UUID) (*CheckpointProgress, error) {
|
||||
var cp CheckpointProgress
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at
|
||||
FROM training_checkpoint_progress
|
||||
WHERE assignment_id = $1 AND checkpoint_id = $2
|
||||
`, assignmentID, checkpointID).Scan(
|
||||
&cp.ID, &cp.AssignmentID, &cp.CheckpointID, &cp.Passed, &cp.Attempts, &cp.LastAttemptAt, &cp.CreatedAt,
|
||||
)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// UpsertCheckpointProgress creates or updates checkpoint progress
|
||||
func (s *Store) UpsertCheckpointProgress(ctx context.Context, progress *CheckpointProgress) error {
|
||||
progress.ID = uuid.New()
|
||||
now := time.Now().UTC()
|
||||
progress.LastAttemptAt = &now
|
||||
progress.CreatedAt = now
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_checkpoint_progress (id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (assignment_id, checkpoint_id) DO UPDATE SET
|
||||
passed = EXCLUDED.passed,
|
||||
attempts = training_checkpoint_progress.attempts + 1,
|
||||
last_attempt_at = EXCLUDED.last_attempt_at
|
||||
`, progress.ID, progress.AssignmentID, progress.CheckpointID, progress.Passed, progress.Attempts, progress.LastAttemptAt, progress.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCheckpointQuestions retrieves quiz questions for a specific checkpoint
|
||||
func (s *Store) GetCheckpointQuestions(ctx context.Context, checkpointID uuid.UUID) ([]QuizQuestion, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, module_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at
|
||||
FROM training_quiz_questions
|
||||
WHERE checkpoint_id = $1 AND is_active = true
|
||||
ORDER BY sort_order
|
||||
`, checkpointID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var questions []QuizQuestion
|
||||
for rows.Next() {
|
||||
var q QuizQuestion
|
||||
var options []byte
|
||||
var difficulty string
|
||||
if err := rows.Scan(&q.ID, &q.ModuleID, &q.Question, &options, &q.CorrectIndex, &q.Explanation, &difficulty, &q.IsActive, &q.SortOrder, &q.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal(options, &q.Options)
|
||||
q.Difficulty = Difficulty(difficulty)
|
||||
questions = append(questions, q)
|
||||
}
|
||||
|
||||
if questions == nil {
|
||||
questions = []QuizQuestion{}
|
||||
}
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// CreateCheckpointQuizQuestion creates a quiz question linked to a checkpoint
|
||||
func (s *Store) CreateCheckpointQuizQuestion(ctx context.Context, q *QuizQuestion, checkpointID uuid.UUID) error {
|
||||
q.ID = uuid.New()
|
||||
q.CreatedAt = time.Now().UTC()
|
||||
q.IsActive = true
|
||||
|
||||
options, _ := json.Marshal(q.Options)
|
||||
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO training_quiz_questions (id, module_id, checkpoint_id, question, options, correct_index, explanation, difficulty, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`, q.ID, q.ModuleID, checkpointID, q.Question, options, q.CorrectIndex, q.Explanation, string(q.Difficulty), q.IsActive, q.SortOrder, q.CreatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// AreAllCheckpointsPassed checks if all checkpoints for a module are passed by an assignment
|
||||
func (s *Store) AreAllCheckpointsPassed(ctx context.Context, assignmentID, moduleID uuid.UUID) (bool, error) {
|
||||
var totalCheckpoints, passedCheckpoints int
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_checkpoints WHERE module_id = $1
|
||||
`, moduleID).Scan(&totalCheckpoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if totalCheckpoints == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM training_checkpoint_progress cp
|
||||
JOIN training_checkpoints c ON cp.checkpoint_id = c.id
|
||||
WHERE cp.assignment_id = $1 AND c.module_id = $2 AND cp.passed = true
|
||||
`, assignmentID, moduleID).Scan(&passedCheckpoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return passedCheckpoints >= totalCheckpoints, nil
|
||||
}
|
||||
|
||||
// ListCheckpointProgress returns all checkpoint progress for an assignment
|
||||
func (s *Store) ListCheckpointProgress(ctx context.Context, assignmentID uuid.UUID) ([]CheckpointProgress, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, assignment_id, checkpoint_id, passed, attempts, last_attempt_at, created_at
|
||||
FROM training_checkpoint_progress
|
||||
WHERE assignment_id = $1
|
||||
ORDER BY created_at
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var progress []CheckpointProgress
|
||||
for rows.Next() {
|
||||
var cp CheckpointProgress
|
||||
if err := rows.Scan(&cp.ID, &cp.AssignmentID, &cp.CheckpointID, &cp.Passed, &cp.Attempts, &cp.LastAttemptAt, &cp.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progress = append(progress, cp)
|
||||
}
|
||||
|
||||
if progress == nil {
|
||||
progress = []CheckpointProgress{}
|
||||
}
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
47
ai-compliance-sdk/migrations/021_training_blocks.sql
Normal file
47
ai-compliance-sdk/migrations/021_training_blocks.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Migration 021: Training Blocks — Generate training modules from Canonical Controls
|
||||
-- Links block configs (filter criteria) to canonical controls, creating modules automatically.
|
||||
-- Uses target_audience, category, severity, and domain (control_id prefix) for filtering.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_block_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
domain_filter VARCHAR(10), -- "AUTH", "CRYP", etc. NULL=all domains
|
||||
category_filter VARCHAR(50), -- "authentication", "encryption", etc. NULL=all
|
||||
severity_filter VARCHAR(20), -- "high", "critical", etc. NULL=all
|
||||
target_audience_filter VARCHAR(20), -- "enterprise", "authority", "provider", "all". NULL=all
|
||||
regulation_area VARCHAR(20) NOT NULL,
|
||||
module_code_prefix VARCHAR(10) NOT NULL,
|
||||
frequency_type VARCHAR(20) DEFAULT 'annual',
|
||||
duration_minutes INT DEFAULT 45,
|
||||
pass_threshold INT DEFAULT 70,
|
||||
max_controls_per_module INT DEFAULT 20,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
last_generated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_block_control_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
block_config_id UUID NOT NULL REFERENCES training_block_configs(id) ON DELETE CASCADE,
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
control_id VARCHAR(20) NOT NULL,
|
||||
control_title VARCHAR(255),
|
||||
control_objective TEXT,
|
||||
control_requirements JSONB DEFAULT '[]',
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tbc_tenant ON training_block_configs(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tbc_active ON training_block_configs(tenant_id, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_tbcl_block ON training_block_control_links(block_config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tbcl_module ON training_block_control_links(module_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tbcl_control ON training_block_control_links(control_id);
|
||||
|
||||
COMMIT;
|
||||
37
ai-compliance-sdk/migrations/022_interactive_training.sql
Normal file
37
ai-compliance-sdk/migrations/022_interactive_training.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Migration 022: Interactive Training Video Pipeline
|
||||
-- Adds checkpoints, checkpoint progress tracking, and links quiz questions to checkpoints
|
||||
|
||||
-- Checkpoints pro Modul-Video (pausiert Video bei bestimmtem Timestamp)
|
||||
CREATE TABLE IF NOT EXISTS training_checkpoints (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_id UUID NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
|
||||
checkpoint_index INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
timestamp_seconds DOUBLE PRECISION NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(module_id, checkpoint_index)
|
||||
);
|
||||
|
||||
-- Checkpoint-Fortschritt pro User-Assignment
|
||||
CREATE TABLE IF NOT EXISTS training_checkpoint_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assignment_id UUID NOT NULL REFERENCES training_assignments(id) ON DELETE CASCADE,
|
||||
checkpoint_id UUID NOT NULL REFERENCES training_checkpoints(id) ON DELETE CASCADE,
|
||||
passed BOOLEAN DEFAULT FALSE,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_attempt_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(assignment_id, checkpoint_id)
|
||||
);
|
||||
|
||||
-- Quiz-Fragen koennen jetzt optional einem Checkpoint zugeordnet sein
|
||||
ALTER TABLE training_quiz_questions ADD COLUMN IF NOT EXISTS checkpoint_id UUID REFERENCES training_checkpoints(id);
|
||||
|
||||
-- Checkpoint-Index auf Media (fuer Manifest-Zuordnung)
|
||||
ALTER TABLE training_media ADD COLUMN IF NOT EXISTS checkpoint_index INTEGER;
|
||||
|
||||
-- Indices for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_training_checkpoints_module ON training_checkpoints(module_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_checkpoint_progress_assignment ON training_checkpoint_progress(assignment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_checkpoint_progress_checkpoint ON training_checkpoint_progress(checkpoint_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_quiz_questions_checkpoint ON training_quiz_questions(checkpoint_id);
|
||||
@@ -22,6 +22,7 @@ Endpoints:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -277,8 +278,8 @@ async def list_framework_controls(
|
||||
query += " AND category = :cat"
|
||||
params["cat"] = category
|
||||
if target_audience:
|
||||
query += " AND target_audience = :ta"
|
||||
params["ta"] = target_audience
|
||||
query += " AND target_audience::jsonb @> (:ta)::jsonb"
|
||||
params["ta"] = json.dumps([target_audience])
|
||||
|
||||
query += " ORDER BY control_id"
|
||||
rows = db.execute(text(query), params).fetchall()
|
||||
@@ -329,8 +330,8 @@ async def list_controls(
|
||||
query += " AND category = :cat"
|
||||
params["cat"] = category
|
||||
if target_audience:
|
||||
query += " AND target_audience = :ta"
|
||||
params["ta"] = target_audience
|
||||
query += " AND target_audience LIKE :ta_pattern"
|
||||
params["ta_pattern"] = f'%"{target_audience}"%'
|
||||
if source:
|
||||
if source == "__none__":
|
||||
query += " AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')"
|
||||
@@ -398,8 +399,8 @@ async def count_controls(
|
||||
query += " AND category = :cat"
|
||||
params["cat"] = category
|
||||
if target_audience:
|
||||
query += " AND target_audience = :ta"
|
||||
params["ta"] = target_audience
|
||||
query += " AND target_audience LIKE :ta_pattern"
|
||||
params["ta_pattern"] = f'%"{target_audience}"%'
|
||||
if source:
|
||||
if source == "__none__":
|
||||
query += " AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')"
|
||||
|
||||
@@ -26,6 +26,13 @@ from compliance.services.control_generator import (
|
||||
ControlGeneratorPipeline,
|
||||
GeneratorConfig,
|
||||
ALL_COLLECTIONS,
|
||||
VALID_CATEGORIES,
|
||||
VALID_DOMAINS,
|
||||
_detect_category,
|
||||
_detect_domain,
|
||||
_llm_local,
|
||||
_parse_llm_json,
|
||||
CATEGORY_LIST_STR,
|
||||
)
|
||||
from compliance.services.citation_backfill import CitationBackfill, BackfillResult
|
||||
from compliance.services.rag_client import get_rag_client
|
||||
@@ -42,6 +49,7 @@ class GenerateRequest(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
collections: Optional[List[str]] = None
|
||||
max_controls: int = 50
|
||||
max_chunks: int = 1000 # Default: process max 1000 chunks per job (respects document boundaries)
|
||||
batch_size: int = 5
|
||||
skip_web_search: bool = False
|
||||
dry_run: bool = False
|
||||
@@ -57,6 +65,7 @@ class GenerateResponse(BaseModel):
|
||||
controls_needs_review: int = 0
|
||||
controls_too_close: int = 0
|
||||
controls_duplicates_found: int = 0
|
||||
controls_qa_fixed: int = 0
|
||||
errors: list = []
|
||||
controls: list = []
|
||||
|
||||
@@ -132,6 +141,7 @@ async def start_generation(req: GenerateRequest):
|
||||
domain=req.domain,
|
||||
batch_size=req.batch_size,
|
||||
max_controls=req.max_controls,
|
||||
max_chunks=req.max_chunks,
|
||||
skip_web_search=req.skip_web_search,
|
||||
dry_run=req.dry_run,
|
||||
)
|
||||
@@ -338,6 +348,188 @@ async def review_control(control_id: str, req: ReviewRequest):
|
||||
db.close()
|
||||
|
||||
|
||||
class BulkReviewRequest(BaseModel):
|
||||
release_state: str # Filter: which controls to bulk-review
|
||||
action: str # "approve" or "reject"
|
||||
new_state: Optional[str] = None # Override target state
|
||||
|
||||
|
||||
@router.post("/generate/bulk-review")
|
||||
async def bulk_review(req: BulkReviewRequest):
|
||||
"""Bulk review all controls matching a release_state filter.
|
||||
|
||||
Example: reject all needs_review → sets them to deprecated.
|
||||
"""
|
||||
if req.release_state not in ("needs_review", "too_close", "duplicate"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid filter state: {req.release_state}")
|
||||
|
||||
if req.action == "approve":
|
||||
target = req.new_state or "draft"
|
||||
elif req.action == "reject":
|
||||
target = "deprecated"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}")
|
||||
|
||||
if target not in ("draft", "review", "approved", "deprecated", "needs_review"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid target state: {target}")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
UPDATE canonical_controls
|
||||
SET release_state = :target, updated_at = NOW()
|
||||
WHERE release_state = :source
|
||||
RETURNING control_id
|
||||
"""),
|
||||
{"source": req.release_state, "target": target},
|
||||
)
|
||||
affected = [row[0] for row in result]
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"action": req.action,
|
||||
"source_state": req.release_state,
|
||||
"target_state": target,
|
||||
"affected_count": len(affected),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class QAReclassifyRequest(BaseModel):
|
||||
limit: int = 100 # How many controls to reclassify per run
|
||||
dry_run: bool = True # Preview only by default
|
||||
filter_category: Optional[str] = None # Only reclassify controls of this category
|
||||
filter_domain_prefix: Optional[str] = None # Only reclassify controls with this prefix
|
||||
|
||||
|
||||
@router.post("/generate/qa-reclassify")
|
||||
async def qa_reclassify(req: QAReclassifyRequest):
|
||||
"""Run QA reclassification on existing controls using local LLM.
|
||||
|
||||
Finds controls where keyword-detection disagrees with current category/domain,
|
||||
then uses Ollama to determine the correct classification.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Load controls to check
|
||||
where_clauses = ["release_state NOT IN ('deprecated')"]
|
||||
params = {"limit": req.limit}
|
||||
if req.filter_category:
|
||||
where_clauses.append("category = :cat")
|
||||
params["cat"] = req.filter_category
|
||||
if req.filter_domain_prefix:
|
||||
where_clauses.append("control_id LIKE :prefix")
|
||||
params["prefix"] = f"{req.filter_domain_prefix}-%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT id, control_id, title, objective, category,
|
||||
COALESCE(requirements::text, '[]') as requirements,
|
||||
COALESCE(source_original_text, '') as source_text
|
||||
FROM canonical_controls
|
||||
WHERE {where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
results = {"checked": 0, "mismatches": 0, "fixes": [], "errors": []}
|
||||
|
||||
for row in rows:
|
||||
results["checked"] += 1
|
||||
control_id = row[1]
|
||||
title = row[2]
|
||||
objective = row[3] or ""
|
||||
current_category = row[4]
|
||||
source_text = row[6] or objective
|
||||
|
||||
# Keyword detection on source text
|
||||
kw_category = _detect_category(source_text) or _detect_category(objective)
|
||||
kw_domain = _detect_domain(source_text)
|
||||
current_prefix = control_id.split("-")[0] if "-" in control_id else ""
|
||||
|
||||
# Skip if keyword detection agrees with current classification
|
||||
if kw_category == current_category and kw_domain == current_prefix:
|
||||
continue
|
||||
|
||||
results["mismatches"] += 1
|
||||
|
||||
# Ask Ollama to arbitrate
|
||||
try:
|
||||
reqs_text = ""
|
||||
try:
|
||||
reqs = json.loads(row[5])
|
||||
if isinstance(reqs, list):
|
||||
reqs_text = ", ".join(str(r) for r in reqs[:3])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prompt = f"""Pruefe dieses Compliance-Control auf korrekte Klassifizierung.
|
||||
|
||||
Titel: {title[:100]}
|
||||
Ziel: {objective[:200]}
|
||||
Anforderungen: {reqs_text[:200]}
|
||||
|
||||
Aktuelle Zuordnung: domain={current_prefix}, category={current_category}
|
||||
Keyword-Erkennung: domain={kw_domain}, category={kw_category}
|
||||
|
||||
Welche Zuordnung ist korrekt? Antworte NUR als JSON:
|
||||
{{"domain": "KUERZEL", "category": "kategorie_name", "reason": "kurze Begruendung"}}
|
||||
|
||||
Domains: AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe, ENV=Umwelt, HLT=Gesundheit
|
||||
Kategorien: {CATEGORY_LIST_STR}"""
|
||||
|
||||
raw = await _llm_local(prompt)
|
||||
data = _parse_llm_json(raw)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
qa_domain = data.get("domain", "").upper()
|
||||
qa_category = data.get("category", "")
|
||||
reason = data.get("reason", "")
|
||||
|
||||
fix_entry = {
|
||||
"control_id": control_id,
|
||||
"title": title[:80],
|
||||
"old_category": current_category,
|
||||
"old_domain": current_prefix,
|
||||
"new_category": qa_category if qa_category in VALID_CATEGORIES else current_category,
|
||||
"new_domain": qa_domain if qa_domain in VALID_DOMAINS else current_prefix,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
category_changed = qa_category in VALID_CATEGORIES and qa_category != current_category
|
||||
|
||||
if category_changed and not req.dry_run:
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE canonical_controls
|
||||
SET category = :category, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row[0], "category": qa_category},
|
||||
)
|
||||
fix_entry["applied"] = True
|
||||
else:
|
||||
fix_entry["applied"] = False
|
||||
|
||||
results["fixes"].append(fix_entry)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append({"control_id": control_id, "error": str(e)})
|
||||
|
||||
if not req.dry_run:
|
||||
db.commit()
|
||||
|
||||
return results
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/generate/processed-stats")
|
||||
async def get_processed_stats():
|
||||
"""Get processing statistics per collection."""
|
||||
|
||||
@@ -39,7 +39,6 @@ router = APIRouter(tags=["extraction"])
|
||||
|
||||
ALL_COLLECTIONS = [
|
||||
"bp_compliance_ce", # BSI-TR documents — primary Prüfaspekte source
|
||||
"bp_compliance_recht", # Legal texts (GDPR, AI Act, ...)
|
||||
"bp_compliance_gesetze", # German laws
|
||||
"bp_compliance_datenschutz", # Data protection documents
|
||||
"bp_dsfa_corpus", # DSFA corpus
|
||||
|
||||
437
backend-compliance/compliance/services/citation_backfill.py
Normal file
437
backend-compliance/compliance/services/citation_backfill.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Citation Backfill Service — enrich existing controls with article/paragraph provenance.
|
||||
|
||||
3-tier matching strategy:
|
||||
Tier 1 — Hash match: sha256(source_original_text) → RAG chunk lookup
|
||||
Tier 2 — Regex parse: split concatenated "DSGVO Art. 35" → regulation + article
|
||||
Tier 3 — Ollama LLM: ask local LLM to identify article/paragraph from text
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .rag_client import ComplianceRAGClient, RAGSearchResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||||
OLLAMA_MODEL = os.getenv("CONTROL_GEN_OLLAMA_MODEL", "qwen3.5:35b-a3b")
|
||||
LLM_TIMEOUT = float(os.getenv("CONTROL_GEN_LLM_TIMEOUT", "180"))
|
||||
|
||||
ALL_COLLECTIONS = [
|
||||
"bp_compliance_ce",
|
||||
"bp_compliance_gesetze",
|
||||
"bp_compliance_datenschutz",
|
||||
"bp_dsfa_corpus",
|
||||
"bp_legal_templates",
|
||||
]
|
||||
|
||||
BACKFILL_SYSTEM_PROMPT = (
|
||||
"Du bist ein Rechtsexperte. Deine Aufgabe ist es, aus einem Gesetzestext "
|
||||
"den genauen Artikel und Absatz zu bestimmen. Antworte NUR mit validem JSON."
|
||||
)
|
||||
|
||||
# Regex to split concatenated source like "DSGVO Art. 35" or "NIS2 Artikel 21 Abs. 2"
|
||||
_SOURCE_ARTICLE_RE = re.compile(
|
||||
r"^(.+?)\s+(Art(?:ikel)?\.?\s*\d+.*)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
article: str
|
||||
paragraph: str
|
||||
method: str # "hash", "regex", "llm"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackfillResult:
|
||||
total_controls: int = 0
|
||||
matched_hash: int = 0
|
||||
matched_regex: int = 0
|
||||
matched_llm: int = 0
|
||||
unmatched: int = 0
|
||||
updated: int = 0
|
||||
errors: list = field(default_factory=list)
|
||||
|
||||
|
||||
class CitationBackfill:
|
||||
"""Backfill article/paragraph into existing control source_citations."""
|
||||
|
||||
def __init__(self, db: Session, rag_client: ComplianceRAGClient):
|
||||
self.db = db
|
||||
self.rag = rag_client
|
||||
self._rag_index: dict[str, RAGSearchResult] = {}
|
||||
|
||||
async def run(self, dry_run: bool = True, limit: int = 0) -> BackfillResult:
|
||||
"""Main entry: iterate controls missing article/paragraph, match to RAG, update."""
|
||||
result = BackfillResult()
|
||||
|
||||
# Load controls needing backfill
|
||||
controls = self._load_controls_needing_backfill(limit)
|
||||
result.total_controls = len(controls)
|
||||
logger.info("Backfill: %d controls need article/paragraph enrichment", len(controls))
|
||||
|
||||
if not controls:
|
||||
return result
|
||||
|
||||
# Collect hashes we need to find — only build index for controls with source text
|
||||
needed_hashes: set[str] = set()
|
||||
for ctrl in controls:
|
||||
src = ctrl.get("source_original_text")
|
||||
if src:
|
||||
needed_hashes.add(hashlib.sha256(src.encode()).hexdigest())
|
||||
|
||||
if needed_hashes:
|
||||
# Build targeted RAG index — only scroll collections that our controls reference
|
||||
logger.info("Building targeted RAG hash index for %d source texts...", len(needed_hashes))
|
||||
await self._build_rag_index_targeted(controls)
|
||||
logger.info("RAG index built: %d chunks indexed, %d hashes needed", len(self._rag_index), len(needed_hashes))
|
||||
else:
|
||||
logger.info("No source_original_text found — skipping RAG index build")
|
||||
|
||||
# Process each control
|
||||
for i, ctrl in enumerate(controls):
|
||||
if i > 0 and i % 100 == 0:
|
||||
logger.info("Backfill progress: %d/%d processed", i, result.total_controls)
|
||||
|
||||
try:
|
||||
match = await self._match_control(ctrl)
|
||||
if match:
|
||||
if match.method == "hash":
|
||||
result.matched_hash += 1
|
||||
elif match.method == "regex":
|
||||
result.matched_regex += 1
|
||||
elif match.method == "llm":
|
||||
result.matched_llm += 1
|
||||
|
||||
if not dry_run:
|
||||
self._update_control(ctrl, match)
|
||||
result.updated += 1
|
||||
else:
|
||||
logger.debug(
|
||||
"DRY RUN: Would update %s with article=%s paragraph=%s (method=%s)",
|
||||
ctrl["control_id"], match.article, match.paragraph, match.method,
|
||||
)
|
||||
else:
|
||||
result.unmatched += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error backfilling {ctrl.get('control_id', '?')}: {e}"
|
||||
logger.error(error_msg)
|
||||
result.errors.append(error_msg)
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
self.db.commit()
|
||||
except Exception as e:
|
||||
logger.error("Backfill commit failed: %s", e)
|
||||
result.errors.append(f"Commit failed: {e}")
|
||||
|
||||
logger.info(
|
||||
"Backfill complete: %d total, hash=%d regex=%d llm=%d unmatched=%d updated=%d",
|
||||
result.total_controls, result.matched_hash, result.matched_regex,
|
||||
result.matched_llm, result.unmatched, result.updated,
|
||||
)
|
||||
return result
|
||||
|
||||
def _load_controls_needing_backfill(self, limit: int = 0) -> list[dict]:
|
||||
"""Load controls where source_citation exists but lacks separate 'article' key."""
|
||||
query = """
|
||||
SELECT id, control_id, source_citation, source_original_text,
|
||||
generation_metadata, license_rule
|
||||
FROM canonical_controls
|
||||
WHERE license_rule IN (1, 2)
|
||||
AND source_citation IS NOT NULL
|
||||
AND (
|
||||
source_citation->>'article' IS NULL
|
||||
OR source_citation->>'article' = ''
|
||||
)
|
||||
ORDER BY control_id
|
||||
"""
|
||||
if limit > 0:
|
||||
query += f" LIMIT {limit}"
|
||||
|
||||
result = self.db.execute(text(query))
|
||||
cols = result.keys()
|
||||
controls = []
|
||||
for row in result:
|
||||
ctrl = dict(zip(cols, row))
|
||||
ctrl["id"] = str(ctrl["id"])
|
||||
# Parse JSON fields
|
||||
for jf in ("source_citation", "generation_metadata"):
|
||||
if isinstance(ctrl.get(jf), str):
|
||||
try:
|
||||
ctrl[jf] = json.loads(ctrl[jf])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
ctrl[jf] = {}
|
||||
controls.append(ctrl)
|
||||
return controls
|
||||
|
||||
async def _build_rag_index_targeted(self, controls: list[dict]):
|
||||
"""Build RAG index by scrolling only collections relevant to our controls.
|
||||
|
||||
Uses regulation codes from generation_metadata to identify which collections
|
||||
to search, falling back to all collections only if needed.
|
||||
"""
|
||||
# Determine which collections are relevant based on regulation codes
|
||||
regulation_to_collection = self._map_regulations_to_collections(controls)
|
||||
collections_to_search = set(regulation_to_collection.values()) or set(ALL_COLLECTIONS)
|
||||
|
||||
logger.info("Targeted index: searching %d collections: %s",
|
||||
len(collections_to_search), ", ".join(collections_to_search))
|
||||
|
||||
for collection in collections_to_search:
|
||||
offset = None
|
||||
page = 0
|
||||
seen_offsets: set[str] = set()
|
||||
while True:
|
||||
chunks, next_offset = await self.rag.scroll(
|
||||
collection=collection, offset=offset, limit=200,
|
||||
)
|
||||
if not chunks:
|
||||
break
|
||||
for chunk in chunks:
|
||||
if chunk.text and len(chunk.text.strip()) >= 50:
|
||||
h = hashlib.sha256(chunk.text.encode()).hexdigest()
|
||||
self._rag_index[h] = chunk
|
||||
page += 1
|
||||
if page % 50 == 0:
|
||||
logger.info("Indexing %s: page %d (%d chunks so far)",
|
||||
collection, page, len(self._rag_index))
|
||||
if not next_offset:
|
||||
break
|
||||
if next_offset in seen_offsets:
|
||||
logger.warning("Scroll loop in %s at page %d — stopping", collection, page)
|
||||
break
|
||||
seen_offsets.add(next_offset)
|
||||
offset = next_offset
|
||||
|
||||
logger.info("Indexed collection %s: %d pages", collection, page)
|
||||
|
||||
def _map_regulations_to_collections(self, controls: list[dict]) -> dict[str, str]:
|
||||
"""Map regulation codes from controls to likely Qdrant collections."""
|
||||
# Heuristic: regulation code prefix → collection
|
||||
collection_map = {
|
||||
"eu_": "bp_compliance_gesetze",
|
||||
"dsgvo": "bp_compliance_datenschutz",
|
||||
"bdsg": "bp_compliance_gesetze",
|
||||
"ttdsg": "bp_compliance_gesetze",
|
||||
"nist_": "bp_compliance_ce",
|
||||
"owasp": "bp_compliance_ce",
|
||||
"bsi_": "bp_compliance_ce",
|
||||
"enisa": "bp_compliance_ce",
|
||||
"at_": "bp_compliance_recht",
|
||||
"fr_": "bp_compliance_recht",
|
||||
"es_": "bp_compliance_recht",
|
||||
}
|
||||
result: dict[str, str] = {}
|
||||
for ctrl in controls:
|
||||
meta = ctrl.get("generation_metadata") or {}
|
||||
reg = meta.get("source_regulation", "")
|
||||
if not reg:
|
||||
continue
|
||||
for prefix, coll in collection_map.items():
|
||||
if reg.startswith(prefix):
|
||||
result[reg] = coll
|
||||
break
|
||||
else:
|
||||
# Unknown regulation — search all
|
||||
for coll in ALL_COLLECTIONS:
|
||||
result[f"_all_{coll}"] = coll
|
||||
return result
|
||||
|
||||
async def _match_control(self, ctrl: dict) -> Optional[MatchResult]:
|
||||
"""3-tier matching: hash → regex → LLM."""
|
||||
|
||||
# Tier 1: Hash match against RAG index
|
||||
source_text = ctrl.get("source_original_text")
|
||||
if source_text:
|
||||
h = hashlib.sha256(source_text.encode()).hexdigest()
|
||||
chunk = self._rag_index.get(h)
|
||||
if chunk and (chunk.article or chunk.paragraph):
|
||||
return MatchResult(
|
||||
article=chunk.article or "",
|
||||
paragraph=chunk.paragraph or "",
|
||||
method="hash",
|
||||
)
|
||||
|
||||
# Tier 2: Regex parse concatenated source
|
||||
citation = ctrl.get("source_citation") or {}
|
||||
source_str = citation.get("source", "")
|
||||
parsed = _parse_concatenated_source(source_str)
|
||||
if parsed and parsed["article"]:
|
||||
return MatchResult(
|
||||
article=parsed["article"],
|
||||
paragraph="", # Regex can't extract paragraph from concatenated format
|
||||
method="regex",
|
||||
)
|
||||
|
||||
# Tier 3: Ollama LLM
|
||||
if source_text:
|
||||
return await self._llm_match(ctrl)
|
||||
|
||||
return None
|
||||
|
||||
async def _llm_match(self, ctrl: dict) -> Optional[MatchResult]:
|
||||
"""Use Ollama to identify article/paragraph from source text."""
|
||||
citation = ctrl.get("source_citation") or {}
|
||||
regulation_name = citation.get("source", "")
|
||||
metadata = ctrl.get("generation_metadata") or {}
|
||||
regulation_code = metadata.get("source_regulation", "")
|
||||
source_text = ctrl.get("source_original_text", "")
|
||||
|
||||
prompt = f"""Analysiere den folgenden Gesetzestext und bestimme den genauen Artikel und Absatz.
|
||||
|
||||
Gesetz: {regulation_name} (Code: {regulation_code})
|
||||
|
||||
Text:
|
||||
---
|
||||
{source_text[:2000]}
|
||||
---
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{{"article": "Art. XX", "paragraph": "Abs. Y"}}
|
||||
|
||||
Falls kein spezifischer Absatz erkennbar ist, setze paragraph auf "".
|
||||
Falls kein Artikel erkennbar ist, setze article auf "".
|
||||
Bei deutschen Gesetzen mit § verwende: "§ XX" statt "Art. XX"."""
|
||||
|
||||
try:
|
||||
raw = await _llm_ollama(prompt, BACKFILL_SYSTEM_PROMPT)
|
||||
data = _parse_json(raw)
|
||||
if data and (data.get("article") or data.get("paragraph")):
|
||||
return MatchResult(
|
||||
article=data.get("article", ""),
|
||||
paragraph=data.get("paragraph", ""),
|
||||
method="llm",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("LLM match failed for %s: %s", ctrl.get("control_id"), e)
|
||||
|
||||
return None
|
||||
|
||||
def _update_control(self, ctrl: dict, match: MatchResult):
|
||||
"""Update source_citation and generation_metadata in DB."""
|
||||
citation = ctrl.get("source_citation") or {}
|
||||
|
||||
# Clean the source name: remove concatenated article if present
|
||||
source_str = citation.get("source", "")
|
||||
parsed = _parse_concatenated_source(source_str)
|
||||
if parsed:
|
||||
citation["source"] = parsed["name"]
|
||||
|
||||
# Add separate article/paragraph fields
|
||||
citation["article"] = match.article
|
||||
citation["paragraph"] = match.paragraph
|
||||
|
||||
# Update generation_metadata
|
||||
metadata = ctrl.get("generation_metadata") or {}
|
||||
if match.article:
|
||||
metadata["source_article"] = match.article
|
||||
metadata["source_paragraph"] = match.paragraph
|
||||
metadata["backfill_method"] = match.method
|
||||
metadata["backfill_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
self.db.execute(
|
||||
text("""
|
||||
UPDATE canonical_controls
|
||||
SET source_citation = :citation,
|
||||
generation_metadata = :metadata,
|
||||
updated_at = NOW()
|
||||
WHERE id = CAST(:id AS uuid)
|
||||
"""),
|
||||
{
|
||||
"id": ctrl["id"],
|
||||
"citation": json.dumps(citation),
|
||||
"metadata": json.dumps(metadata),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_concatenated_source(source: str) -> Optional[dict]:
|
||||
"""Parse 'DSGVO Art. 35' → {name: 'DSGVO', article: 'Art. 35'}.
|
||||
|
||||
Also handles '§' format: 'BDSG § 42' → {name: 'BDSG', article: '§ 42'}.
|
||||
"""
|
||||
if not source:
|
||||
return None
|
||||
|
||||
# Try Art./Artikel pattern
|
||||
m = _SOURCE_ARTICLE_RE.match(source)
|
||||
if m:
|
||||
return {"name": m.group(1).strip(), "article": m.group(2).strip()}
|
||||
|
||||
# Try § pattern
|
||||
m2 = re.match(r"^(.+?)\s+(§\s*\d+.*)$", source)
|
||||
if m2:
|
||||
return {"name": m2.group(1).strip(), "article": m2.group(2).strip()}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _llm_ollama(prompt: str, system_prompt: Optional[str] = None) -> str:
|
||||
"""Call Ollama chat API for backfill matching."""
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload = {
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"num_predict": 256},
|
||||
"think": False,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
|
||||
resp = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)
|
||||
if resp.status_code != 200:
|
||||
logger.error("Ollama backfill failed %d: %s", resp.status_code, resp.text[:300])
|
||||
return ""
|
||||
data = resp.json()
|
||||
msg = data.get("message", {})
|
||||
if isinstance(msg, dict):
|
||||
return msg.get("content", "")
|
||||
return data.get("response", str(msg))
|
||||
except Exception as e:
|
||||
logger.error("Ollama backfill request failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_json(raw: str) -> Optional[dict]:
|
||||
"""Extract JSON object from LLM output."""
|
||||
if not raw:
|
||||
return None
|
||||
# Try direct parse
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Try extracting from markdown code block
|
||||
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Try finding first { ... }
|
||||
m = re.search(r"\{[^{}]*\}", raw)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
@@ -44,6 +44,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SDK_URL = os.getenv("SDK_URL", "http://ai-compliance-sdk:8090")
|
||||
EMBEDDING_URL = os.getenv("EMBEDDING_URL", "http://embedding-service:8087")
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://host.docker.internal:6333")
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_MODEL = os.getenv("CONTROL_GEN_ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||||
@@ -54,7 +55,6 @@ HARMONIZATION_THRESHOLD = 0.85 # Cosine similarity above this = duplicate
|
||||
|
||||
ALL_COLLECTIONS = [
|
||||
"bp_compliance_ce",
|
||||
"bp_compliance_recht",
|
||||
"bp_compliance_gesetze",
|
||||
"bp_compliance_datenschutz",
|
||||
"bp_dsfa_corpus",
|
||||
@@ -312,6 +312,12 @@ CATEGORY_KEYWORDS = {
|
||||
"hygiene", "infektionsschutz", "pflege"],
|
||||
}
|
||||
|
||||
VALID_CATEGORIES = set(CATEGORY_KEYWORDS.keys())
|
||||
VALID_DOMAINS = {"AUTH", "CRYP", "NET", "DATA", "LOG", "ACC", "SEC", "INC",
|
||||
"AI", "COMP", "GOV", "LAB", "FIN", "TRD", "ENV", "HLT"}
|
||||
|
||||
CATEGORY_LIST_STR = ", ".join(sorted(VALID_CATEGORIES))
|
||||
|
||||
VERIFICATION_KEYWORDS = {
|
||||
"code_review": ["source code", "code review", "static analysis", "sast", "dast",
|
||||
"dependency check", "quellcode", "codeanalyse", "secure coding",
|
||||
@@ -373,6 +379,7 @@ class GeneratorConfig(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
batch_size: int = 5
|
||||
max_controls: int = 0 # 0 = unlimited (process ALL chunks)
|
||||
max_chunks: int = 0 # 0 = unlimited; >0 = stop after N chunks (respects document boundaries)
|
||||
skip_processed: bool = True
|
||||
skip_web_search: bool = False
|
||||
dry_run: bool = False
|
||||
@@ -418,6 +425,7 @@ class GeneratorResult:
|
||||
controls_needs_review: int = 0
|
||||
controls_too_close: int = 0
|
||||
controls_duplicates_found: int = 0
|
||||
controls_qa_fixed: int = 0
|
||||
chunks_skipped_prefilter: int = 0
|
||||
errors: list = field(default_factory=list)
|
||||
controls: list = field(default_factory=list)
|
||||
@@ -713,7 +721,7 @@ class ControlGeneratorPipeline:
|
||||
async def _scan_rag(self, config: GeneratorConfig) -> list[RAGSearchResult]:
|
||||
"""Scroll through ALL chunks in RAG collections.
|
||||
|
||||
Uses the scroll endpoint to iterate over every chunk (not just top-K search).
|
||||
Uses DIRECT Qdrant scroll API (bypasses Go SDK which has offset cycling bugs).
|
||||
Filters out already-processed chunks by hash.
|
||||
"""
|
||||
collections = config.collections or ALL_COLLECTIONS
|
||||
@@ -734,80 +742,105 @@ class ControlGeneratorPipeline:
|
||||
seen_hashes: set[str] = set()
|
||||
|
||||
for collection in collections:
|
||||
offset = None
|
||||
page = 0
|
||||
collection_total = 0
|
||||
collection_new = 0
|
||||
max_pages = 1000 # Safety limit: 1000 pages × 200 = 200K chunks max per collection
|
||||
prev_chunk_count = -1 # Track stalls (same count means no progress)
|
||||
stall_count = 0
|
||||
qdrant_offset = None # Qdrant uses point ID as offset
|
||||
|
||||
while page < max_pages:
|
||||
chunks, next_offset = await self.rag.scroll(
|
||||
collection=collection,
|
||||
offset=offset,
|
||||
limit=200,
|
||||
while True:
|
||||
# Direct Qdrant scroll API — bypasses Go SDK offset cycling bug
|
||||
try:
|
||||
scroll_body: dict = {
|
||||
"limit": 250,
|
||||
"with_payload": True,
|
||||
"with_vector": False,
|
||||
}
|
||||
if qdrant_offset is not None:
|
||||
scroll_body["offset"] = qdrant_offset
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{QDRANT_URL}/collections/{collection}/points/scroll",
|
||||
json=scroll_body,
|
||||
)
|
||||
|
||||
if not chunks:
|
||||
if resp.status_code != 200:
|
||||
logger.error("Qdrant scroll %s failed: %d %s", collection, resp.status_code, resp.text[:200])
|
||||
break
|
||||
data = resp.json().get("result", {})
|
||||
points = data.get("points", [])
|
||||
next_page_offset = data.get("next_page_offset")
|
||||
except Exception as e:
|
||||
logger.error("Qdrant scroll error for %s: %s", collection, e)
|
||||
break
|
||||
|
||||
collection_total += len(chunks)
|
||||
if not points:
|
||||
break
|
||||
|
||||
for chunk in chunks:
|
||||
if not chunk.text or len(chunk.text.strip()) < 50:
|
||||
continue # Skip empty/tiny chunks
|
||||
collection_total += len(points)
|
||||
|
||||
h = hashlib.sha256(chunk.text.encode()).hexdigest()
|
||||
for point in points:
|
||||
payload = point.get("payload", {})
|
||||
# Different collections use different field names for text
|
||||
chunk_text = (payload.get("chunk_text", "")
|
||||
or payload.get("content", "")
|
||||
or payload.get("text", "")
|
||||
or payload.get("page_content", ""))
|
||||
if not chunk_text or len(chunk_text.strip()) < 50:
|
||||
continue
|
||||
|
||||
h = hashlib.sha256(chunk_text.encode()).hexdigest()
|
||||
|
||||
# Skip duplicates (same text in multiple collections)
|
||||
if h in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(h)
|
||||
|
||||
# Skip already-processed
|
||||
if h in processed_hashes:
|
||||
continue
|
||||
|
||||
# Convert Qdrant point to RAGSearchResult
|
||||
# Handle varying payload schemas across collections
|
||||
reg_code = (payload.get("regulation_id", "")
|
||||
or payload.get("regulation_code", "")
|
||||
or payload.get("source_id", "")
|
||||
or payload.get("source_code", ""))
|
||||
reg_name = (payload.get("regulation_name_de", "")
|
||||
or payload.get("regulation_name", "")
|
||||
or payload.get("source_name", "")
|
||||
or payload.get("guideline_name", "")
|
||||
or payload.get("document_title", "")
|
||||
or payload.get("filename", ""))
|
||||
reg_short = (payload.get("regulation_short", "")
|
||||
or reg_code)
|
||||
chunk = RAGSearchResult(
|
||||
text=chunk_text,
|
||||
regulation_code=reg_code,
|
||||
regulation_name=reg_name,
|
||||
regulation_short=reg_short,
|
||||
category=payload.get("category", "") or payload.get("data_type", ""),
|
||||
article=payload.get("article", "") or payload.get("section_title", "") or payload.get("section", ""),
|
||||
paragraph=payload.get("paragraph", ""),
|
||||
source_url=payload.get("source_url", "") or payload.get("source", "") or payload.get("url", ""),
|
||||
score=0.0,
|
||||
collection=collection,
|
||||
)
|
||||
all_results.append(chunk)
|
||||
collection_new += 1
|
||||
|
||||
page += 1
|
||||
if page % 50 == 0:
|
||||
if page % 100 == 0:
|
||||
logger.info(
|
||||
"Scrolling %s: page %d, %d total chunks, %d new unprocessed",
|
||||
"Scrolling %s (direct Qdrant): page %d, %d total chunks, %d new unprocessed",
|
||||
collection, page, collection_total, collection_new,
|
||||
)
|
||||
|
||||
# Stop conditions
|
||||
if not next_offset:
|
||||
break
|
||||
if next_page_offset is None:
|
||||
break # Qdrant returns null when no more pages
|
||||
|
||||
# Detect stalls: if no NEW unique chunks found for several pages,
|
||||
# we've likely cycled through all chunks in this collection.
|
||||
# (Safer than offset dedup which breaks with mixed Qdrant ID types)
|
||||
if collection_new == prev_chunk_count:
|
||||
stall_count += 1
|
||||
if stall_count >= 5:
|
||||
logger.warning(
|
||||
"Scroll stalled in %s at page %d — no new unique chunks for 5 pages (%d total, %d new) — stopping",
|
||||
collection, page, collection_total, collection_new,
|
||||
)
|
||||
break
|
||||
else:
|
||||
stall_count = 0
|
||||
prev_chunk_count = collection_new
|
||||
|
||||
offset = next_offset
|
||||
|
||||
if page >= max_pages:
|
||||
logger.warning(
|
||||
"Collection %s: reached max_pages limit (%d). %d chunks scrolled.",
|
||||
collection, max_pages, collection_total,
|
||||
)
|
||||
qdrant_offset = next_page_offset
|
||||
|
||||
logger.info(
|
||||
"Collection %s: %d total chunks scrolled, %d new unprocessed",
|
||||
"Collection %s: %d total chunks scrolled (direct Qdrant), %d new unprocessed",
|
||||
collection, collection_total, collection_new,
|
||||
)
|
||||
|
||||
@@ -857,6 +890,11 @@ Gib JSON zurück mit diesen Feldern:
|
||||
- evidence: Liste von Nachweisdokumenten (Strings)
|
||||
- severity: low/medium/high/critical
|
||||
- tags: Liste von Tags
|
||||
- domain: Fachgebiet als Kuerzel (AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden/Verwaltung, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe/Handelsrecht, ENV=Umwelt, HLT=Gesundheit)
|
||||
- category: Inhaltliche Kategorie — MUSS zum domain passen. Moegliche Werte: {CATEGORY_LIST_STR}
|
||||
- target_audience: Liste der Zielgruppen (z.B. "unternehmen", "behoerden", "entwickler", "datenschutzbeauftragte", "geschaeftsfuehrung", "it-abteilung", "rechtsabteilung", "compliance-officer", "personalwesen", "einkauf", "produktion", "gesundheitswesen", "finanzwesen", "oeffentlicher_dienst")
|
||||
- source_article: Artikel-/Paragraphen-Referenz aus dem Text (z.B. "Artikel 10", "§ 42"). Leer lassen wenn nicht erkennbar.
|
||||
- source_paragraph: Absatz-Referenz aus dem Text (z.B. "Absatz 5", "Nr. 2"). Leer lassen wenn nicht erkennbar.
|
||||
|
||||
Text: {chunk.text[:2000]}
|
||||
Quelle: {chunk.regulation_name} ({chunk.regulation_code}), {chunk.article}"""
|
||||
@@ -868,24 +906,29 @@ Quelle: {chunk.regulation_name} ({chunk.regulation_code}), {chunk.article}"""
|
||||
|
||||
domain = _detect_domain(chunk.text)
|
||||
control = self._build_control_from_json(data, domain)
|
||||
llm_article = str(data.get("source_article", "")).strip()
|
||||
llm_paragraph = str(data.get("source_paragraph", "")).strip()
|
||||
effective_article = llm_article or chunk.article or ""
|
||||
effective_paragraph = llm_paragraph or chunk.paragraph or ""
|
||||
control.license_rule = 1
|
||||
control.source_original_text = chunk.text
|
||||
control.source_citation = {
|
||||
"source": chunk.regulation_name,
|
||||
"article": chunk.article or "",
|
||||
"paragraph": chunk.paragraph or "",
|
||||
"article": effective_article,
|
||||
"paragraph": effective_paragraph,
|
||||
"license": license_info.get("license", ""),
|
||||
"url": chunk.source_url or "",
|
||||
}
|
||||
control.customer_visible = True
|
||||
control.verification_method = _detect_verification_method(chunk.text)
|
||||
if not control.category:
|
||||
control.category = _detect_category(chunk.text)
|
||||
control.generation_metadata = {
|
||||
"processing_path": "structured",
|
||||
"license_rule": 1,
|
||||
"source_regulation": chunk.regulation_code,
|
||||
"source_article": chunk.article,
|
||||
"source_paragraph": chunk.paragraph,
|
||||
"source_article": effective_article,
|
||||
"source_paragraph": effective_paragraph,
|
||||
}
|
||||
return control
|
||||
|
||||
@@ -910,6 +953,11 @@ Gib JSON zurück mit diesen Feldern:
|
||||
- evidence: Liste von Nachweisdokumenten (Strings)
|
||||
- severity: low/medium/high/critical
|
||||
- tags: Liste von Tags
|
||||
- domain: Fachgebiet als Kuerzel (AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden/Verwaltung, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe/Handelsrecht, ENV=Umwelt, HLT=Gesundheit)
|
||||
- category: Inhaltliche Kategorie — MUSS zum domain passen. Moegliche Werte: {CATEGORY_LIST_STR}
|
||||
- target_audience: Liste der Zielgruppen (z.B. "unternehmen", "behoerden", "entwickler", "datenschutzbeauftragte", "geschaeftsfuehrung", "it-abteilung", "rechtsabteilung", "compliance-officer", "personalwesen", "einkauf", "produktion", "gesundheitswesen", "finanzwesen", "oeffentlicher_dienst")
|
||||
- source_article: Artikel-/Paragraphen-Referenz aus dem Text (z.B. "Artikel 10", "§ 42"). Leer lassen wenn nicht erkennbar.
|
||||
- source_paragraph: Absatz-Referenz aus dem Text (z.B. "Absatz 5", "Nr. 2"). Leer lassen wenn nicht erkennbar.
|
||||
|
||||
Text: {chunk.text[:2000]}
|
||||
Quelle: {chunk.regulation_name}, {chunk.article}"""
|
||||
@@ -921,25 +969,30 @@ Quelle: {chunk.regulation_name}, {chunk.article}"""
|
||||
|
||||
domain = _detect_domain(chunk.text)
|
||||
control = self._build_control_from_json(data, domain)
|
||||
llm_article = str(data.get("source_article", "")).strip()
|
||||
llm_paragraph = str(data.get("source_paragraph", "")).strip()
|
||||
effective_article = llm_article or chunk.article or ""
|
||||
effective_paragraph = llm_paragraph or chunk.paragraph or ""
|
||||
control.license_rule = 2
|
||||
control.source_original_text = chunk.text
|
||||
control.source_citation = {
|
||||
"source": chunk.regulation_name,
|
||||
"article": chunk.article or "",
|
||||
"paragraph": chunk.paragraph or "",
|
||||
"article": effective_article,
|
||||
"paragraph": effective_paragraph,
|
||||
"license": license_info.get("license", ""),
|
||||
"license_notice": attribution,
|
||||
"url": chunk.source_url or "",
|
||||
}
|
||||
control.customer_visible = True
|
||||
control.verification_method = _detect_verification_method(chunk.text)
|
||||
if not control.category:
|
||||
control.category = _detect_category(chunk.text)
|
||||
control.generation_metadata = {
|
||||
"processing_path": "structured",
|
||||
"license_rule": 2,
|
||||
"source_regulation": chunk.regulation_code,
|
||||
"source_article": chunk.article,
|
||||
"source_paragraph": chunk.paragraph,
|
||||
"source_article": effective_article,
|
||||
"source_paragraph": effective_paragraph,
|
||||
}
|
||||
return control
|
||||
|
||||
@@ -968,7 +1021,8 @@ Gib JSON zurück mit diesen Feldern:
|
||||
- evidence: Liste von Nachweisdokumenten (Strings)
|
||||
- severity: low/medium/high/critical
|
||||
- tags: Liste von Tags (eigene Begriffe)
|
||||
- domain: Fachgebiet als Kuerzel (AUTH, CRYP, NET, DATA, LOG, ACC, SEC, INC, AI, COMP, GOV, LAB, FIN, TRD, ENV, HLT)
|
||||
- domain: Fachgebiet als Kuerzel (AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden/Verwaltung, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe/Handelsrecht, ENV=Umwelt, HLT=Gesundheit)
|
||||
- category: Inhaltliche Kategorie — MUSS zum domain passen. Moegliche Werte: {CATEGORY_LIST_STR}
|
||||
- target_audience: Liste der Zielgruppen (z.B. "unternehmen", "behoerden", "entwickler", "datenschutzbeauftragte", "geschaeftsfuehrung", "it-abteilung", "rechtsabteilung", "compliance-officer", "personalwesen", "oeffentlicher_dienst")"""
|
||||
|
||||
raw = await _llm_chat(prompt, REFORM_SYSTEM_PROMPT)
|
||||
@@ -982,6 +1036,7 @@ Gib JSON zurück mit diesen Feldern:
|
||||
control.source_citation = None # NEVER cite source
|
||||
control.customer_visible = False # Only our formulation
|
||||
control.verification_method = _detect_verification_method(chunk.text)
|
||||
if not control.category:
|
||||
control.category = _detect_category(chunk.text)
|
||||
# generation_metadata: NO source names, NO original texts
|
||||
control.generation_metadata = {
|
||||
@@ -1046,7 +1101,10 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
- severity: low/medium/high/critical
|
||||
- tags: Liste von Tags
|
||||
- domain: Fachgebiet als Kuerzel (AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden/Verwaltung, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe/Handelsrecht, ENV=Umwelt, HLT=Gesundheit)
|
||||
- category: Inhaltliche Kategorie — MUSS zum domain passen. Moegliche Werte: {CATEGORY_LIST_STR}
|
||||
- target_audience: Liste der Zielgruppen fuer die dieses Control relevant ist. Moegliche Werte: "unternehmen", "behoerden", "entwickler", "datenschutzbeauftragte", "geschaeftsfuehrung", "it-abteilung", "rechtsabteilung", "compliance-officer", "personalwesen", "einkauf", "produktion", "vertrieb", "gesundheitswesen", "finanzwesen", "oeffentlicher_dienst"
|
||||
- source_article: Artikel-/Paragraphen-Referenz aus dem Text extrahieren (z.B. "Artikel 10", "Art. 5", "§ 42", "Section 3"). Leer lassen wenn nicht erkennbar.
|
||||
- source_paragraph: Absatz-Referenz aus dem Text extrahieren (z.B. "Absatz 5", "Abs. 3", "Nr. 2", "(1)"). Leer lassen wenn nicht erkennbar.
|
||||
|
||||
{joined}"""
|
||||
|
||||
@@ -1071,26 +1129,32 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
domain = _detect_domain(chunk.text)
|
||||
control = self._build_control_from_json(data, domain)
|
||||
control.license_rule = lic["rule"]
|
||||
# Use LLM-extracted article/paragraph, fall back to chunk metadata
|
||||
llm_article = str(data.get("source_article", "")).strip()
|
||||
llm_paragraph = str(data.get("source_paragraph", "")).strip()
|
||||
effective_article = llm_article or chunk.article or ""
|
||||
effective_paragraph = llm_paragraph or chunk.paragraph or ""
|
||||
if lic["rule"] in (1, 2):
|
||||
control.source_original_text = chunk.text
|
||||
control.source_citation = {
|
||||
"source": chunk.regulation_name,
|
||||
"article": chunk.article or "",
|
||||
"paragraph": chunk.paragraph or "",
|
||||
"article": effective_article,
|
||||
"paragraph": effective_paragraph,
|
||||
"license": lic.get("license", ""),
|
||||
"license_notice": lic.get("attribution", ""),
|
||||
"url": chunk.source_url or "",
|
||||
}
|
||||
control.customer_visible = True
|
||||
control.verification_method = _detect_verification_method(chunk.text)
|
||||
if not control.category:
|
||||
control.category = _detect_category(chunk.text)
|
||||
same_doc = len(set(c.regulation_code for c in chunks)) == 1
|
||||
control.generation_metadata = {
|
||||
"processing_path": "structured_batch",
|
||||
"license_rule": lic["rule"],
|
||||
"source_regulation": chunk.regulation_code,
|
||||
"source_article": chunk.article,
|
||||
"source_paragraph": chunk.paragraph,
|
||||
"source_article": effective_article,
|
||||
"source_paragraph": effective_paragraph,
|
||||
"batch_size": len(chunks),
|
||||
"document_grouped": same_doc,
|
||||
}
|
||||
@@ -1133,6 +1197,7 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
- severity: low/medium/high/critical
|
||||
- tags: Liste von Tags (eigene Begriffe)
|
||||
- domain: Fachgebiet als Kuerzel (AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden/Verwaltung, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe/Handelsrecht, ENV=Umwelt, HLT=Gesundheit)
|
||||
- category: Inhaltliche Kategorie — MUSS zum domain passen. Moegliche Werte: {CATEGORY_LIST_STR}
|
||||
- target_audience: Liste der Zielgruppen (z.B. "unternehmen", "behoerden", "entwickler", "datenschutzbeauftragte", "geschaeftsfuehrung", "it-abteilung", "rechtsabteilung", "compliance-officer", "personalwesen", "einkauf", "produktion", "gesundheitswesen", "finanzwesen", "oeffentlicher_dienst")
|
||||
|
||||
{joined}"""
|
||||
@@ -1159,6 +1224,7 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
control.source_citation = None
|
||||
control.customer_visible = False
|
||||
control.verification_method = _detect_verification_method(chunk.text)
|
||||
if not control.category:
|
||||
control.category = _detect_category(chunk.text)
|
||||
control.generation_metadata = {
|
||||
"processing_path": "llm_reform_batch",
|
||||
@@ -1209,6 +1275,9 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
all_controls[orig_idx] = ctrl
|
||||
|
||||
# Post-process all controls: harmonization + anchor search
|
||||
# NOTE: QA validation runs as a separate batch AFTER generation (qa-reclassify endpoint)
|
||||
# to avoid competing with Ollama prefilter for resources.
|
||||
qa_fixed_count = 0
|
||||
final: list[Optional[GeneratedControl]] = []
|
||||
for i in range(len(batch_items)):
|
||||
control = all_controls.get(i)
|
||||
@@ -1245,7 +1314,7 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
else:
|
||||
control.release_state = "needs_review"
|
||||
|
||||
# Control ID — prefer LLM-assigned domain over keyword detection
|
||||
# Control ID — prefer QA-corrected or LLM-assigned domain over keyword detection
|
||||
domain = (control.generation_metadata.get("_effective_domain")
|
||||
or config.domain
|
||||
or _detect_domain(control.objective))
|
||||
@@ -1254,7 +1323,9 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
|
||||
final.append(control)
|
||||
|
||||
return final
|
||||
if qa_fixed_count:
|
||||
logger.info("QA validation: fixed %d/%d controls in batch", qa_fixed_count, len(final))
|
||||
return final, qa_fixed_count
|
||||
|
||||
# ── Stage 4: Harmonization ─────────────────────────────────────────
|
||||
|
||||
@@ -1337,11 +1408,15 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
|
||||
# Use LLM-provided domain if available, fallback to keyword-detected domain
|
||||
llm_domain = data.get("domain")
|
||||
valid_domains = {"AUTH", "CRYP", "NET", "DATA", "LOG", "ACC", "SEC", "INC",
|
||||
"AI", "COMP", "GOV", "LAB", "FIN", "TRD", "ENV", "HLT"}
|
||||
if llm_domain and llm_domain.upper() in valid_domains:
|
||||
if llm_domain and llm_domain.upper() in VALID_DOMAINS:
|
||||
domain = llm_domain.upper()
|
||||
|
||||
# Use LLM-provided category if available
|
||||
llm_category = data.get("category")
|
||||
category = None
|
||||
if llm_category and llm_category in VALID_CATEGORIES:
|
||||
category = llm_category
|
||||
|
||||
# Parse target_audience from LLM response
|
||||
target_audience = data.get("target_audience")
|
||||
if isinstance(target_audience, str):
|
||||
@@ -1362,6 +1437,7 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
implementation_effort=data.get("implementation_effort", "m") if data.get("implementation_effort") in ("s", "m", "l", "xl") else "m",
|
||||
tags=tags[:20],
|
||||
target_audience=target_audience,
|
||||
category=category,
|
||||
)
|
||||
# Store effective domain for later control_id generation
|
||||
control.generation_metadata["_effective_domain"] = domain
|
||||
@@ -1395,6 +1471,79 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
pass
|
||||
return f"{prefix}-001"
|
||||
|
||||
# ── Stage QA: Automated Quality Validation ───────────────────────
|
||||
|
||||
async def _qa_validate_control(
|
||||
self, control: GeneratedControl, chunk_text: str
|
||||
) -> tuple[GeneratedControl, bool]:
|
||||
"""Cross-validate category/domain using keyword detection + local LLM.
|
||||
|
||||
Returns (control, was_fixed). Only triggers Ollama QA when the LLM
|
||||
classification disagrees with keyword detection — keeps it fast.
|
||||
"""
|
||||
kw_category = _detect_category(chunk_text) or _detect_category(control.objective)
|
||||
kw_domain = _detect_domain(chunk_text)
|
||||
llm_domain = control.generation_metadata.get("_effective_domain", "")
|
||||
|
||||
# If keyword and LLM agree → no QA needed
|
||||
if control.category == kw_category and llm_domain == kw_domain:
|
||||
return control, False
|
||||
|
||||
# Disagreement detected → ask local LLM to arbitrate
|
||||
title = control.title[:100]
|
||||
objective = control.objective[:200]
|
||||
reqs = ", ".join(control.requirements[:3]) if control.requirements else "keine"
|
||||
prompt = f"""Pruefe dieses Compliance-Control auf korrekte Klassifizierung.
|
||||
|
||||
Titel: {title}
|
||||
Ziel: {objective}
|
||||
Anforderungen: {reqs}
|
||||
|
||||
Aktuelle Zuordnung: domain={llm_domain}, category={control.category}
|
||||
Keyword-Erkennung: domain={kw_domain}, category={kw_category}
|
||||
|
||||
Welche Zuordnung ist korrekt? Antworte NUR als JSON:
|
||||
{{"domain": "KUERZEL", "category": "kategorie_name", "reason": "kurze Begruendung"}}
|
||||
|
||||
Domains: AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe, ENV=Umwelt, HLT=Gesundheit
|
||||
Kategorien: {CATEGORY_LIST_STR}"""
|
||||
|
||||
try:
|
||||
raw = await _llm_local(prompt)
|
||||
data = _parse_llm_json(raw)
|
||||
if not data:
|
||||
return control, False
|
||||
|
||||
fixed = False
|
||||
qa_domain = data.get("domain", "").upper()
|
||||
qa_category = data.get("category", "")
|
||||
reason = data.get("reason", "")
|
||||
|
||||
if qa_category and qa_category in VALID_CATEGORIES and qa_category != control.category:
|
||||
old_cat = control.category
|
||||
control.category = qa_category
|
||||
control.generation_metadata["qa_category_fix"] = {
|
||||
"from": old_cat, "to": qa_category, "reason": reason,
|
||||
}
|
||||
logger.info("QA fix: '%s' category '%s' -> '%s' (%s)",
|
||||
title[:40], old_cat, qa_category, reason)
|
||||
fixed = True
|
||||
|
||||
if qa_domain and qa_domain in VALID_DOMAINS and qa_domain != llm_domain:
|
||||
control.generation_metadata["qa_domain_fix"] = {
|
||||
"from": llm_domain, "to": qa_domain, "reason": reason,
|
||||
}
|
||||
control.generation_metadata["_effective_domain"] = qa_domain
|
||||
logger.info("QA fix: '%s' domain '%s' -> '%s' (%s)",
|
||||
title[:40], llm_domain, qa_domain, reason)
|
||||
fixed = True
|
||||
|
||||
return control, fixed
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("QA validation failed for '%s': %s", title[:40], e)
|
||||
return control, False
|
||||
|
||||
# ── Pipeline Orchestration ─────────────────────────────────────────
|
||||
|
||||
def _create_job(self, config: GeneratorConfig) -> str:
|
||||
@@ -1605,11 +1754,29 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
len(chunks), len(doc_groups),
|
||||
)
|
||||
|
||||
# Flatten back: chunks from same document are now adjacent
|
||||
# ── Apply max_chunks limit respecting document boundaries ──
|
||||
# Process complete documents until we exceed the limit.
|
||||
# Never split a document across jobs.
|
||||
chunks = []
|
||||
if config.max_chunks and config.max_chunks > 0:
|
||||
for group_key, group_list in doc_groups.items():
|
||||
if chunks and len(chunks) + len(group_list) > config.max_chunks:
|
||||
# Adding this document would exceed the limit — stop here
|
||||
break
|
||||
chunks.extend(group_list)
|
||||
logger.info(
|
||||
"max_chunks=%d: selected %d chunks from %d complete documents (of %d total groups)",
|
||||
config.max_chunks, len(chunks),
|
||||
len(set(c.regulation_code for c in chunks)),
|
||||
len(doc_groups),
|
||||
)
|
||||
else:
|
||||
# No limit: flatten all groups
|
||||
for group_list in doc_groups.values():
|
||||
chunks.extend(group_list)
|
||||
|
||||
result.total_chunks_scanned = len(chunks)
|
||||
|
||||
# Process chunks — batch mode (N chunks per Anthropic API call)
|
||||
BATCH_SIZE = config.batch_size or 5
|
||||
controls_count = 0
|
||||
@@ -1633,7 +1800,8 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
len(batch), ", ".join(regs_in_batch),
|
||||
)
|
||||
try:
|
||||
batch_controls = await self._process_batch(batch, config, job_id)
|
||||
batch_controls, batch_qa_fixes = await self._process_batch(batch, config, job_id)
|
||||
result.controls_qa_fixed += batch_qa_fixes
|
||||
except Exception as e:
|
||||
logger.error("Batch processing failed: %s — falling back to single-chunk mode", e)
|
||||
# Fallback: process each chunk individually
|
||||
@@ -1785,6 +1953,8 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
if not control.title or not control.objective:
|
||||
return None
|
||||
|
||||
# NOTE: QA validation runs as a separate batch AFTER generation (qa-reclassify endpoint)
|
||||
|
||||
# Stage 4: Harmonization
|
||||
duplicates = await self._check_harmonization(control)
|
||||
if duplicates:
|
||||
@@ -1809,8 +1979,10 @@ Gib ein JSON-Array zurueck mit GENAU {len(chunks)} Objekten. Jedes Objekt hat di
|
||||
else:
|
||||
control.release_state = "needs_review"
|
||||
|
||||
# Generate control_id
|
||||
domain = config.domain or _detect_domain(control.objective)
|
||||
# Generate control_id — prefer QA-corrected or LLM-assigned domain
|
||||
domain = (control.generation_metadata.get("_effective_domain")
|
||||
or config.domain
|
||||
or _detect_domain(control.objective))
|
||||
control.control_id = self._generate_control_id(domain, self.db)
|
||||
|
||||
# Store job_id in metadata
|
||||
|
||||
292
backend-compliance/migrations/059_wiki_cra_annex_i_detail.sql
Normal file
292
backend-compliance/migrations/059_wiki_cra_annex_i_detail.sql
Normal file
@@ -0,0 +1,292 @@
|
||||
-- Migration 059: CRA Annex I — Detaillierte Essential Cybersecurity Requirements
|
||||
-- Erweitert den bestehenden Wiki-Artikel 'cra-security-controls' um Part 1 + Part 2,
|
||||
-- Produktklassifizierung und ISO 27001 Mapping.
|
||||
-- Zusaetzlich: Neuer Artikel fuer CRA-Produktklassifizierung und Konformitaetsbewertung.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1) Update: CRA Security Controls (Annex I) — Vollstaendige 8-Kategorien-Struktur
|
||||
-- ============================================================================
|
||||
UPDATE compliance_wiki_articles
|
||||
SET
|
||||
title = 'CRA Annex I — Essential Cybersecurity Requirements (Vollstaendig)',
|
||||
summary = 'Annex I des CRA definiert die wesentlichen Cybersicherheitsanforderungen in zwei Teilen: Teil 1 (Produktsicherheit, 11 Anforderungen) und Teil 2 (Schwachstellenbehandlung, 8 Anforderungen). Daraus ergeben sich rund 35 konkrete Security-Controls in 8 Kategorien.',
|
||||
content = '## Ueberblick
|
||||
|
||||
Der **EU Cyber Resilience Act (CRA)**, Verordnung (EU) 2024/2847, legt in **Annex I** die **Essential Cybersecurity Requirements** fest, die alle Produkte mit digitalen Elementen erfuellen muessen. Annex I besteht aus zwei Teilen:
|
||||
|
||||
- **Teil 1 — Sicherheitsanforderungen an Produkte** (11 Kernanforderungen)
|
||||
- **Teil 2 — Anforderungen an die Schwachstellenbehandlung** (8 Prozessanforderungen)
|
||||
|
||||
Daraus lassen sich etwa **35 konkrete Security-Controls** in **8 thematischen Kategorien** ableiten. Diese Controls bilden die Grundlage fuer eine Cybersecurity-Compliance-Strategie.
|
||||
|
||||
---
|
||||
|
||||
## Teil 1: Sicherheitsanforderungen an Produkte
|
||||
|
||||
### Kategorie 1 — Secure-by-Design und Architektur
|
||||
|
||||
Diese Controls stellen sicher, dass Sicherheit von Anfang an in die Produktarchitektur integriert wird.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 1 | **Secure-by-Default-Konfiguration** | Annex I, 1(1) | Produkte muessen mit sicheren Standardeinstellungen ausgeliefert werden. Keine offenen Ports, keine aktivierten Debug-Schnittstellen, keine unnoetig laufenden Dienste. | A.8.9 |
|
||||
| 2 | **Minimale Angriffsflaeche** | Annex I, 1(2) | Nur notwendige Schnittstellen, Dienste und Protokolle aktivieren. Jede zusaetzliche Funktionalitaet vergroessert die Angriffsflaeche und muss einzeln gerechtfertigt werden. | A.8.9, A.8.20 |
|
||||
| 3 | **Sichere Systemarchitektur** | Annex I, 1(3) | Sicherheitskritische Komponenten muessen isoliert werden (Sandboxing, Containerisierung, Privilege Separation). Defense-in-Depth-Prinzip anwenden. | A.8.27 |
|
||||
| 4 | **Least-Privilege-Prinzip** | Annex I, 1(3)(d) | Jede Komponente, jeder Prozess und jeder Benutzer erhaelt nur die minimal notwendigen Berechtigungen. Privilegien-Eskalation muss verhindert werden. | A.8.2, A.8.3 |
|
||||
| 5 | **Manipulationsschutz** | Annex I, 1(3)(c) | Schutz vor unautorisierter Aenderung von Software und Konfiguration durch Integritaetsmechanismen (Code Signing, Secure Boot, TPM). | A.8.24 |
|
||||
| 6 | **Integritaetspruefung** | Annex I, 1(3)(c) | Automatische Ueberpruefung der Integritaet von Software, Firmware und Konfigurationsdaten bei Start und Laufzeit. Hash-basierte Validierung und digitale Signaturen. | A.8.24 |
|
||||
|
||||
### Kategorie 2 — Authentifizierung und Zugriffskontrolle
|
||||
|
||||
Controls zur Sicherstellung, dass nur autorisierte Personen und Systeme Zugriff erhalten.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 7 | **Starke Authentifizierung** | Annex I, 1(3)(d) | Implementierung sicherer Authentifizierungsmechanismen. Multi-Faktor-Authentifizierung fuer administrative Zugriffe. Unterstuetzung moderner Standards (FIDO2, WebAuthn). | A.8.5 |
|
||||
| 8 | **Keine Default-Passwoerter** | Annex I, 1(3)(d) | Produkte duerfen keine universellen Standardpasswoerter verwenden. Jedes Geraet muss ein individuelles Passwort erhalten oder den Benutzer zur Aenderung bei Ersteinrichtung zwingen. | A.8.5 |
|
||||
| 9 | **Sicheres Credential-Management** | Annex I, 1(3)(d) | Zugangsdaten muessen verschluesselt gespeichert werden (bcrypt, Argon2id). Keine Klartextspeicherung. API-Keys und Tokens regelmaessig rotieren. | A.8.5 |
|
||||
| 10 | **Sitzungsmanagement** | Annex I, 1(3)(d) | Sichere Session-Verwaltung mit Timeout, Token-Binding und Session-Invalidierung bei Logout oder Passwortwechsel. CSRF-Schutz implementieren. | A.8.5 |
|
||||
| 11 | **Brute-Force-Schutz** | Annex I, 1(3)(d) | Schutz vor Brute-Force- und Credential-Stuffing-Angriffen durch Rate Limiting, Account Lockout und CAPTCHA-Mechanismen. | A.8.5, A.8.16 |
|
||||
| 12 | **Rollenbasierte Autorisierung** | Annex I, 1(3)(d) | Implementierung von RBAC (Role-Based Access Control). Trennung von administrativen und Nutzerfunktionen. Prinzip der geringsten Privilegien durchsetzen. | A.8.2, A.8.3 |
|
||||
|
||||
### Kategorie 3 — Kryptografie und Datenschutz
|
||||
|
||||
Controls zum Schutz von Daten durch kryptografische Verfahren.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 13 | **Verschluesselung sensibler Daten** | Annex I, 1(3)(e) | Alle sensiblen Daten muessen verschluesselt werden — sowohl bei der Speicherung (at rest, AES-256) als auch bei der Uebertragung (in transit, TLS 1.2+). | A.8.24 |
|
||||
| 14 | **Speicher-Schutz (Data at Rest)** | Annex I, 1(3)(e) | Verschluesselung gespeicherter Daten auf Festplatten, in Datenbanken und Backups. Schluessel getrennt von Daten speichern. | A.8.24 |
|
||||
| 15 | **Transport-Schutz (Data in Transit)** | Annex I, 1(3)(e) | Alle Netzwerkkommunikation ueber TLS 1.2 oder hoeher. Veraltete Protokolle (SSL, TLS 1.0/1.1) deaktivieren. Certificate Pinning fuer kritische Verbindungen. | A.8.24 |
|
||||
| 16 | **Sicheres Schluesselmanagement** | Annex I, 1(3)(e) | Kryptografische Schluessel in HSM oder Vault speichern. Regelmaessige Rotation (mind. jaehrlich). Dokumentation der Schluessel-Lebenszyklen. Sofortige Rotation bei Kompromittierungsverdacht. | A.8.24 |
|
||||
| 17 | **Datenminimierung** | Annex I, 1(3)(f) | Nur Daten erfassen und verarbeiten, die fuer die Produktfunktion erforderlich sind. Personenbezogene Daten gemaess DSGVO-Grundsaetzen behandeln. | A.8.10, A.8.11 |
|
||||
|
||||
### Kategorie 4 — Secure Software Development Lifecycle
|
||||
|
||||
Controls fuer sichere Softwareentwicklung ueber den gesamten Lebenszyklus.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 18 | **Strukturierter SSDLC** | Annex I, 1(1) | Implementierung eines formalen Secure Software Development Lifecycle mit definierten Security Gates in jeder Phase (Requirements, Design, Implementation, Test, Release). | A.8.25, A.8.26 |
|
||||
| 19 | **Systematische Code Reviews** | Annex I, 1(1) | Peer Reviews mit Security-Fokus fuer jeden Code-Commit. Einsatz von Checklisten fuer OWASP Top 10 und CWE Top 25. Security Champions in jedem Entwicklerteam. | A.8.25 |
|
||||
| 20 | **Automatisierte Sicherheitstests** | Annex I, 1(1) | Static Application Security Testing (SAST), Dynamic Application Security Testing (DAST) und Software Composition Analysis (SCA) in der CI/CD-Pipeline. Secrets Detection fuer eingebettete Zugangsdaten. | A.8.25 |
|
||||
| 21 | **Supply-Chain-Security** | Annex I, 1(5) | Systematische Pruefung aller Drittanbieter-Komponenten auf Schwachstellen und Lizenz-Compliance. Vertrauenswuerdigkeit von Lieferanten bewerten. | A.5.19, A.5.21 |
|
||||
| 22 | **Dependency-Monitoring** | Annex I, 1(5) | Kontinuierliche Ueberwachung aller Abhaengigkeiten auf bekannte Schwachstellen (CVE). Automatische Benachrichtigung bei neuen CVEs in verwendeten Bibliotheken. | A.8.8, A.8.25 |
|
||||
| 23 | **Software Bill of Materials (SBOM)** | Annex I, 1(5) | Fuer jedes Produkt ein maschinenlesbares SBOM fuehren (CycloneDX oder SPDX). Mindestens Top-Level-Abhaengigkeiten mit Name, Version und Lizenz dokumentieren. SBOM bei jedem Release aktualisieren. | A.8.25 |
|
||||
|
||||
### Kategorie 5 — Logging, Monitoring und Anomalie-Erkennung
|
||||
|
||||
Controls zur Erkennung und Nachverfolgung von Sicherheitsereignissen.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 24 | **Security-Logging** | Annex I, 1(3)(g) | Protokollierung aller sicherheitsrelevanten Ereignisse: Login-Versuche, Berechtigungsaenderungen, administrative Aktionen, API-Zugriffe, Fehler und Ausnahmen. Logs muessen Zeitstempel, Akteur, Aktion und Ergebnis enthalten. | A.8.15 |
|
||||
| 25 | **Ereignis-Monitoring** | Annex I, 1(3)(g) | Zentrale Sammlung und Echtzeit-Ueberwachung sicherheitsrelevanter Events. Einsatz eines SIEM-Systems oder vergleichbarer Loesung. Korrelation von Events aus verschiedenen Quellen. | A.8.16 |
|
||||
| 26 | **Anomalie-Erkennung** | Annex I, 1(3)(g) | Automatische Erkennung von Angriffsmustern und ungewoehnlichem Verhalten. Alarmierung bei Abweichungen von Baseline-Verhalten. Integration von Threat Intelligence Feeds. | A.8.16 |
|
||||
| 27 | **Log-Integritaet und -Aufbewahrung** | Annex I, 1(3)(g) | Logs muessen manipulationssicher gespeichert werden (append-only, signiert oder WORM). Aufbewahrung mindestens 12 Monate. Zugriff auf Logs nur fuer autorisiertes Security-Personal. | A.8.15 |
|
||||
|
||||
### Kategorie 6 — Update- und Patch-Management
|
||||
|
||||
Controls fuer die sichere Bereitstellung und Installation von Updates.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 28 | **Sichere Update-Mechanismen** | Annex I, 1(4) | Updates muessen ueber sichere Kanaele verteilt werden (HTTPS, signierte Pakete). Automatische oder einfach zugaengliche Update-Moeglichkeit fuer Endnutzer. Rollback-Faehigkeit bei fehlerhaften Updates. | A.8.8, A.8.19 |
|
||||
| 29 | **Update-Authentizitaet** | Annex I, 1(4) | Alle Updates muessen digital signiert sein. Signaturpruefung vor Installation erzwingen. Verwendung vertrauenswuerdiger Signaturschluessel mit dokumentierter Key Ceremony. | A.8.24 |
|
||||
| 30 | **Update-Integritaet** | Annex I, 1(4) | Integritaetspruefung jedes Update-Pakets vor und nach Installation (Hash-Vergleich, Signatur-Verifikation). Manipulation waehrend der Uebertragung erkennen und ablehnen. | A.8.24 |
|
||||
| 31 | **Lifecycle-Support** | Annex I, 1(4) | Security-Updates waehrend des gesamten erwarteten Produktlebenszyklus bereitstellen — mindestens **5 Jahre** ab Inverkehrbringen oder die erwartete Nutzungsdauer, je nachdem welcher Zeitraum laenger ist. End-of-Life klar kommunizieren. | A.8.8 |
|
||||
|
||||
---
|
||||
|
||||
## Teil 2: Anforderungen an die Schwachstellenbehandlung
|
||||
|
||||
### Kategorie 7 — Vulnerability Management
|
||||
|
||||
Controls fuer die systematische Identifikation, Bewertung und Behebung von Schwachstellen.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 32 | **Schwachstellen-Identifikation** | Annex I, 2(1) | Kontinuierliches CVE-Monitoring aller eingesetzten Komponenten. Regelmaessige Vulnerability Scans (woechentlich automatisiert). Bug-Bounty-Programme oder Responsible-Disclosure-Kanaele einrichten. | A.8.8 |
|
||||
| 33 | **SBOM-Pflege und Analyse** | Annex I, 2(1) | SBOM aktuell halten und kontinuierlich gegen CVE-Datenbanken pruefen. Automatische Alarmierung bei neu entdeckten Schwachstellen in verwendeten Komponenten. | A.8.8, A.8.25 |
|
||||
| 34 | **Risikobasierte Priorisierung** | Annex I, 2(2) | Schwachstellen nach CVSS-Score und tatsaechlichem Risiko priorisieren. Reaktionszeiten nach Schweregrad: Kritisch (24–72h), Hoch (7 Tage), Mittel (30 Tage), Niedrig (naechster Zyklus). | A.8.8 |
|
||||
| 35 | **Coordinated Vulnerability Disclosure** | Annex I, 2(5) | Veroeffentlichung einer CVD-Policy mit klarem Meldeprozess. Kontaktadresse fuer Sicherheitsforscher bereitstellen. Eingangsbestaetigung innerhalb von 5 Werktagen. Koordinierte Veroeffentlichung nach Patch-Verfuegbarkeit. | A.5.5, A.5.6 |
|
||||
|
||||
### Kategorie 8 — Incident Response und Meldepflichten
|
||||
|
||||
Controls fuer die Erkennung, Behandlung und Meldung von Sicherheitsvorfaellen.
|
||||
|
||||
| # | Control | CRA-Referenz | Beschreibung | ISO 27001 Mapping |
|
||||
|---|---------|-------------|-------------|-------------------|
|
||||
| 36 | **Incident-Response-Prozess** | Annex I, 2(5) | Dokumentierter Prozess mit definierten Phasen: Detection → Classification → Containment → Investigation → Recovery → Reporting → Lessons Learned. Regelmaessige Uebungen (Tabletop Exercises). | A.5.24, A.5.25, A.5.26 |
|
||||
| 37 | **Fruehwarnung (24h)** | Annex I, 2(7) + Art. 14(2)(a) | Bei aktiv ausgenutzten Schwachstellen oder schweren Vorfaellen: Fruehwarnung an ENISA und/oder zustaendige nationale Behoerde innerhalb von **24 Stunden** nach Kenntniserlangung. | A.5.24, A.5.26 |
|
||||
| 38 | **Detaillierter Vorfallsbericht (72h)** | Annex I, 2(7) + Art. 14(2)(b) | Innerhalb von **72 Stunden**: Detaillierter Bericht mit Umfang, Auswirkung, Ursachenanalyse und eingeleiteten Gegenmassnahmen. Bei personenbezogenen Daten zusaetzlich Art. 33/34 DSGVO beachten. | A.5.24, A.5.26 |
|
||||
| 39 | **Patch-Bereitstellung** | Annex I, 2(3) | Patches fuer gemeldete und bestaetigte Schwachstellen so schnell wie moeglich bereitstellen. Sicherheitshinweise (Security Advisories) an Kunden veroeffentlichen. CSAF-Format fuer maschinenlesbare Advisories empfohlen. | A.8.8 |
|
||||
| 40 | **Dokumentation und Nachbereitung** | Annex I, 2(6) | Alle Schwachstellen und Vorfaelle lueckenlos dokumentieren und fuer mindestens 10 Jahre aufbewahren. Lessons-Learned-Prozess nach jedem bedeutenden Vorfall. Ergebnisse in Risikobewertung einfliessen lassen. | A.5.27 |
|
||||
|
||||
---
|
||||
|
||||
## Produktklassifizierung nach CRA
|
||||
|
||||
Der CRA unterscheidet drei Produktkategorien mit unterschiedlichen Konformitaetsanforderungen:
|
||||
|
||||
### Standardprodukte (Default)
|
||||
|
||||
**Beispiele:** einfache Apps, Desktop-Software, Spiele, Foto-Editoren
|
||||
|
||||
- **Konformitaetsbewertung:** Selbstbewertung (Modul A)
|
||||
- **Anforderungen:** Alle Annex-I-Anforderungen, aber einfachster Nachweis
|
||||
- **Betrifft:** ca. 90% aller Produkte
|
||||
|
||||
### Wichtige Produkte (Annex III) — Klasse I
|
||||
|
||||
**Beispiele:** Passwort-Manager, VPN-Software, Firewalls, Router, Smart-Home-Systeme, IoT-Geraete mit Sensorfunktion, SIEM-Systeme
|
||||
|
||||
- **Konformitaetsbewertung:** Harmonisierte Standards oder Drittanbieter-Bewertung
|
||||
- **Anforderungen:** Alle Annex-I-Anforderungen + erhoehte Nachweispflichten
|
||||
- **Betrifft:** ca. 8% aller Produkte
|
||||
|
||||
### Wichtige Produkte — Klasse II
|
||||
|
||||
**Beispiele:** Betriebssysteme, Hypervisoren, Container-Runtimes, Public-Key-Infrastruktur, industrielle Steuerungssysteme (ICS/SCADA)
|
||||
|
||||
- **Konformitaetsbewertung:** Verpflichtende Drittanbieter-Bewertung durch benannte Stelle
|
||||
- **Anforderungen:** Alle Annex-I-Anforderungen + strengste Nachweispflichten
|
||||
- **Betrifft:** ca. 2% aller Produkte
|
||||
|
||||
### Kritische Produkte (Annex IV)
|
||||
|
||||
**Beispiele:** Hardware-Security-Module (HSM), Smartcard-Chips, Secure Elements, Smart-Meter-Gateways
|
||||
|
||||
- **Konformitaetsbewertung:** Europaeisches Cybersicherheitszertifikat erforderlich (EUCC)
|
||||
- **Anforderungen:** Hoechste Stufe — europaeische Zertifizierung obligatorisch
|
||||
|
||||
---
|
||||
|
||||
## Zuordnung der Controls zu Dokumenten
|
||||
|
||||
Diese 40 Controls koennen automatisiert zu folgenden Compliance-Dokumenten fuehren:
|
||||
|
||||
| Dokument | Controls | Beschreibung |
|
||||
|----------|----------|-------------|
|
||||
| **Cybersecurity Policy** | 1–40 | Uebergreifendes Grundsatzdokument fuer Cybersicherheit |
|
||||
| **Secure Development Policy** | 18–23 | Richtlinie fuer den sicheren Entwicklungsprozess (SSDLC) |
|
||||
| **Vulnerability Management Policy** | 32–35, 39 | CVD, Patching, SBOM-Analyse |
|
||||
| **Incident Response Plan** | 36–38, 40 | 24h/72h Meldung, Eskalation, Nachbereitung |
|
||||
| **Access Control Policy** | 7–12 | Authentifizierung, Autorisierung, Passwort-Richtlinie |
|
||||
| **Cryptographic Policy** | 13–17 | Verschluesselung, Schluesselmanagement, Datenschutz |
|
||||
| **Update/Patch Policy** | 28–31 | Update-Mechanismen, Signierung, Lifecycle-Support |
|
||||
| **Logging & Monitoring Policy** | 24–27 | Security-Logging, SIEM, Anomalie-Erkennung |
|
||||
|
||||
---
|
||||
|
||||
## Zeitplan fuer die Umsetzung
|
||||
|
||||
| Datum | Meilenstein |
|
||||
|-------|------------|
|
||||
| 10.12.2024 | CRA in Kraft getreten |
|
||||
| 11.06.2026 | Konformitaetsbewertungsstellen muessen benannt sein |
|
||||
| 11.09.2026 | **Meldepflichten aktiv** (Controls 37, 38) |
|
||||
| 11.12.2027 | **Volle Anwendung** — alle 40 Controls muessen umgesetzt sein, CE-Kennzeichnung erforderlich |
|
||||
|
||||
---
|
||||
|
||||
## Sanktionen bei Nicht-Einhaltung
|
||||
|
||||
| Verstoss | Maximales Bussgeld |
|
||||
|----------|-------------------|
|
||||
| Wesentliche Anforderungen (Annex I) | 15 Mio. EUR oder 2,5% des weltweiten Jahresumsatzes |
|
||||
| Sonstige Pflichten | 10 Mio. EUR oder 2% des weltweiten Jahresumsatzes |
|
||||
| Falsche/unvollstaendige Informationen | 5 Mio. EUR oder 1% des weltweiten Jahresumsatzes |',
|
||||
legal_refs = ARRAY['Annex I CRA', 'Annex III CRA', 'Annex IV CRA', 'Art. 13 CRA', 'Art. 14 CRA', 'Art. 15 CRA', 'Art. 64 CRA', '(EU) 2024/2847'],
|
||||
tags = ARRAY['security-controls', 'annex-i', 'secure-by-design', 'authentifizierung', 'kryptografie', 'sbom', 'vulnerability', 'patching', 'incident-response', 'produktklassifizierung', 'iso-27001', 'ssdlc'],
|
||||
relevance = 'critical',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'cra-security-controls';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2) Neuer Artikel: CRA-Konformitaetsbewertung — Praktischer Leitfaden
|
||||
-- ============================================================================
|
||||
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
|
||||
('cra-konformitaet', 'cra',
|
||||
'CRA-Konformitaetsbewertung — Praktischer Leitfaden',
|
||||
'Schritt-fuer-Schritt-Anleitung zur CRA-Konformitaetsbewertung: Produktklassifizierung, Dokumentation, Self-Assessment vs. Drittanbieter-Pruefung, CE-Kennzeichnung.',
|
||||
'## Ueberblick
|
||||
|
||||
Jeder Hersteller muss vor dem Inverkehrbringen eine **Konformitaetsbewertung** durchfuehren, um nachzuweisen, dass sein Produkt die Essential Cybersecurity Requirements (Annex I) erfuellt. Der Aufwand haengt von der Produktkategorie ab.
|
||||
|
||||
## Schritt 1: Produkt klassifizieren
|
||||
|
||||
Bestimmen Sie, ob Ihr Produkt unter eine der Sonderkategorien faellt:
|
||||
|
||||
### Entscheidungsbaum
|
||||
|
||||
```
|
||||
Ist das Produkt in Annex IV gelistet?
|
||||
→ Ja: Kritisches Produkt → Europaeische Zertifizierung (EUCC)
|
||||
→ Nein: Weiter
|
||||
|
||||
Ist das Produkt in Annex III, Klasse II gelistet?
|
||||
→ Ja: Wichtig Klasse II → Drittanbieter-Bewertung (Pflicht)
|
||||
→ Nein: Weiter
|
||||
|
||||
Ist das Produkt in Annex III, Klasse I gelistet?
|
||||
→ Ja: Wichtig Klasse I → Harmonisierte Standards ODER Drittanbieter
|
||||
→ Nein: Standardprodukt → Selbstbewertung (Modul A)
|
||||
```
|
||||
|
||||
## Schritt 2: Cybersecurity-Risikobewertung
|
||||
|
||||
Fuehren Sie eine systematische Risikoanalyse durch:
|
||||
|
||||
1. **Assets identifizieren** — Welche Daten verarbeitet das Produkt? Welche Schnittstellen hat es?
|
||||
2. **Bedrohungen analysieren** — STRIDE-Methodik oder vergleichbar anwenden
|
||||
3. **Schwachstellen bewerten** — Bekannte CVEs, Design-Schwaechen, Konfigurationsfehler
|
||||
4. **Risiken priorisieren** — Eintrittswahrscheinlichkeit × Auswirkung
|
||||
5. **Massnahmen definieren** — Welche Controls aus Annex I adressieren welches Risiko?
|
||||
|
||||
## Schritt 3: Controls implementieren
|
||||
|
||||
Setzen Sie die relevanten Controls aus den 8 Kategorien um (siehe Artikel „CRA Annex I — Essential Cybersecurity Requirements"). Dokumentieren Sie fuer jeden Control:
|
||||
|
||||
- **Status**: Implementiert / In Bearbeitung / Nicht anwendbar
|
||||
- **Nachweis**: Wie wird die Umsetzung belegt? (Code, Konfiguration, Test, Policy)
|
||||
- **Verantwortlich**: Wer ist zustaendig?
|
||||
|
||||
## Schritt 4: Technische Dokumentation
|
||||
|
||||
Die technische Dokumentation muss enthalten:
|
||||
|
||||
- Beschreibung des Produkts und seiner Funktionen
|
||||
- Cybersecurity-Risikobewertung
|
||||
- Angewandte harmonisierte Normen
|
||||
- Nachweis der Einhaltung jeder Annex-I-Anforderung
|
||||
- SBOM (Software Bill of Materials)
|
||||
- Informationen zum Support-Zeitraum
|
||||
|
||||
## Schritt 5: Konformitaetserklaerung und CE-Kennzeichnung
|
||||
|
||||
Nach erfolgreicher Bewertung:
|
||||
|
||||
1. **EU-Konformitaetserklaerung** ausstellen
|
||||
2. **CE-Kennzeichnung** anbringen
|
||||
3. **Dokumentation** mindestens 10 Jahre aufbewahren
|
||||
4. Produkt darf in der EU vertrieben werden
|
||||
|
||||
## Haeufige Fehler
|
||||
|
||||
| Fehler | Konsequenz |
|
||||
|--------|-----------|
|
||||
| Default-Passwoerter nicht entfernt | Verstoss gegen Annex I, 1(3)(d) |
|
||||
| Kein SBOM erstellt | Verstoss gegen Annex I, 1(5) |
|
||||
| Kein Update-Mechanismus | Verstoss gegen Annex I, 1(4) |
|
||||
| Keine CVD-Policy | Verstoss gegen Annex I, 2(5) |
|
||||
| Support-Zeitraum nicht definiert | Verstoss gegen Art. 13(8) |
|
||||
|
||||
## Empfehlung
|
||||
|
||||
Nutzen Sie die **BreakPilot Compliance SDK Control Library**, um den Umsetzungsstand Ihrer CRA-Controls systematisch zu tracken und automatisiert Nachweise zu generieren.',
|
||||
ARRAY['Annex I CRA', 'Annex II CRA', 'Annex III CRA', 'Annex IV CRA', 'Annex V CRA', 'Art. 13 CRA', 'Art. 24 CRA', 'Art. 25 CRA', 'Art. 26 CRA', 'Art. 27 CRA'],
|
||||
ARRAY['konformitaet', 'ce-kennzeichnung', 'self-assessment', 'technische-dokumentation', 'sbom', 'risikobewertung'],
|
||||
'important',
|
||||
ARRAY['https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng'])
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
221
backend-compliance/tests/test_citation_backfill.py
Normal file
221
backend-compliance/tests/test_citation_backfill.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Tests for citation_backfill.py — article/paragraph enrichment."""
|
||||
import hashlib
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from compliance.services.citation_backfill import (
|
||||
CitationBackfill,
|
||||
MatchResult,
|
||||
_parse_concatenated_source,
|
||||
_parse_json,
|
||||
)
|
||||
from compliance.services.rag_client import RAGSearchResult
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unit tests: _parse_concatenated_source
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestParseConcatenatedSource:
|
||||
def test_dsgvo_art(self):
|
||||
result = _parse_concatenated_source("DSGVO Art. 35")
|
||||
assert result == {"name": "DSGVO", "article": "Art. 35"}
|
||||
|
||||
def test_nis2_artikel(self):
|
||||
result = _parse_concatenated_source("NIS2 Artikel 21 Abs. 2")
|
||||
assert result == {"name": "NIS2", "article": "Artikel 21 Abs. 2"}
|
||||
|
||||
def test_long_name_with_article(self):
|
||||
result = _parse_concatenated_source("Verordnung (EU) 2024/1689 (KI-Verordnung) Art. 6")
|
||||
assert result == {"name": "Verordnung (EU) 2024/1689 (KI-Verordnung)", "article": "Art. 6"}
|
||||
|
||||
def test_paragraph_sign(self):
|
||||
result = _parse_concatenated_source("BDSG § 42")
|
||||
assert result == {"name": "BDSG", "article": "§ 42"}
|
||||
|
||||
def test_paragraph_sign_with_abs(self):
|
||||
result = _parse_concatenated_source("TTDSG § 25 Abs. 1")
|
||||
assert result == {"name": "TTDSG", "article": "§ 25 Abs. 1"}
|
||||
|
||||
def test_no_article(self):
|
||||
result = _parse_concatenated_source("DSGVO")
|
||||
assert result is None
|
||||
|
||||
def test_empty_string(self):
|
||||
result = _parse_concatenated_source("")
|
||||
assert result is None
|
||||
|
||||
def test_none(self):
|
||||
result = _parse_concatenated_source(None)
|
||||
assert result is None
|
||||
|
||||
def test_just_name_no_article(self):
|
||||
result = _parse_concatenated_source("Cyber Resilience Act")
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unit tests: _parse_json
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestParseJson:
|
||||
def test_direct_json(self):
|
||||
result = _parse_json('{"article": "Art. 35", "paragraph": "Abs. 1"}')
|
||||
assert result == {"article": "Art. 35", "paragraph": "Abs. 1"}
|
||||
|
||||
def test_markdown_code_block(self):
|
||||
raw = '```json\n{"article": "§ 42", "paragraph": ""}\n```'
|
||||
result = _parse_json(raw)
|
||||
assert result == {"article": "§ 42", "paragraph": ""}
|
||||
|
||||
def test_text_with_json(self):
|
||||
raw = 'Der Artikel ist {"article": "Art. 6", "paragraph": "Abs. 2"} wie beschrieben.'
|
||||
result = _parse_json(raw)
|
||||
assert result == {"article": "Art. 6", "paragraph": "Abs. 2"}
|
||||
|
||||
def test_empty(self):
|
||||
assert _parse_json("") is None
|
||||
assert _parse_json(None) is None
|
||||
|
||||
def test_no_json(self):
|
||||
assert _parse_json("Das ist kein JSON.") is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration tests: CitationBackfill matching
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _make_rag_chunk(text="Test text", article="Art. 35", paragraph="Abs. 1",
|
||||
regulation_code="eu_2016_679", regulation_name="DSGVO"):
|
||||
return RAGSearchResult(
|
||||
text=text,
|
||||
regulation_code=regulation_code,
|
||||
regulation_name=regulation_name,
|
||||
regulation_short="DSGVO",
|
||||
category="datenschutz",
|
||||
article=article,
|
||||
paragraph=paragraph,
|
||||
source_url="https://example.com",
|
||||
score=0.0,
|
||||
collection="bp_compliance_gesetze",
|
||||
)
|
||||
|
||||
|
||||
class TestCitationBackfillMatching:
|
||||
def setup_method(self):
|
||||
self.db = MagicMock()
|
||||
self.rag = MagicMock()
|
||||
self.backfill = CitationBackfill(db=self.db, rag_client=self.rag)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_match(self):
|
||||
"""Tier 1: exact text hash matches a RAG chunk."""
|
||||
source_text = "Dies ist ein Gesetzestext mit spezifischen Anforderungen an die Datensicherheit."
|
||||
chunk = _make_rag_chunk(text=source_text, article="Art. 32", paragraph="Abs. 1")
|
||||
h = hashlib.sha256(source_text.encode()).hexdigest()
|
||||
self.backfill._rag_index = {h: chunk}
|
||||
|
||||
ctrl = {
|
||||
"control_id": "DATA-001",
|
||||
"source_original_text": source_text,
|
||||
"source_citation": {"source": "DSGVO Art. 32"},
|
||||
"generation_metadata": {"source_regulation": "eu_2016_679"},
|
||||
}
|
||||
|
||||
result = await self.backfill._match_control(ctrl)
|
||||
assert result is not None
|
||||
assert result.method == "hash"
|
||||
assert result.article == "Art. 32"
|
||||
assert result.paragraph == "Abs. 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regex_match(self):
|
||||
"""Tier 2: regex parses concatenated source when no hash match."""
|
||||
self.backfill._rag_index = {}
|
||||
|
||||
ctrl = {
|
||||
"control_id": "NET-010",
|
||||
"source_original_text": None, # No original text available
|
||||
"source_citation": {"source": "NIS2 Artikel 21"},
|
||||
"generation_metadata": {},
|
||||
}
|
||||
|
||||
result = await self.backfill._match_control(ctrl)
|
||||
assert result is not None
|
||||
assert result.method == "regex"
|
||||
assert result.article == "Artikel 21"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_match(self):
|
||||
"""Tier 3: Ollama LLM identifies article/paragraph."""
|
||||
self.backfill._rag_index = {}
|
||||
|
||||
ctrl = {
|
||||
"control_id": "AUTH-005",
|
||||
"source_original_text": "Verantwortliche muessen geeignete technische Massnahmen treffen...",
|
||||
"source_citation": {"source": "DSGVO"}, # No article in source
|
||||
"generation_metadata": {"source_regulation": "eu_2016_679"},
|
||||
}
|
||||
|
||||
with patch("compliance.services.citation_backfill._llm_ollama", new_callable=AsyncMock) as mock_llm:
|
||||
mock_llm.return_value = '{"article": "Art. 25", "paragraph": "Abs. 1"}'
|
||||
result = await self.backfill._match_control(ctrl)
|
||||
|
||||
assert result is not None
|
||||
assert result.method == "llm"
|
||||
assert result.article == "Art. 25"
|
||||
assert result.paragraph == "Abs. 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match(self):
|
||||
"""No match when no source text and no parseable source."""
|
||||
self.backfill._rag_index = {}
|
||||
|
||||
ctrl = {
|
||||
"control_id": "SEC-001",
|
||||
"source_original_text": None,
|
||||
"source_citation": {"source": "Unknown Source"},
|
||||
"generation_metadata": {},
|
||||
}
|
||||
|
||||
result = await self.backfill._match_control(ctrl)
|
||||
assert result is None
|
||||
|
||||
def test_update_control_cleans_source(self):
|
||||
"""_update_control splits concatenated source and adds article/paragraph."""
|
||||
ctrl = {
|
||||
"id": "test-uuid-123",
|
||||
"control_id": "DATA-001",
|
||||
"source_citation": {"source": "DSGVO Art. 32", "license": "EU_LAW"},
|
||||
"generation_metadata": {"processing_path": "structured"},
|
||||
}
|
||||
match = MatchResult(article="Art. 32", paragraph="Abs. 1", method="hash")
|
||||
|
||||
self.backfill._update_control(ctrl, match)
|
||||
|
||||
call_args = self.db.execute.call_args
|
||||
params = call_args[1] if call_args[1] else call_args[0][1]
|
||||
citation = json.loads(params["citation"])
|
||||
metadata = json.loads(params["metadata"])
|
||||
|
||||
assert citation["source"] == "DSGVO" # Cleaned: article removed
|
||||
assert citation["article"] == "Art. 32"
|
||||
assert citation["paragraph"] == "Abs. 1"
|
||||
assert metadata["source_paragraph"] == "Abs. 1"
|
||||
assert metadata["backfill_method"] == "hash"
|
||||
assert "backfill_at" in metadata
|
||||
|
||||
def test_rule3_not_loaded(self):
|
||||
"""Verify the SQL query only loads Rule 1+2 controls."""
|
||||
# Simulate what _load_controls_needing_backfill does
|
||||
self.db.execute.return_value = MagicMock(keys=lambda: [], __iter__=lambda s: iter([]))
|
||||
self.backfill._load_controls_needing_backfill()
|
||||
|
||||
sql_text = str(self.db.execute.call_args[0][0].text)
|
||||
assert "license_rule IN (1, 2)" in sql_text
|
||||
assert "source_citation IS NOT NULL" in sql_text
|
||||
@@ -70,6 +70,17 @@ class GenerateVideoResponse(BaseModel):
|
||||
size_bytes: int
|
||||
|
||||
|
||||
class PresignedURLRequest(BaseModel):
|
||||
bucket: str
|
||||
object_key: str
|
||||
expires: int = 3600
|
||||
|
||||
|
||||
class PresignedURLResponse(BaseModel):
|
||||
url: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class VoiceInfo(BaseModel):
|
||||
id: str
|
||||
language: str
|
||||
@@ -105,6 +116,17 @@ async def list_voices():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/presigned-url", response_model=PresignedURLResponse)
|
||||
async def get_presigned_url(req: PresignedURLRequest):
|
||||
"""Generate a presigned URL for accessing a stored media file."""
|
||||
try:
|
||||
url = storage.get_presigned_url(req.bucket, req.object_key, req.expires)
|
||||
return PresignedURLResponse(url=url, expires_in=req.expires)
|
||||
except Exception as e:
|
||||
logger.error(f"Presigned URL generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/synthesize", response_model=SynthesizeResponse)
|
||||
async def synthesize(req: SynthesizeRequest):
|
||||
"""Synthesize text to audio and upload to storage."""
|
||||
@@ -132,6 +154,112 @@ async def synthesize(req: SynthesizeRequest):
|
||||
)
|
||||
|
||||
|
||||
class SynthesizeSectionsRequest(BaseModel):
|
||||
sections: list[dict] # [{text, heading}]
|
||||
voice: str = "de_DE-thorsten-high"
|
||||
module_id: str = ""
|
||||
|
||||
|
||||
class SynthesizeSectionsResponse(BaseModel):
|
||||
sections: list[dict]
|
||||
total_duration: float
|
||||
|
||||
|
||||
class GenerateInteractiveVideoRequest(BaseModel):
|
||||
script: dict
|
||||
audio: dict # SynthesizeSectionsResponse
|
||||
module_id: str
|
||||
|
||||
|
||||
class GenerateInteractiveVideoResponse(BaseModel):
|
||||
video_id: str
|
||||
bucket: str
|
||||
object_key: str
|
||||
duration_seconds: float
|
||||
size_bytes: int
|
||||
|
||||
|
||||
@app.post("/synthesize-sections", response_model=SynthesizeSectionsResponse)
|
||||
async def synthesize_sections(req: SynthesizeSectionsRequest):
|
||||
"""Synthesize audio for multiple sections, returning per-section timing."""
|
||||
if not req.sections:
|
||||
raise HTTPException(status_code=400, detail="No sections provided")
|
||||
|
||||
results = []
|
||||
cumulative = 0.0
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for i, section in enumerate(req.sections):
|
||||
text = section.get("text", "")
|
||||
heading = section.get("heading", f"Section {i+1}")
|
||||
|
||||
if not text.strip():
|
||||
results.append({
|
||||
"heading": heading,
|
||||
"audio_path": "",
|
||||
"audio_object_key": "",
|
||||
"duration": 0.0,
|
||||
"start_timestamp": cumulative,
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
mp3_path, duration = tts.synthesize_to_mp3(text, tmpdir, suffix=f"_section_{i}")
|
||||
object_key = f"audio/{req.module_id}/section_{i}.mp3"
|
||||
storage.upload_file(AUDIO_BUCKET, object_key, mp3_path, "audio/mpeg")
|
||||
|
||||
results.append({
|
||||
"heading": heading,
|
||||
"audio_path": mp3_path,
|
||||
"audio_object_key": object_key,
|
||||
"duration": round(duration, 2),
|
||||
"start_timestamp": round(cumulative, 2),
|
||||
})
|
||||
cumulative += duration
|
||||
except Exception as e:
|
||||
logger.error(f"Section {i} synthesis failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Section {i} synthesis failed: {e}")
|
||||
|
||||
return SynthesizeSectionsResponse(
|
||||
sections=results,
|
||||
total_duration=round(cumulative, 2),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/generate-interactive-video", response_model=GenerateInteractiveVideoResponse)
|
||||
async def generate_interactive_video(req: GenerateInteractiveVideoRequest):
|
||||
"""Generate an interactive presentation video with checkpoint slides."""
|
||||
try:
|
||||
from video_generator import generate_interactive_presentation_video
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="Interactive video generation not available")
|
||||
|
||||
video_id = str(uuid.uuid4())
|
||||
object_key = f"video/{req.module_id}/interactive.mp4"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
try:
|
||||
mp4_path, duration = generate_interactive_presentation_video(
|
||||
script=req.script,
|
||||
audio_sections=req.audio.get("sections", []),
|
||||
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"Interactive video generation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
return GenerateInteractiveVideoResponse(
|
||||
video_id=video_id,
|
||||
bucket=VIDEO_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."""
|
||||
|
||||
@@ -130,3 +130,97 @@ def render_title_slide(
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ImageMagick title slide failed: {result.stderr}")
|
||||
|
||||
|
||||
def render_checkpoint_slide(
|
||||
title: str,
|
||||
question_preview: str,
|
||||
question_count: int,
|
||||
output_path: str,
|
||||
) -> None:
|
||||
"""Render a checkpoint slide with red border and quiz preview."""
|
||||
border_width = 12
|
||||
cmd = [
|
||||
"convert",
|
||||
"-size", f"{WIDTH}x{HEIGHT}",
|
||||
"xc:white",
|
||||
# Red border (full rectangle, then white inner)
|
||||
"-fill", "#c0392b",
|
||||
"-draw", f"rectangle 0,0 {WIDTH},{HEIGHT}",
|
||||
"-fill", "white",
|
||||
"-draw", f"rectangle {border_width},{border_width} {WIDTH - border_width},{HEIGHT - border_width}",
|
||||
# Red header bar
|
||||
"-fill", "#c0392b",
|
||||
"-draw", f"rectangle {border_width},{border_width} {WIDTH - border_width},{HEADER_HEIGHT + border_width}",
|
||||
# CHECKPOINT label
|
||||
"-fill", "white",
|
||||
"-font", FONT_BOLD,
|
||||
"-pointsize", "48",
|
||||
"-gravity", "NorthWest",
|
||||
"-annotate", f"+{60 + border_width}+{(HEADER_HEIGHT - 48) // 2 + border_width}",
|
||||
f"CHECKPOINT: {title[:50]}",
|
||||
]
|
||||
|
||||
y_pos = HEADER_HEIGHT + border_width + 60
|
||||
|
||||
# Instruction text
|
||||
cmd.extend([
|
||||
"-fill", "#333333",
|
||||
"-font", FONT,
|
||||
"-pointsize", "32",
|
||||
"-gravity", "NorthWest",
|
||||
"-annotate", f"+80+{y_pos}",
|
||||
"Bitte beantworten Sie die folgenden Fragen,",
|
||||
])
|
||||
y_pos += 44
|
||||
|
||||
cmd.extend([
|
||||
"-fill", "#333333",
|
||||
"-font", FONT,
|
||||
"-pointsize", "32",
|
||||
"-gravity", "NorthWest",
|
||||
"-annotate", f"+80+{y_pos}",
|
||||
"um mit der Schulung fortzufahren.",
|
||||
])
|
||||
y_pos += 80
|
||||
|
||||
# Question preview
|
||||
if question_preview:
|
||||
preview = textwrap.fill(question_preview, width=70)
|
||||
cmd.extend([
|
||||
"-fill", "#666666",
|
||||
"-font", FONT,
|
||||
"-pointsize", "26",
|
||||
"-gravity", "NorthWest",
|
||||
"-annotate", f"+80+{y_pos}",
|
||||
f"Erste Frage: {preview[:120]}...",
|
||||
])
|
||||
y_pos += 50
|
||||
|
||||
# Question count
|
||||
cmd.extend([
|
||||
"-fill", "#888888",
|
||||
"-font", FONT,
|
||||
"-pointsize", "24",
|
||||
"-gravity", "NorthWest",
|
||||
"-annotate", f"+80+{y_pos}",
|
||||
f"{question_count} Fragen in diesem Checkpoint",
|
||||
])
|
||||
|
||||
# Footer
|
||||
cmd.extend([
|
||||
"-fill", "#f0f0f0",
|
||||
"-draw", f"rectangle {border_width},{HEIGHT - FOOTER_HEIGHT - border_width} {WIDTH - border_width},{HEIGHT - border_width}",
|
||||
"-fill", "#c0392b",
|
||||
"-font", FONT_BOLD,
|
||||
"-pointsize", "22",
|
||||
"-gravity", "South",
|
||||
"-annotate", f"+0+{(FOOTER_HEIGHT - 22) // 2 + border_width}",
|
||||
"Video wird pausiert — Quiz im Player beantworten",
|
||||
])
|
||||
|
||||
cmd.append(output_path)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ImageMagick checkpoint slide failed: {result.stderr}")
|
||||
|
||||
@@ -74,7 +74,7 @@ class PiperTTS:
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Piper failed: {proc.stderr}")
|
||||
|
||||
def synthesize_to_mp3(self, text: str, output_dir: str) -> tuple[str, float]:
|
||||
def synthesize_to_mp3(self, text: str, output_dir: str, suffix: str = "") -> tuple[str, float]:
|
||||
"""
|
||||
Synthesize text to MP3.
|
||||
Splits text into sentences, synthesizes each, concatenates, encodes to MP3.
|
||||
@@ -88,16 +88,16 @@ class PiperTTS:
|
||||
wav_files = []
|
||||
try:
|
||||
for i, sentence in enumerate(sentences):
|
||||
wav_path = os.path.join(output_dir, f"seg_{i:04d}.wav")
|
||||
wav_path = os.path.join(output_dir, f"seg{suffix}_{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")
|
||||
combined_wav = os.path.join(output_dir, f"combined{suffix}.wav")
|
||||
self._concatenate_wavs(wav_files, combined_wav)
|
||||
|
||||
# Convert to MP3
|
||||
mp3_path = os.path.join(output_dir, "output.mp3")
|
||||
mp3_path = os.path.join(output_dir, f"output{suffix}.mp3")
|
||||
self._wav_to_mp3(combined_wav, mp3_path)
|
||||
|
||||
# Get duration
|
||||
|
||||
@@ -115,6 +115,139 @@ def generate_presentation_video(
|
||||
return output_path, video_duration
|
||||
|
||||
|
||||
def generate_interactive_presentation_video(
|
||||
script: dict,
|
||||
audio_sections: list[dict],
|
||||
output_dir: str,
|
||||
storage,
|
||||
audio_bucket: str,
|
||||
) -> tuple[str, float]:
|
||||
"""
|
||||
Generate an interactive presentation video from narrator script + per-section audio.
|
||||
|
||||
Includes checkpoint slides (red-bordered pause markers) between sections.
|
||||
Returns (mp4_path, duration_seconds).
|
||||
"""
|
||||
from slide_renderer import render_slide, render_title_slide, render_checkpoint_slide
|
||||
|
||||
title = script.get("title", "Compliance Training")
|
||||
sections = script.get("sections", [])
|
||||
|
||||
if not sections:
|
||||
raise ValueError("Script has no sections")
|
||||
if not audio_sections:
|
||||
raise ValueError("No audio sections provided")
|
||||
|
||||
# Step 1: Download all section audio files
|
||||
audio_paths = []
|
||||
for i, sec in enumerate(audio_sections):
|
||||
obj_key = sec.get("audio_object_key", "")
|
||||
if not obj_key:
|
||||
continue
|
||||
audio_path = os.path.join(output_dir, f"section_{i}.mp3")
|
||||
storage.client.download_file(audio_bucket, obj_key, audio_path)
|
||||
audio_paths.append((i, audio_path, sec.get("duration", 0.0)))
|
||||
|
||||
# Step 2: Render slides
|
||||
slides_dir = os.path.join(output_dir, "slides")
|
||||
os.makedirs(slides_dir, exist_ok=True)
|
||||
|
||||
# All slide entries: (png_path, duration)
|
||||
slide_entries = []
|
||||
|
||||
# Title slide (5 seconds)
|
||||
title_path = os.path.join(slides_dir, "slide_000_title.png")
|
||||
render_title_slide(title, "Interaktive Compliance-Schulung", title_path)
|
||||
slide_entries.append((title_path, 5.0))
|
||||
|
||||
total_content_slides = sum(1 for _ in sections) # for numbering
|
||||
slide_num = 1
|
||||
|
||||
for i, section in enumerate(sections):
|
||||
heading = section.get("heading", "")
|
||||
narrator_text = section.get("narrator_text", "")
|
||||
bullet_points = section.get("bullet_points", [])
|
||||
|
||||
# Content slide for this section
|
||||
slide_path = os.path.join(slides_dir, f"slide_{i+1:03d}_content.png")
|
||||
render_slide(
|
||||
heading=heading,
|
||||
text=narrator_text[:200] if len(narrator_text) > 200 else narrator_text,
|
||||
bullet_points=bullet_points,
|
||||
slide_number=slide_num + 1,
|
||||
total_slides=total_content_slides + 1,
|
||||
module_code=script.get("module_code", ""),
|
||||
output_path=slide_path,
|
||||
)
|
||||
slide_num += 1
|
||||
|
||||
# Duration = matching audio section duration
|
||||
section_duration = 5.0 # fallback
|
||||
if i < len(audio_paths):
|
||||
section_duration = audio_paths[i][2] or 5.0
|
||||
slide_entries.append((slide_path, section_duration))
|
||||
|
||||
# Checkpoint slide (if this section has a checkpoint)
|
||||
checkpoint = section.get("checkpoint")
|
||||
if checkpoint:
|
||||
cp_title = checkpoint.get("title", f"Checkpoint {i+1}")
|
||||
questions = checkpoint.get("questions", [])
|
||||
question_preview = questions[0].get("question", "") if questions else ""
|
||||
cp_path = os.path.join(slides_dir, f"slide_{i+1:03d}_checkpoint.png")
|
||||
render_checkpoint_slide(cp_title, question_preview, len(questions), cp_path)
|
||||
slide_entries.append((cp_path, 3.0)) # 3s still frame as pause marker
|
||||
|
||||
# Step 3: Concatenate all section audio files into one
|
||||
combined_audio = os.path.join(output_dir, "combined_audio.mp3")
|
||||
if len(audio_paths) == 1:
|
||||
import shutil
|
||||
shutil.copy2(audio_paths[0][1], combined_audio)
|
||||
elif len(audio_paths) > 1:
|
||||
# Use FFmpeg to concatenate audio
|
||||
audio_list_path = os.path.join(output_dir, "audio_list.txt")
|
||||
with open(audio_list_path, "w") as f:
|
||||
for _, apath, _ in audio_paths:
|
||||
f.write(f"file '{apath}'\n")
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", audio_list_path, "-c", "copy", combined_audio,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg audio concat failed: {result.stderr}")
|
||||
else:
|
||||
raise ValueError("No audio files to concatenate")
|
||||
|
||||
# Step 4: Create FFmpeg concat file for slides
|
||||
concat_path = os.path.join(output_dir, "concat.txt")
|
||||
with open(concat_path, "w") as f:
|
||||
for slide_path, dur in slide_entries:
|
||||
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_entries[-1][0]}'\n")
|
||||
|
||||
# Step 5: Combine slides + audio into MP4
|
||||
output_path = os.path.join(output_dir, "interactive.mp4")
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-f", "concat", "-safe", "0", "-i", concat_path,
|
||||
"-i", combined_audio,
|
||||
"-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 interactive video 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 = [
|
||||
|
||||
439
developer-portal/app/api/training/page.tsx
Normal file
439
developer-portal/app/api/training/page.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
'use client'
|
||||
|
||||
import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout'
|
||||
|
||||
export default function TrainingAPIPage() {
|
||||
return (
|
||||
<DevPortalLayout
|
||||
title="Training API"
|
||||
description="Compliance-Schulungssystem (CP-TRAIN) — Module, Zuweisungen, Quiz, Zertifikate, KI-Generierung"
|
||||
>
|
||||
{/* ================================================================= */}
|
||||
{/* OVERVIEW */}
|
||||
{/* ================================================================= */}
|
||||
<h2>Uebersicht</h2>
|
||||
<p>
|
||||
Das Training-Modul bietet ein vollstaendiges Compliance-Schulungssystem mit:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Modulverwaltung mit Regulierungsbereichen (DSGVO, NIS2, ISO 27001, AI Act, GeschGehG, HinSchG)</li>
|
||||
<li>Compliance Training Matrix (CTM) — rollenbasierte Zuweisung</li>
|
||||
<li>KI-gestuetzte Content- und Quiz-Generierung</li>
|
||||
<li>Audio/Video-Schulungsinhalte via TTS</li>
|
||||
<li>Quiz-Engine mit Bestehens-Schwelle</li>
|
||||
<li>Eskalationsstufen (7/14/30/45 Tage)</li>
|
||||
<li>PDF-Zertifikate nach Schulungsabschluss</li>
|
||||
<li>Training Blocks — automatische Modul-Erstellung aus Canonical Controls</li>
|
||||
</ul>
|
||||
|
||||
<InfoBox type="info" title="Basis-URL">
|
||||
Alle Endpoints nutzen den Prefix <code>/sdk/v1/training</code>.
|
||||
Authentifizierung via <code>X-Tenant-ID</code> und <code>X-User-ID</code> Header.
|
||||
</InfoBox>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* MODULES */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="modules">1. Module</h2>
|
||||
<p>Schulungsmodule sind die zentrale Einheit des Training-Systems.</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/modules" description="Alle Module auflisten (mit optionalen Filtern)" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'regulation_area', type: 'string', description: 'Filter: dsgvo, nis2, iso27001, ai_act, geschgehg, hinschg' },
|
||||
{ name: 'frequency_type', type: 'string', description: 'Filter: onboarding, annual, event_trigger, micro' },
|
||||
{ name: 'search', type: 'string', description: 'Volltextsuche in Titel und Beschreibung' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/modules/:id" description="Einzelnes Modul mit Content und Quiz-Fragen laden" />
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/modules" description="Neues Schulungsmodul erstellen" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'module_code', type: 'string', required: true, description: 'Eindeutiger Modulcode (z.B. CP-TRAIN-001)' },
|
||||
{ name: 'title', type: 'string', required: true, description: 'Titel des Moduls' },
|
||||
{ name: 'description', type: 'string', description: 'Beschreibung' },
|
||||
{ name: 'regulation_area', type: 'string', required: true, description: 'Regulierungsbereich' },
|
||||
{ name: 'frequency_type', type: 'string', required: true, description: 'Schulungsfrequenz' },
|
||||
{ name: 'duration_minutes', type: 'integer', description: 'Dauer in Minuten (Standard: 30)' },
|
||||
{ name: 'pass_threshold', type: 'integer', description: 'Quiz-Bestehensgrenze in Prozent (Standard: 70)' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="PUT" path="/sdk/v1/training/modules/:id" description="Modul aktualisieren" />
|
||||
<ApiEndpoint method="DELETE" path="/sdk/v1/training/modules/:id" description="Modul loeschen" />
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/modules — Beispiel
|
||||
{
|
||||
"module_code": "CP-DSGVO-001",
|
||||
"title": "DSGVO Grundlagen fuer Mitarbeiter",
|
||||
"description": "Einfuehrung in die Datenschutz-Grundverordnung",
|
||||
"regulation_area": "dsgvo",
|
||||
"frequency_type": "annual",
|
||||
"duration_minutes": 30,
|
||||
"pass_threshold": 70
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* MATRIX */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="matrix">2. Compliance Training Matrix (CTM)</h2>
|
||||
<p>Die CTM ordnet Rollen zu Schulungsmodulen zu. 10 vordefinierte Rollen (R1–R10).</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/matrix" description="Vollstaendige Training-Matrix abrufen (alle Rollen → Module)" />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/matrix/:role" description="Module fuer eine bestimmte Rolle abrufen" />
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/matrix" description="Matrix-Eintrag setzen (Rolle → Modul)" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'role_code', type: 'string', required: true, description: 'Rollencode (R1–R10)' },
|
||||
{ name: 'module_id', type: 'uuid', required: true, description: 'Modul-UUID' },
|
||||
{ name: 'is_mandatory', type: 'boolean', description: 'Pflichtschulung (Standard: false)' },
|
||||
{ name: 'priority', type: 'integer', description: 'Prioritaet (1 = hoechste)' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="DELETE" path="/sdk/v1/training/matrix/:role/:moduleId" description="Matrix-Eintrag entfernen" />
|
||||
|
||||
<InfoBox type="info" title="Rollen">
|
||||
R1: Geschaeftsfuehrung, R2: IT-Leitung, R3: DSB, R4: ISB, R5: HR,
|
||||
R6: Einkauf, R7: Fachabteilung, R8: IT-Admin, R9: Alle Mitarbeiter, R10: Behoerden
|
||||
</InfoBox>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* ASSIGNMENTS */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="assignments">3. Zuweisungen</h2>
|
||||
<p>Zuweisungen verbinden Mitarbeiter mit Schulungsmodulen und tracken den Fortschritt.</p>
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/assignments/compute" description="Zuweisungen fuer einen Benutzer berechnen (basierend auf Rollen + CTM)" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'user_id', type: 'uuid', required: true, description: 'Benutzer-UUID' },
|
||||
{ name: 'user_name', type: 'string', required: true, description: 'Name des Benutzers' },
|
||||
{ name: 'user_email', type: 'string', required: true, description: 'E-Mail' },
|
||||
{ name: 'roles', type: 'string[]', required: true, description: 'Rollencodes des Benutzers' },
|
||||
{ name: 'trigger', type: 'string', description: 'Ausloeser: onboarding, annual, event, manual' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/assignments" description="Zuweisungen auflisten" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'user_id', type: 'uuid', description: 'Filter nach Benutzer' },
|
||||
{ name: 'module_id', type: 'uuid', description: 'Filter nach Modul' },
|
||||
{ name: 'role', type: 'string', description: 'Filter nach Rolle' },
|
||||
{ name: 'status', type: 'string', description: 'Filter: pending, in_progress, completed, overdue, expired' },
|
||||
{ name: 'limit', type: 'integer', description: 'Pagination (Standard: 50)' },
|
||||
{ name: 'offset', type: 'integer', description: 'Pagination Offset' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/assignments/:id" description="Einzelne Zuweisung laden" />
|
||||
<ApiEndpoint method="PUT" path="/sdk/v1/training/assignments/:id" description="Zuweisung aktualisieren (z.B. Deadline aendern)" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/assignments/:id/start" description="Schulung starten (Status → in_progress)" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/assignments/:id/progress" description="Fortschritt aktualisieren (0–100%)" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/assignments/:id/complete" description="Schulung abschliessen (Status → completed)" />
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/assignments/compute — Response
|
||||
{
|
||||
"assignments": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"module_id": "...",
|
||||
"user_id": "...",
|
||||
"status": "pending",
|
||||
"progress_percent": 0,
|
||||
"deadline": "2026-04-15T00:00:00Z",
|
||||
"escalation_level": 0
|
||||
}
|
||||
],
|
||||
"created": 5
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* QUIZ */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="quiz">4. Quiz-Engine</h2>
|
||||
<p>Multiple-Choice-Quiz mit automatischer Bewertung und Bestehensgrenze.</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/quiz/:moduleId" description="Quiz-Fragen fuer ein Modul abrufen" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/quiz/:moduleId/submit" description="Quiz-Antworten einreichen" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'assignment_id', type: 'uuid', required: true, description: 'Zuweisungs-UUID' },
|
||||
{ name: 'answers', type: 'QuizAnswer[]', required: true, description: 'Array von {question_id, selected_index}' },
|
||||
{ name: 'duration_seconds', type: 'integer', description: 'Bearbeitungsdauer in Sekunden' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/quiz/attempts/:assignmentId" description="Quiz-Versuche fuer eine Zuweisung anzeigen" />
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/quiz/:moduleId/submit — Response
|
||||
{
|
||||
"attempt_id": "...",
|
||||
"score": 80.0,
|
||||
"passed": true,
|
||||
"correct_count": 4,
|
||||
"total_count": 5,
|
||||
"threshold": 70
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* CONTENT GENERATION */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="content">5. KI-Content-Generierung</h2>
|
||||
<p>LLM-basierte Erstellung von Schulungsinhalten und Quiz-Fragen.</p>
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/generate" description="Schulungsinhalt fuer ein Modul generieren (Markdown)" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'module_id', type: 'uuid', required: true, description: 'Modul-UUID' },
|
||||
{ name: 'language', type: 'string', description: 'Sprache: de (Standard) oder en' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/generate-quiz" description="Quiz-Fragen fuer ein Modul generieren" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'module_id', type: 'uuid', required: true, description: 'Modul-UUID' },
|
||||
{ name: 'count', type: 'integer', description: 'Anzahl Fragen (Standard: 5)' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/content/:moduleId" description="Veroeffentlichten Content eines Moduls abrufen" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/:contentId/publish" description="Content veroeffentlichen (Freigabe)" />
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/generate-all" description="Content fuer alle Module ohne Content generieren (Bulk)" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/generate-all-quiz" description="Quiz fuer alle Module ohne Fragen generieren (Bulk)" />
|
||||
|
||||
<InfoBox type="warning" title="LLM-Kosten">
|
||||
Content- und Quiz-Generierung nutzt LLM-APIs (Ollama/Anthropic). Bulk-Generierung kann
|
||||
signifikante Token-Kosten verursachen. PII-Detektion ist aktiv — personenbezogene Daten
|
||||
werden automatisch redaktiert.
|
||||
</InfoBox>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* MEDIA */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="media">6. Media (Audio/Video)</h2>
|
||||
<p>TTS-basierte Audio- und Videogenerierung fuer Schulungsmodule.</p>
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/:moduleId/generate-audio" description="Audio-Datei aus Schulungsinhalt generieren (Piper TTS)" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/:moduleId/generate-video" description="Praesentationsvideo generieren (TTS + Folien)" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/content/:moduleId/preview-script" description="Video-Script als JSON-Vorschau generieren" />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/media/module/:moduleId" description="Alle Medien eines Moduls auflisten" />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/media/:mediaId/url" description="Metadaten (Bucket, Object Key) fuer eine Media-Datei" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/media/:mediaId/publish" description="Media veroeffentlichen/zurueckziehen" />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/media/:mediaId/stream" description="Media streamen (307-Redirect zu Presigned URL)" />
|
||||
|
||||
<InfoBox type="info" title="Streaming">
|
||||
Der <code>/stream</code>-Endpoint liefert einen <code>307 Temporary Redirect</code> zu einer
|
||||
zeitlich begrenzten Presigned URL (MinIO/S3). Browser und Audio/Video-Player folgen dem
|
||||
Redirect automatisch.
|
||||
</InfoBox>
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/content/:moduleId/preview-script — Response
|
||||
{
|
||||
"title": "DSGVO Grundlagen",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "Was ist die DSGVO?",
|
||||
"text": "Die DSGVO regelt den Umgang mit personenbezogenen Daten.",
|
||||
"bullet_points": [
|
||||
"Gilt seit 25. Mai 2018",
|
||||
"EU-weit verbindlich",
|
||||
"Hohe Bussgelder bei Verstoessen"
|
||||
]
|
||||
}
|
||||
]
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* DEADLINES & ESCALATION */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="deadlines">7. Deadlines & Eskalation</h2>
|
||||
<p>Automatisches Eskalationssystem mit 4 Stufen.</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/deadlines" description="Anstehende Deadlines auflisten" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'limit', type: 'integer', description: 'Maximale Anzahl (Standard: 50)' },
|
||||
]} />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/deadlines/overdue" description="Ueberfaellige Zuweisungen auflisten" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/escalation/check" description="Eskalationspruefung ausfuehren" />
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/escalation/check — Response
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"assignment_id": "...",
|
||||
"user_name": "Max Mustermann",
|
||||
"module_title": "DSGVO Grundlagen",
|
||||
"previous_level": 1,
|
||||
"new_level": 2,
|
||||
"days_overdue": 15,
|
||||
"escalation_label": "Benachrichtigung Teamleitung"
|
||||
}
|
||||
],
|
||||
"total_checked": 42,
|
||||
"escalated": 3
|
||||
}`}</CodeBlock>
|
||||
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'Stufe 1 (7 Tage)', type: '-', description: 'Erinnerung an Mitarbeiter' },
|
||||
{ name: 'Stufe 2 (14 Tage)', type: '-', description: 'Benachrichtigung Teamleitung' },
|
||||
{ name: 'Stufe 3 (30 Tage)', type: '-', description: 'Benachrichtigung Management' },
|
||||
{ name: 'Stufe 4 (45 Tage)', type: '-', description: 'Benachrichtigung Compliance Officer' },
|
||||
]} />
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* CERTIFICATES */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="certificates">8. Zertifikate</h2>
|
||||
<p>PDF-Zertifikate nach erfolgreichem Schulungsabschluss.</p>
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/certificates/generate/:assignmentId" description="Zertifikat fuer eine abgeschlossene Zuweisung generieren" />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/certificates" description="Alle Zertifikate des Tenants auflisten" />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/certificates/:id/pdf" description="Zertifikat als PDF herunterladen" />
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/certificates/:id/verify" description="Zertifikat verifizieren (z.B. fuer Audit)" />
|
||||
|
||||
<InfoBox type="warning" title="Voraussetzungen">
|
||||
Zertifikate koennen nur generiert werden, wenn die Zuweisung den Status <code>completed</code> hat
|
||||
UND das Quiz bestanden wurde (<code>quiz_passed = true</code>).
|
||||
</InfoBox>
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/certificates/generate/:assignmentId — Response
|
||||
{
|
||||
"certificate_id": "a1b2c3d4-...",
|
||||
"assignment": {
|
||||
"id": "...",
|
||||
"status": "completed",
|
||||
"quiz_passed": true,
|
||||
"certificate_id": "a1b2c3d4-...",
|
||||
"module_title": "DSGVO Grundlagen"
|
||||
}
|
||||
}
|
||||
|
||||
// GET /sdk/v1/training/certificates/:id/verify — Response
|
||||
{
|
||||
"valid": true,
|
||||
"assignment": { ... }
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* AUDIT & STATS */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="audit">9. Audit & Statistiken</h2>
|
||||
<p>Compliance-konformes Audit-Logging aller Schulungsaktivitaeten.</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/audit-log" description="Audit-Log abrufen" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'action', type: 'string', description: 'Filter: assigned, started, completed, quiz_submitted, escalated, certificate_issued, content_generated' },
|
||||
{ name: 'entity_type', type: 'string', description: 'Filter: assignment, module, quiz, certificate' },
|
||||
{ name: 'limit', type: 'integer', description: 'Pagination (Standard: 50)' },
|
||||
{ name: 'offset', type: 'integer', description: 'Pagination Offset' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/stats" description="Aggregierte Schulungsstatistiken" />
|
||||
|
||||
<CodeBlock language="json">{`// GET /sdk/v1/training/stats — Response
|
||||
{
|
||||
"total_modules": 28,
|
||||
"total_assignments": 156,
|
||||
"completion_rate": 72.5,
|
||||
"overdue_count": 8,
|
||||
"pending_count": 23,
|
||||
"in_progress_count": 14,
|
||||
"completed_count": 111,
|
||||
"avg_quiz_score": 81.3,
|
||||
"avg_completion_days": 4.2,
|
||||
"upcoming_deadlines": 12
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* TRAINING BLOCKS */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="blocks">10. Training Blocks (Controls → Module)</h2>
|
||||
<p>
|
||||
Training Blocks automatisieren die Erstellung von Schulungsmodulen aus Canonical Controls.
|
||||
Ein Block definiert Filter (Domain, Kategorie, Severity, Zielgruppe) und generiert
|
||||
automatisch Module, Content und CTM-Eintraege.
|
||||
</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/blocks" description="Alle Block-Konfigurationen auflisten" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/blocks" description="Neue Block-Konfiguration erstellen" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'name', type: 'string', required: true, description: 'Name des Blocks' },
|
||||
{ name: 'description', type: 'string', description: 'Beschreibung' },
|
||||
{ name: 'domain_filter', type: 'string', description: 'Domain-Filter (z.B. AUTH, CRYP, NET)' },
|
||||
{ name: 'category_filter', type: 'string', description: 'Kategorie-Filter (z.B. authentication, encryption)' },
|
||||
{ name: 'severity_filter', type: 'string', description: 'Severity-Filter (high, critical)' },
|
||||
{ name: 'target_audience_filter', type: 'string', description: 'Zielgruppe: enterprise, authority, provider, all' },
|
||||
{ name: 'regulation_area', type: 'string', required: true, description: 'Regulierungsbereich' },
|
||||
{ name: 'module_code_prefix', type: 'string', required: true, description: 'Prefix fuer generierte Modulcodes' },
|
||||
{ name: 'frequency_type', type: 'string', description: 'Schulungsfrequenz' },
|
||||
{ name: 'duration_minutes', type: 'integer', description: 'Dauer pro Modul' },
|
||||
{ name: 'pass_threshold', type: 'integer', description: 'Quiz-Bestehensgrenze' },
|
||||
{ name: 'max_controls_per_module', type: 'integer', description: 'Max. Controls pro Modul (Standard: 10)' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/blocks/:id" description="Block-Konfiguration laden" />
|
||||
<ApiEndpoint method="PUT" path="/sdk/v1/training/blocks/:id" description="Block-Konfiguration aktualisieren" />
|
||||
<ApiEndpoint method="DELETE" path="/sdk/v1/training/blocks/:id" description="Block-Konfiguration loeschen" />
|
||||
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/blocks/:id/preview" description="Vorschau: Welche Controls und Module wuerden generiert?" />
|
||||
<ApiEndpoint method="POST" path="/sdk/v1/training/blocks/:id/generate" description="Module aus Block generieren (Content + CTM)" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'language', type: 'string', description: 'Sprache: de (Standard) oder en' },
|
||||
{ name: 'auto_matrix', type: 'boolean', description: 'Automatisch CTM-Eintraege erstellen (Standard: true)' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/blocks/:id/controls" description="Verlinkte Controls eines Blocks anzeigen" />
|
||||
|
||||
<CodeBlock language="json">{`// POST /sdk/v1/training/blocks/:id/generate — Response
|
||||
{
|
||||
"modules_created": 3,
|
||||
"controls_linked": 24,
|
||||
"matrix_entries_created": 15,
|
||||
"content_generated": 3,
|
||||
"errors": []
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* CANONICAL CONTROLS */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="canonical">11. Canonical Controls</h2>
|
||||
<p>Referenz-Datenbank mit standardisierten Sicherheitskontrollen.</p>
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/canonical/controls" description="Canonical Controls auflisten (mit Filtern)" />
|
||||
<ParameterTable parameters={[
|
||||
{ name: 'domain', type: 'string', description: 'Domain-Filter (z.B. AUTH, CRYP)' },
|
||||
{ name: 'category', type: 'string', description: 'Kategorie-Filter' },
|
||||
{ name: 'severity', type: 'string', description: 'Severity-Filter' },
|
||||
{ name: 'target_audience', type: 'string', description: 'Zielgruppen-Filter' },
|
||||
]} />
|
||||
|
||||
<ApiEndpoint method="GET" path="/sdk/v1/training/canonical/meta" description="Aggregierte Metadaten (Domains, Kategorien, Audiences mit Counts)" />
|
||||
|
||||
<CodeBlock language="json">{`// GET /sdk/v1/training/canonical/meta — Response
|
||||
{
|
||||
"domains": [
|
||||
{ "domain": "AUTH", "count": 12 },
|
||||
{ "domain": "CRYP", "count": 8 }
|
||||
],
|
||||
"categories": [
|
||||
{ "category": "authentication", "count": 12 },
|
||||
{ "category": "encryption", "count": 8 }
|
||||
],
|
||||
"audiences": [
|
||||
{ "audience": "enterprise", "count": 45 },
|
||||
{ "audience": "all", "count": 30 }
|
||||
],
|
||||
"total": 102
|
||||
}`}</CodeBlock>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* WORKFLOW */}
|
||||
{/* ================================================================= */}
|
||||
<h2 id="workflow">Typischer Workflow</h2>
|
||||
<ol>
|
||||
<li><strong>Module erstellen</strong> — via POST /modules oder Training Blocks</li>
|
||||
<li><strong>Content generieren</strong> — POST /content/generate (LLM)</li>
|
||||
<li><strong>Content freigeben</strong> — POST /content/:id/publish</li>
|
||||
<li><strong>Quiz generieren</strong> — POST /content/generate-quiz</li>
|
||||
<li><strong>Audio/Video</strong> — POST /content/:id/generate-audio, generate-video</li>
|
||||
<li><strong>CTM konfigurieren</strong> — POST /matrix (Rolle → Modul)</li>
|
||||
<li><strong>Zuweisungen berechnen</strong> — POST /assignments/compute</li>
|
||||
<li><strong>Mitarbeiter absolviert</strong> — start → progress → Quiz → complete</li>
|
||||
<li><strong>Zertifikat</strong> — POST /certificates/generate/:assignmentId</li>
|
||||
<li><strong>Audit</strong> — GET /audit-log + GET /stats</li>
|
||||
</ol>
|
||||
</DevPortalLayout>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const navigation: NavItem[] = [
|
||||
{ title: 'RAG Search API', href: '/api/rag' },
|
||||
{ title: 'Generation API', href: '/api/generate' },
|
||||
{ title: 'Export API', href: '/api/export' },
|
||||
{ title: 'Training API', href: '/api/training' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
1991
docs-src/control_generator.py
Normal file
1991
docs-src/control_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
976
docs-src/control_generator_routes.py
Normal file
976
docs-src/control_generator_routes.py
Normal file
@@ -0,0 +1,976 @@
|
||||
"""
|
||||
FastAPI routes for the Control Generator Pipeline.
|
||||
|
||||
Endpoints:
|
||||
POST /v1/canonical/generate — Start generation run
|
||||
GET /v1/canonical/generate/status/{job_id} — Job status
|
||||
GET /v1/canonical/generate/jobs — All jobs
|
||||
GET /v1/canonical/generate/review-queue — Controls needing review
|
||||
POST /v1/canonical/generate/review/{control_id} — Complete review
|
||||
GET /v1/canonical/generate/processed-stats — Processing stats per collection
|
||||
GET /v1/canonical/blocked-sources — Blocked sources list
|
||||
POST /v1/canonical/blocked-sources/cleanup — Start cleanup workflow
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import SessionLocal
|
||||
from compliance.services.control_generator import (
|
||||
ControlGeneratorPipeline,
|
||||
GeneratorConfig,
|
||||
ALL_COLLECTIONS,
|
||||
VALID_CATEGORIES,
|
||||
VALID_DOMAINS,
|
||||
_detect_category,
|
||||
_detect_domain,
|
||||
_llm_local,
|
||||
_parse_llm_json,
|
||||
CATEGORY_LIST_STR,
|
||||
)
|
||||
from compliance.services.citation_backfill import CitationBackfill, BackfillResult
|
||||
from compliance.services.rag_client import get_rag_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/canonical", tags=["control-generator"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REQUEST / RESPONSE MODELS
|
||||
# =============================================================================
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
collections: Optional[List[str]] = None
|
||||
max_controls: int = 50
|
||||
max_chunks: int = 1000 # Default: process max 1000 chunks per job (respects document boundaries)
|
||||
batch_size: int = 5
|
||||
skip_web_search: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
job_id: str
|
||||
status: str
|
||||
message: str
|
||||
total_chunks_scanned: int = 0
|
||||
controls_generated: int = 0
|
||||
controls_verified: int = 0
|
||||
controls_needs_review: int = 0
|
||||
controls_too_close: int = 0
|
||||
controls_duplicates_found: int = 0
|
||||
controls_qa_fixed: int = 0
|
||||
errors: list = []
|
||||
controls: list = []
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
action: str # "approve", "reject", "needs_rework"
|
||||
release_state: Optional[str] = None # Override release_state
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ProcessedStats(BaseModel):
|
||||
collection: str
|
||||
total_chunks_estimated: int
|
||||
processed_chunks: int
|
||||
pending_chunks: int
|
||||
direct_adopted: int
|
||||
llm_reformed: int
|
||||
skipped: int
|
||||
|
||||
|
||||
class BlockedSourceResponse(BaseModel):
|
||||
id: str
|
||||
regulation_code: str
|
||||
document_title: str
|
||||
reason: str
|
||||
deletion_status: str
|
||||
qdrant_collection: Optional[str] = None
|
||||
marked_at: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
async def _run_pipeline_background(config: GeneratorConfig, job_id: str):
|
||||
"""Run the pipeline in the background. Uses its own DB session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
config.existing_job_id = job_id
|
||||
pipeline = ControlGeneratorPipeline(db=db, rag_client=get_rag_client())
|
||||
result = await pipeline.run(config)
|
||||
logger.info(
|
||||
"Background generation job %s completed: %d controls from %d chunks",
|
||||
job_id, result.controls_generated, result.total_chunks_scanned,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Background generation job %s failed: %s", job_id, e)
|
||||
# Update job as failed
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE canonical_generation_jobs
|
||||
SET status = 'failed', errors = :errors, completed_at = NOW()
|
||||
WHERE id = CAST(:job_id AS uuid)
|
||||
"""),
|
||||
{"job_id": job_id, "errors": json.dumps([str(e)])},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerateResponse)
|
||||
async def start_generation(req: GenerateRequest):
|
||||
"""Start a control generation run (runs in background).
|
||||
|
||||
Returns immediately with job_id. Use GET /generate/status/{job_id} to poll progress.
|
||||
"""
|
||||
config = GeneratorConfig(
|
||||
collections=req.collections,
|
||||
domain=req.domain,
|
||||
batch_size=req.batch_size,
|
||||
max_controls=req.max_controls,
|
||||
max_chunks=req.max_chunks,
|
||||
skip_web_search=req.skip_web_search,
|
||||
dry_run=req.dry_run,
|
||||
)
|
||||
|
||||
if req.dry_run:
|
||||
# Dry run: execute synchronously and return controls
|
||||
db = SessionLocal()
|
||||
try:
|
||||
pipeline = ControlGeneratorPipeline(db=db, rag_client=get_rag_client())
|
||||
result = await pipeline.run(config)
|
||||
return GenerateResponse(
|
||||
job_id=result.job_id,
|
||||
status=result.status,
|
||||
message=f"Dry run: {result.controls_generated} controls from {result.total_chunks_scanned} chunks",
|
||||
total_chunks_scanned=result.total_chunks_scanned,
|
||||
controls_generated=result.controls_generated,
|
||||
controls_verified=result.controls_verified,
|
||||
controls_needs_review=result.controls_needs_review,
|
||||
controls_too_close=result.controls_too_close,
|
||||
controls_duplicates_found=result.controls_duplicates_found,
|
||||
errors=result.errors,
|
||||
controls=result.controls,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Dry run failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Create job record first so we can return the ID
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO canonical_generation_jobs (status, config)
|
||||
VALUES ('running', :config)
|
||||
RETURNING id
|
||||
"""),
|
||||
{"config": json.dumps(config.model_dump())},
|
||||
)
|
||||
db.commit()
|
||||
row = result.fetchone()
|
||||
job_id = str(row[0]) if row else None
|
||||
except Exception as e:
|
||||
logger.error("Failed to create job: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create job: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not job_id:
|
||||
raise HTTPException(status_code=500, detail="Failed to create job record")
|
||||
|
||||
# Launch pipeline in background
|
||||
asyncio.create_task(_run_pipeline_background(config, job_id))
|
||||
|
||||
return GenerateResponse(
|
||||
job_id=job_id,
|
||||
status="running",
|
||||
message="Generation started in background. Poll /generate/status/{job_id} for progress.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/generate/status/{job_id}")
|
||||
async def get_job_status(job_id: str):
|
||||
"""Get status of a generation job."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("SELECT * FROM canonical_generation_jobs WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": job_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
cols = result.keys()
|
||||
job = dict(zip(cols, row))
|
||||
# Serialize datetime fields
|
||||
for key in ("started_at", "completed_at", "created_at"):
|
||||
if job.get(key):
|
||||
job[key] = str(job[key])
|
||||
job["id"] = str(job["id"])
|
||||
return job
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/generate/jobs")
|
||||
async def list_jobs(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all generation jobs."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT id, status, total_chunks_scanned, controls_generated,
|
||||
controls_verified, controls_needs_review, controls_too_close,
|
||||
controls_duplicates_found, created_at, completed_at
|
||||
FROM canonical_generation_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"limit": limit, "offset": offset},
|
||||
)
|
||||
jobs = []
|
||||
cols = result.keys()
|
||||
for row in result:
|
||||
job = dict(zip(cols, row))
|
||||
job["id"] = str(job["id"])
|
||||
for key in ("created_at", "completed_at"):
|
||||
if job.get(key):
|
||||
job[key] = str(job[key])
|
||||
jobs.append(job)
|
||||
return {"jobs": jobs, "total": len(jobs)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/generate/review-queue")
|
||||
async def get_review_queue(
|
||||
release_state: str = Query("needs_review", regex="^(needs_review|too_close|duplicate)$"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""Get controls that need manual review."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT c.id, c.control_id, c.title, c.objective, c.severity,
|
||||
c.release_state, c.license_rule, c.customer_visible,
|
||||
c.generation_metadata, c.open_anchors, c.tags,
|
||||
c.created_at
|
||||
FROM canonical_controls c
|
||||
WHERE c.release_state = :state
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"state": release_state, "limit": limit},
|
||||
)
|
||||
controls = []
|
||||
cols = result.keys()
|
||||
for row in result:
|
||||
ctrl = dict(zip(cols, row))
|
||||
ctrl["id"] = str(ctrl["id"])
|
||||
ctrl["created_at"] = str(ctrl["created_at"])
|
||||
# Parse JSON fields
|
||||
for jf in ("generation_metadata", "open_anchors", "tags"):
|
||||
if isinstance(ctrl.get(jf), str):
|
||||
try:
|
||||
ctrl[jf] = json.loads(ctrl[jf])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
controls.append(ctrl)
|
||||
return {"controls": controls, "total": len(controls)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate/review/{control_id}")
|
||||
async def review_control(control_id: str, req: ReviewRequest):
|
||||
"""Complete review of a generated control."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Validate control exists and is in reviewable state
|
||||
result = db.execute(
|
||||
text("SELECT id, release_state FROM canonical_controls WHERE control_id = :cid"),
|
||||
{"cid": control_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Control not found")
|
||||
|
||||
current_state = row[1]
|
||||
if current_state not in ("needs_review", "too_close", "duplicate"):
|
||||
raise HTTPException(status_code=400, detail=f"Control is in state '{current_state}', not reviewable")
|
||||
|
||||
# Determine new state
|
||||
if req.action == "approve":
|
||||
new_state = req.release_state or "draft"
|
||||
elif req.action == "reject":
|
||||
new_state = "deprecated"
|
||||
elif req.action == "needs_rework":
|
||||
new_state = "needs_review"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}")
|
||||
|
||||
if new_state not in ("draft", "review", "approved", "deprecated", "needs_review", "too_close", "duplicate"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid release_state: {new_state}")
|
||||
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE canonical_controls
|
||||
SET release_state = :state, updated_at = NOW()
|
||||
WHERE control_id = :cid
|
||||
"""),
|
||||
{"state": new_state, "cid": control_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {"control_id": control_id, "release_state": new_state, "action": req.action}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class BulkReviewRequest(BaseModel):
|
||||
release_state: str # Filter: which controls to bulk-review
|
||||
action: str # "approve" or "reject"
|
||||
new_state: Optional[str] = None # Override target state
|
||||
|
||||
|
||||
@router.post("/generate/bulk-review")
|
||||
async def bulk_review(req: BulkReviewRequest):
|
||||
"""Bulk review all controls matching a release_state filter.
|
||||
|
||||
Example: reject all needs_review → sets them to deprecated.
|
||||
"""
|
||||
if req.release_state not in ("needs_review", "too_close", "duplicate"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid filter state: {req.release_state}")
|
||||
|
||||
if req.action == "approve":
|
||||
target = req.new_state or "draft"
|
||||
elif req.action == "reject":
|
||||
target = "deprecated"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}")
|
||||
|
||||
if target not in ("draft", "review", "approved", "deprecated", "needs_review"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid target state: {target}")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
UPDATE canonical_controls
|
||||
SET release_state = :target, updated_at = NOW()
|
||||
WHERE release_state = :source
|
||||
RETURNING control_id
|
||||
"""),
|
||||
{"source": req.release_state, "target": target},
|
||||
)
|
||||
affected = [row[0] for row in result]
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"action": req.action,
|
||||
"source_state": req.release_state,
|
||||
"target_state": target,
|
||||
"affected_count": len(affected),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class QAReclassifyRequest(BaseModel):
|
||||
limit: int = 100 # How many controls to reclassify per run
|
||||
dry_run: bool = True # Preview only by default
|
||||
filter_category: Optional[str] = None # Only reclassify controls of this category
|
||||
filter_domain_prefix: Optional[str] = None # Only reclassify controls with this prefix
|
||||
|
||||
|
||||
@router.post("/generate/qa-reclassify")
|
||||
async def qa_reclassify(req: QAReclassifyRequest):
|
||||
"""Run QA reclassification on existing controls using local LLM.
|
||||
|
||||
Finds controls where keyword-detection disagrees with current category/domain,
|
||||
then uses Ollama to determine the correct classification.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Load controls to check
|
||||
where_clauses = ["release_state NOT IN ('deprecated')"]
|
||||
params = {"limit": req.limit}
|
||||
if req.filter_category:
|
||||
where_clauses.append("category = :cat")
|
||||
params["cat"] = req.filter_category
|
||||
if req.filter_domain_prefix:
|
||||
where_clauses.append("control_id LIKE :prefix")
|
||||
params["prefix"] = f"{req.filter_domain_prefix}-%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT id, control_id, title, objective, category,
|
||||
COALESCE(requirements::text, '[]') as requirements,
|
||||
COALESCE(source_original_text, '') as source_text
|
||||
FROM canonical_controls
|
||||
WHERE {where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
results = {"checked": 0, "mismatches": 0, "fixes": [], "errors": []}
|
||||
|
||||
for row in rows:
|
||||
results["checked"] += 1
|
||||
control_id = row[1]
|
||||
title = row[2]
|
||||
objective = row[3] or ""
|
||||
current_category = row[4]
|
||||
source_text = row[6] or objective
|
||||
|
||||
# Keyword detection on source text
|
||||
kw_category = _detect_category(source_text) or _detect_category(objective)
|
||||
kw_domain = _detect_domain(source_text)
|
||||
current_prefix = control_id.split("-")[0] if "-" in control_id else ""
|
||||
|
||||
# Skip if keyword detection agrees with current classification
|
||||
if kw_category == current_category and kw_domain == current_prefix:
|
||||
continue
|
||||
|
||||
results["mismatches"] += 1
|
||||
|
||||
# Ask Ollama to arbitrate
|
||||
try:
|
||||
reqs_text = ""
|
||||
try:
|
||||
reqs = json.loads(row[5])
|
||||
if isinstance(reqs, list):
|
||||
reqs_text = ", ".join(str(r) for r in reqs[:3])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prompt = f"""Pruefe dieses Compliance-Control auf korrekte Klassifizierung.
|
||||
|
||||
Titel: {title[:100]}
|
||||
Ziel: {objective[:200]}
|
||||
Anforderungen: {reqs_text[:200]}
|
||||
|
||||
Aktuelle Zuordnung: domain={current_prefix}, category={current_category}
|
||||
Keyword-Erkennung: domain={kw_domain}, category={kw_category}
|
||||
|
||||
Welche Zuordnung ist korrekt? Antworte NUR als JSON:
|
||||
{{"domain": "KUERZEL", "category": "kategorie_name", "reason": "kurze Begruendung"}}
|
||||
|
||||
Domains: AUTH=Authentifizierung, CRYP=Kryptographie, NET=Netzwerk, DATA=Datenschutz, LOG=Logging, ACC=Zugriffskontrolle, SEC=IT-Sicherheit, INC=Vorfallmanagement, AI=KI, COMP=Compliance, GOV=Behoerden, LAB=Arbeitsrecht, FIN=Finanzregulierung, TRD=Gewerbe, ENV=Umwelt, HLT=Gesundheit
|
||||
Kategorien: {CATEGORY_LIST_STR}"""
|
||||
|
||||
raw = await _llm_local(prompt)
|
||||
data = _parse_llm_json(raw)
|
||||
if not data:
|
||||
continue
|
||||
|
||||
qa_domain = data.get("domain", "").upper()
|
||||
qa_category = data.get("category", "")
|
||||
reason = data.get("reason", "")
|
||||
|
||||
fix_entry = {
|
||||
"control_id": control_id,
|
||||
"title": title[:80],
|
||||
"old_category": current_category,
|
||||
"old_domain": current_prefix,
|
||||
"new_category": qa_category if qa_category in VALID_CATEGORIES else current_category,
|
||||
"new_domain": qa_domain if qa_domain in VALID_DOMAINS else current_prefix,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
category_changed = qa_category in VALID_CATEGORIES and qa_category != current_category
|
||||
|
||||
if category_changed and not req.dry_run:
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE canonical_controls
|
||||
SET category = :category, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": row[0], "category": qa_category},
|
||||
)
|
||||
fix_entry["applied"] = True
|
||||
else:
|
||||
fix_entry["applied"] = False
|
||||
|
||||
results["fixes"].append(fix_entry)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append({"control_id": control_id, "error": str(e)})
|
||||
|
||||
if not req.dry_run:
|
||||
db.commit()
|
||||
|
||||
return results
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/generate/processed-stats")
|
||||
async def get_processed_stats():
|
||||
"""Get processing statistics per collection."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
collection,
|
||||
COUNT(*) as processed_chunks,
|
||||
COUNT(*) FILTER (WHERE processing_path = 'structured') as direct_adopted,
|
||||
COUNT(*) FILTER (WHERE processing_path = 'llm_reform') as llm_reformed,
|
||||
COUNT(*) FILTER (WHERE processing_path = 'skipped') as skipped
|
||||
FROM canonical_processed_chunks
|
||||
GROUP BY collection
|
||||
ORDER BY collection
|
||||
""")
|
||||
)
|
||||
stats = []
|
||||
cols = result.keys()
|
||||
for row in result:
|
||||
stat = dict(zip(cols, row))
|
||||
stat["total_chunks_estimated"] = 0 # Would need Qdrant API to get total
|
||||
stat["pending_chunks"] = 0
|
||||
stats.append(stat)
|
||||
return {"stats": stats}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BLOCKED SOURCES
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/blocked-sources")
|
||||
async def list_blocked_sources():
|
||||
"""List all blocked (Rule 3) sources."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT id, regulation_code, document_title, reason,
|
||||
deletion_status, qdrant_collection, marked_at
|
||||
FROM canonical_blocked_sources
|
||||
ORDER BY marked_at DESC
|
||||
""")
|
||||
)
|
||||
sources = []
|
||||
cols = result.keys()
|
||||
for row in result:
|
||||
src = dict(zip(cols, row))
|
||||
src["id"] = str(src["id"])
|
||||
src["marked_at"] = str(src["marked_at"])
|
||||
sources.append(src)
|
||||
return {"sources": sources}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/blocked-sources/cleanup")
|
||||
async def start_cleanup():
|
||||
"""Start cleanup workflow for blocked sources.
|
||||
|
||||
This marks all pending blocked sources for deletion.
|
||||
Actual RAG chunk deletion and file removal is a separate manual step.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("""
|
||||
UPDATE canonical_blocked_sources
|
||||
SET deletion_status = 'marked_for_deletion'
|
||||
WHERE deletion_status = 'pending'
|
||||
RETURNING regulation_code
|
||||
""")
|
||||
)
|
||||
marked = [row[0] for row in result]
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "marked_for_deletion",
|
||||
"marked_count": len(marked),
|
||||
"regulation_codes": marked,
|
||||
"message": "Sources marked for deletion. Run manual cleanup to remove RAG chunks and files.",
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CUSTOMER VIEW FILTER
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/controls-customer")
|
||||
async def get_controls_customer_view(
|
||||
severity: Optional[str] = Query(None),
|
||||
domain: Optional[str] = Query(None),
|
||||
):
|
||||
"""Get controls filtered for customer visibility.
|
||||
|
||||
Rule 3 controls have source_citation and source_original_text hidden.
|
||||
generation_metadata is NEVER shown to customers.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = """
|
||||
SELECT c.id, c.control_id, c.title, c.objective, c.rationale,
|
||||
c.scope, c.requirements, c.test_procedure, c.evidence,
|
||||
c.severity, c.risk_score, c.implementation_effort,
|
||||
c.open_anchors, c.release_state, c.tags,
|
||||
c.license_rule, c.customer_visible,
|
||||
c.source_original_text, c.source_citation,
|
||||
c.created_at, c.updated_at
|
||||
FROM canonical_controls c
|
||||
WHERE c.release_state IN ('draft', 'approved')
|
||||
"""
|
||||
params: dict = {}
|
||||
|
||||
if severity:
|
||||
query += " AND c.severity = :severity"
|
||||
params["severity"] = severity
|
||||
if domain:
|
||||
query += " AND c.control_id LIKE :domain"
|
||||
params["domain"] = f"{domain.upper()}-%"
|
||||
|
||||
query += " ORDER BY c.control_id"
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
controls = []
|
||||
cols = result.keys()
|
||||
for row in result:
|
||||
ctrl = dict(zip(cols, row))
|
||||
ctrl["id"] = str(ctrl["id"])
|
||||
for key in ("created_at", "updated_at"):
|
||||
if ctrl.get(key):
|
||||
ctrl[key] = str(ctrl[key])
|
||||
|
||||
# Parse JSON fields
|
||||
for jf in ("scope", "requirements", "test_procedure", "evidence",
|
||||
"open_anchors", "tags", "source_citation"):
|
||||
if isinstance(ctrl.get(jf), str):
|
||||
try:
|
||||
ctrl[jf] = json.loads(ctrl[jf])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Customer visibility rules:
|
||||
# - NEVER show generation_metadata
|
||||
# - Rule 3: NEVER show source_citation or source_original_text
|
||||
ctrl.pop("generation_metadata", None)
|
||||
if not ctrl.get("customer_visible", True):
|
||||
ctrl["source_citation"] = None
|
||||
ctrl["source_original_text"] = None
|
||||
|
||||
controls.append(ctrl)
|
||||
|
||||
return {"controls": controls, "total": len(controls)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CITATION BACKFILL
|
||||
# =============================================================================
|
||||
|
||||
class BackfillRequest(BaseModel):
|
||||
dry_run: bool = True # Default to dry_run for safety
|
||||
limit: int = 0 # 0 = all controls
|
||||
|
||||
|
||||
class BackfillResponse(BaseModel):
|
||||
status: str
|
||||
total_controls: int = 0
|
||||
matched_hash: int = 0
|
||||
matched_regex: int = 0
|
||||
matched_llm: int = 0
|
||||
unmatched: int = 0
|
||||
updated: int = 0
|
||||
errors: list = []
|
||||
|
||||
|
||||
_backfill_status: dict = {}
|
||||
|
||||
|
||||
async def _run_backfill_background(dry_run: bool, limit: int, backfill_id: str):
|
||||
"""Run backfill in background with own DB session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
backfill = CitationBackfill(db=db, rag_client=get_rag_client())
|
||||
result = await backfill.run(dry_run=dry_run, limit=limit)
|
||||
_backfill_status[backfill_id] = {
|
||||
"status": "completed",
|
||||
"total_controls": result.total_controls,
|
||||
"matched_hash": result.matched_hash,
|
||||
"matched_regex": result.matched_regex,
|
||||
"matched_llm": result.matched_llm,
|
||||
"unmatched": result.unmatched,
|
||||
"updated": result.updated,
|
||||
"errors": result.errors[:50],
|
||||
}
|
||||
logger.info("Backfill %s completed: %d updated", backfill_id, result.updated)
|
||||
except Exception as e:
|
||||
logger.error("Backfill %s failed: %s", backfill_id, e)
|
||||
_backfill_status[backfill_id] = {"status": "failed", "errors": [str(e)]}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate/backfill-citations", response_model=BackfillResponse)
|
||||
async def start_backfill(req: BackfillRequest):
|
||||
"""Backfill article/paragraph into existing control source_citations.
|
||||
|
||||
Uses 3-tier matching: hash lookup → regex parse → Ollama LLM.
|
||||
Default is dry_run=True (preview only, no DB changes).
|
||||
"""
|
||||
import uuid
|
||||
backfill_id = str(uuid.uuid4())[:8]
|
||||
_backfill_status[backfill_id] = {"status": "running"}
|
||||
|
||||
# Always run in background (RAG index build takes minutes)
|
||||
asyncio.create_task(_run_backfill_background(req.dry_run, req.limit, backfill_id))
|
||||
return BackfillResponse(
|
||||
status=f"running (id={backfill_id})",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/generate/backfill-status/{backfill_id}")
|
||||
async def get_backfill_status(backfill_id: str):
|
||||
"""Get status of a backfill job."""
|
||||
status = _backfill_status.get(backfill_id)
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Backfill job not found")
|
||||
return status
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DOMAIN + TARGET AUDIENCE BACKFILL
|
||||
# =============================================================================
|
||||
|
||||
class DomainBackfillRequest(BaseModel):
|
||||
dry_run: bool = True
|
||||
job_id: Optional[str] = None # Only backfill controls from this job
|
||||
limit: int = 0 # 0 = all
|
||||
|
||||
_domain_backfill_status: dict = {}
|
||||
|
||||
|
||||
async def _run_domain_backfill(req: DomainBackfillRequest, backfill_id: str):
|
||||
"""Backfill domain, category, and target_audience for existing controls using Anthropic."""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_MODEL = os.getenv("CONTROL_GEN_ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||
|
||||
if not ANTHROPIC_API_KEY:
|
||||
_domain_backfill_status[backfill_id] = {
|
||||
"status": "failed", "error": "ANTHROPIC_API_KEY not set"
|
||||
}
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Find controls needing backfill
|
||||
where_clauses = ["(target_audience IS NULL OR target_audience = '[]' OR target_audience = 'null')"]
|
||||
params: dict = {}
|
||||
if req.job_id:
|
||||
where_clauses.append("generation_metadata->>'job_id' = :job_id")
|
||||
params["job_id"] = req.job_id
|
||||
|
||||
query = f"""
|
||||
SELECT id, control_id, title, objective, category, source_original_text, tags
|
||||
FROM canonical_controls
|
||||
WHERE {' AND '.join(where_clauses)}
|
||||
ORDER BY control_id
|
||||
"""
|
||||
if req.limit > 0:
|
||||
query += f" LIMIT {req.limit}"
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
controls = [dict(zip(result.keys(), row)) for row in result]
|
||||
|
||||
total = len(controls)
|
||||
updated = 0
|
||||
errors = []
|
||||
|
||||
_domain_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "updated": 0, "errors": []
|
||||
}
|
||||
|
||||
# Process in batches of 10
|
||||
BATCH_SIZE = 10
|
||||
for batch_start in range(0, total, BATCH_SIZE):
|
||||
batch = controls[batch_start:batch_start + BATCH_SIZE]
|
||||
|
||||
entries = []
|
||||
for idx, ctrl in enumerate(batch):
|
||||
text_for_analysis = ctrl.get("objective") or ctrl.get("title") or ""
|
||||
original = ctrl.get("source_original_text") or ""
|
||||
if original:
|
||||
text_for_analysis += f"\n\nQuelltext-Auszug: {original[:500]}"
|
||||
entries.append(
|
||||
f"--- CONTROL {idx + 1}: {ctrl['control_id']} ---\n"
|
||||
f"Titel: {ctrl.get('title', '')}\n"
|
||||
f"Objective: {text_for_analysis[:800]}\n"
|
||||
f"Tags: {json.dumps(ctrl.get('tags', []))}"
|
||||
)
|
||||
|
||||
prompt = f"""Analysiere die folgenden {len(batch)} Controls und bestimme fuer jedes:
|
||||
1. domain: Das Fachgebiet (AUTH, CRYP, NET, DATA, LOG, ACC, SEC, INC, AI, COMP, GOV, LAB, FIN, TRD, ENV, HLT)
|
||||
2. category: Die Kategorie (encryption, authentication, network, data_protection, logging, incident, continuity, compliance, supply_chain, physical, personnel, application, system, risk, governance, hardware, identity, public_administration, labor_law, finance, trade_regulation, environmental, health)
|
||||
3. target_audience: Liste der Zielgruppen (moegliche Werte: "unternehmen", "behoerden", "entwickler", "datenschutzbeauftragte", "geschaeftsfuehrung", "it-abteilung", "rechtsabteilung", "compliance-officer", "personalwesen", "einkauf", "produktion", "vertrieb", "gesundheitswesen", "finanzwesen", "oeffentlicher_dienst")
|
||||
|
||||
Antworte mit einem JSON-Array mit {len(batch)} Objekten. Jedes Objekt hat:
|
||||
- control_index: 1-basierter Index
|
||||
- domain: Fachgebiet-Kuerzel
|
||||
- category: Kategorie
|
||||
- target_audience: Liste der Zielgruppen
|
||||
|
||||
{"".join(entries)}"""
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": ANTHROPIC_MODEL,
|
||||
"max_tokens": 4096,
|
||||
"system": "Du bist ein Compliance-Experte. Klassifiziere Controls nach Fachgebiet und Zielgruppe. Antworte NUR mit validem JSON.",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
errors.append(f"Anthropic API {resp.status_code} at batch {batch_start}")
|
||||
continue
|
||||
|
||||
raw = resp.json().get("content", [{}])[0].get("text", "")
|
||||
|
||||
# Parse response
|
||||
import re
|
||||
bracket_match = re.search(r"\[.*\]", raw, re.DOTALL)
|
||||
if not bracket_match:
|
||||
errors.append(f"No JSON array in response at batch {batch_start}")
|
||||
continue
|
||||
|
||||
results_list = json.loads(bracket_match.group(0))
|
||||
|
||||
for item in results_list:
|
||||
idx = item.get("control_index", 0) - 1
|
||||
if idx < 0 or idx >= len(batch):
|
||||
continue
|
||||
ctrl = batch[idx]
|
||||
ctrl_id = str(ctrl["id"])
|
||||
|
||||
new_domain = item.get("domain", "")
|
||||
new_category = item.get("category", "")
|
||||
new_audience = item.get("target_audience", [])
|
||||
|
||||
if not isinstance(new_audience, list):
|
||||
new_audience = []
|
||||
|
||||
# Build new control_id from domain if domain changed
|
||||
old_prefix = ctrl["control_id"].split("-")[0] if ctrl["control_id"] else ""
|
||||
new_prefix = new_domain.upper()[:4] if new_domain else old_prefix
|
||||
|
||||
if not req.dry_run:
|
||||
update_parts = []
|
||||
update_params: dict = {"ctrl_id": ctrl_id}
|
||||
|
||||
if new_category:
|
||||
update_parts.append("category = :category")
|
||||
update_params["category"] = new_category
|
||||
|
||||
if new_audience:
|
||||
update_parts.append("target_audience = :target_audience")
|
||||
update_params["target_audience"] = json.dumps(new_audience)
|
||||
|
||||
# Note: We do NOT rename control_ids here — that would
|
||||
# break references and cause unique constraint violations.
|
||||
|
||||
if update_parts:
|
||||
update_parts.append("updated_at = NOW()")
|
||||
db.execute(
|
||||
text(f"UPDATE canonical_controls SET {', '.join(update_parts)} WHERE id = CAST(:ctrl_id AS uuid)"),
|
||||
update_params,
|
||||
)
|
||||
updated += 1
|
||||
|
||||
if not req.dry_run:
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Batch {batch_start}: {str(e)}")
|
||||
db.rollback()
|
||||
|
||||
_domain_backfill_status[backfill_id] = {
|
||||
"status": "running", "total": total, "updated": updated,
|
||||
"progress": f"{min(batch_start + BATCH_SIZE, total)}/{total}",
|
||||
"errors": errors[-10:],
|
||||
}
|
||||
|
||||
_domain_backfill_status[backfill_id] = {
|
||||
"status": "completed", "total": total, "updated": updated,
|
||||
"errors": errors[-50:],
|
||||
}
|
||||
logger.info("Domain backfill %s completed: %d/%d updated", backfill_id, updated, total)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Domain backfill %s failed: %s", backfill_id, e)
|
||||
_domain_backfill_status[backfill_id] = {"status": "failed", "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/generate/backfill-domain")
|
||||
async def start_domain_backfill(req: DomainBackfillRequest):
|
||||
"""Backfill domain, category, and target_audience for controls using Anthropic API.
|
||||
|
||||
Finds controls where target_audience is NULL and enriches them.
|
||||
Default is dry_run=True (preview only).
|
||||
"""
|
||||
import uuid
|
||||
backfill_id = str(uuid.uuid4())[:8]
|
||||
_domain_backfill_status[backfill_id] = {"status": "starting"}
|
||||
asyncio.create_task(_run_domain_backfill(req, backfill_id))
|
||||
return {"status": "running", "backfill_id": backfill_id,
|
||||
"message": f"Domain backfill started. Poll /generate/backfill-status/{backfill_id}"}
|
||||
|
||||
|
||||
@router.get("/generate/domain-backfill-status/{backfill_id}")
|
||||
async def get_domain_backfill_status(backfill_id: str):
|
||||
"""Get status of a domain backfill job."""
|
||||
status = _domain_backfill_status.get(backfill_id)
|
||||
if not status:
|
||||
raise HTTPException(status_code=404, detail="Domain backfill job not found")
|
||||
return status
|
||||
@@ -37,13 +37,21 @@ Wir benoetigen ein System, um aus verschiedenen Security-Guidelines **eigenstaen
|
||||
| Domain | Name | Beschreibung |
|
||||
|--------|------|-------------|
|
||||
| AUTH | Identity & Access Management | Authentisierung, MFA, Token-Management |
|
||||
| NET | Network & Transport Security | TLS, Zertifikate, Netzwerk-Haertung |
|
||||
| SUP | Software Supply Chain | Signierung, SBOM, Dependency-Scanning |
|
||||
| LOG | Security Operations & Logging | Privacy-Aware Logging, SIEM |
|
||||
| WEB | Web Application Security | Admin-Flows, Account Recovery |
|
||||
| DATA | Data Governance & Classification | Datenklassifikation, Schutzmassnahmen |
|
||||
| CRYP | Cryptographic Operations | Key Management, Rotation, HSM |
|
||||
| REL | Release & Change Governance | Change Impact Assessment, Security Review |
|
||||
| NET | Network & Transport Security | TLS, Zertifikate, Netzwerk-Haertung |
|
||||
| DATA | Data Governance & Classification | Datenklassifikation, Schutzmassnahmen |
|
||||
| LOG | Security Operations & Logging | Privacy-Aware Logging, SIEM |
|
||||
| ACC | Access Control | Zugriffskontrolle, Berechtigungen |
|
||||
| SEC | IT Security | Schwachstellen, Haertung, Konfiguration |
|
||||
| INC | Incident Management | Vorfallmanagement, Wiederherstellung |
|
||||
| AI | Artificial Intelligence | KI-Compliance, Bias, Transparenz |
|
||||
| COMP | Compliance | Konformitaet, Audit, Zertifizierung |
|
||||
| GOV | Government & Public Administration | Behoerden, Verwaltung, Aufsicht |
|
||||
| LAB | Labor Law | Arbeitsrecht, Arbeitsschutz, Betriebsverfassung |
|
||||
| FIN | Financial Regulation | Finanzregulierung, Rechnungslegung, BaFin |
|
||||
| TRD | Trade Regulation | Gewerbe, Handelsrecht, Produktsicherheit |
|
||||
| ENV | Environmental | Umweltschutz, Nachhaltigkeit, Emissionen |
|
||||
| HLT | Health | Gesundheit, Medizinprodukte, Hygiene |
|
||||
|
||||
!!! warning "Keine BSI-Nomenklatur"
|
||||
Die Domains verwenden bewusst KEINE BSI-Bezeichner (O.Auth_*, O.Netz_*).
|
||||
@@ -123,6 +131,8 @@ erDiagram
|
||||
| `GET` | `/v1/canonical/generate/processed-stats` | Verarbeitungsstatistik pro Collection |
|
||||
| `GET` | `/v1/canonical/generate/review-queue` | Controls zur Pruefung |
|
||||
| `POST` | `/v1/canonical/generate/review/{control_id}` | Review abschliessen |
|
||||
| `POST` | `/v1/canonical/generate/bulk-review` | Bulk-Review (approve/reject nach State) |
|
||||
| `POST` | `/v1/canonical/generate/qa-reclassify` | QA-Reklassifizierung bestehender Controls |
|
||||
| `GET` | `/v1/canonical/blocked-sources` | Gesperrte Quellen (Rule 3) |
|
||||
| `POST` | `/v1/canonical/blocked-sources/cleanup` | Cleanup-Workflow starten |
|
||||
|
||||
@@ -231,25 +241,28 @@ Der Validator (`scripts/validate-controls.py`) prueft bei jedem Commit:
|
||||
|
||||
## Control Generator Pipeline
|
||||
|
||||
Automatische Generierung von Controls aus dem gesamten RAG-Korpus (~183.000 Chunks aus Gesetzen, Verordnungen und Standards).
|
||||
Aktueller Stand: **~2.120 Controls** generiert.
|
||||
Automatische Generierung von Controls aus dem gesamten RAG-Korpus (~105.000 Chunks aus Gesetzen, Verordnungen und Standards).
|
||||
Aktueller Stand: **~4.738 Controls** generiert.
|
||||
|
||||
### 8-Stufen-Pipeline
|
||||
### 9-Stufen-Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[1. RAG Scroll] -->|Alle Chunks| B[2. Prefilter - Lokales LLM]
|
||||
A[1. RAG Scroll] -->|max_chunks| B[2. Prefilter - Lokales LLM]
|
||||
B -->|Irrelevant| C[Als processed markieren]
|
||||
B -->|Relevant| D[3. License Classify]
|
||||
D -->|Batch sammeln| E[4. Batch Processing - 5 Chunks/API-Call]
|
||||
E -->|Rule 1/2| F[4a. Structure Batch - Anthropic]
|
||||
E -->|Rule 3| G[4b. Reform Batch - Anthropic]
|
||||
F --> H[5. Harmonization - Embeddings]
|
||||
G --> H
|
||||
F --> QA[5. QA Validation - Lokales LLM]
|
||||
G --> QA
|
||||
QA -->|Mismatch| QAF[Auto-Fix Category/Domain]
|
||||
QA -->|OK| H[6. Harmonization - Embeddings]
|
||||
QAF --> H
|
||||
H -->|Duplikat| I[Als Duplikat speichern]
|
||||
H -->|Neu| J[6. Anchor Search]
|
||||
J --> K[7. Store Control]
|
||||
K --> L[8. Mark Processed]
|
||||
H -->|Neu| J[7. Anchor Search]
|
||||
J --> K[8. Store Control]
|
||||
K --> L[9. Mark Processed]
|
||||
```
|
||||
|
||||
### Stufe 1: RAG Scroll (Vollstaendig)
|
||||
@@ -343,12 +356,64 @@ except Exception as e:
|
||||
Die `batch_size` ist ueber `GeneratorConfig` konfigurierbar.
|
||||
Bei grosser Batch-Size steigt die Wahrscheinlichkeit fuer Parsing-Fehler.
|
||||
|
||||
### Stufe 5: Harmonisierung (Embedding-basiert)
|
||||
### Stufe 5: QA Validation (Automatische Qualitaetspruefung)
|
||||
|
||||
Die QA-Stufe validiert die Klassifizierung jedes Controls automatisch. Sie vergleicht die LLM-Klassifizierung mit Keyword-basierter Erkennung und loest bei Abweichungen eine Arbitrierung durch das lokale Ollama-Modell aus.
|
||||
|
||||
#### Ablauf
|
||||
|
||||
1. **LLM-Category auswerten:** Der Anthropic-Prompt fragt jetzt explizit nach `category` und `domain`
|
||||
2. **Keyword-Detection als Cross-Check:** `_detect_category(chunk.text)` liefert eine zweite Meinung
|
||||
3. **Stimmen beide ueberein?** → Kein QA noetig (schneller Pfad)
|
||||
4. **Bei Disagreement:** Lokales LLM (Ollama qwen3.5:35b-a3b) arbitriert
|
||||
5. **Auto-Fix:** Bei hoher Konfidenz wird Category/Domain automatisch korrigiert
|
||||
|
||||
#### Beispiel
|
||||
|
||||
```
|
||||
Control: "Offenlegung von Risikokonzentrationen bei Finanzinstrumenten"
|
||||
LLM sagt: domain=AUTH, category=authentication
|
||||
Keyword sagt: domain=FIN, category=finance
|
||||
→ QA via Ollama: domain=FIN, category=finance (Grund: IFRS-Thema)
|
||||
→ Auto-Fix: AUTH-315 → FIN-xxx
|
||||
```
|
||||
|
||||
#### QA-Metriken in generation_metadata
|
||||
|
||||
```json
|
||||
{
|
||||
"qa_category_fix": {"from": "authentication", "to": "finance", "reason": "IFRS-Thema"},
|
||||
"qa_domain_fix": {"from": "AUTH", "to": "FIN", "reason": "Finanzregulierung"}
|
||||
}
|
||||
```
|
||||
|
||||
#### QA-Reklassifizierung bestehender Controls
|
||||
|
||||
Fuer bereits generierte Controls gibt es den Backfill-Endpoint:
|
||||
|
||||
```bash
|
||||
# Dry Run: Welche AUTH-Controls sind falsch klassifiziert?
|
||||
curl -X POST https://macmini:8002/api/compliance/v1/canonical/generate/qa-reclassify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"limit": 50, "dry_run": true, "filter_domain_prefix": "AUTH"}'
|
||||
|
||||
# Korrekturen anwenden:
|
||||
curl -X POST https://macmini:8002/api/compliance/v1/canonical/generate/qa-reclassify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"limit": 50, "dry_run": false, "filter_domain_prefix": "AUTH"}'
|
||||
```
|
||||
|
||||
!!! info "Performance"
|
||||
Die QA-Stufe nutzt das lokale Ollama-Modell (kostenlos, ~2s/Control).
|
||||
Sie wird nur bei Disagreement zwischen LLM und Keyword getriggert (~10-15% der Controls),
|
||||
sodass der Overhead minimal bleibt.
|
||||
|
||||
### Stufe 6: Harmonisierung (Embedding-basiert)
|
||||
|
||||
Prueft per bge-m3 Embeddings (Cosine Similarity > 0.85), ob ein aehnliches Control existiert.
|
||||
Embeddings werden in Batches vorgeladen (32 Texte/Request) fuer maximale Performance.
|
||||
|
||||
### Stufe 6-8: Anchor Search, Store, Mark Processed
|
||||
### Stufe 7-9: Anchor Search, Store, Mark Processed
|
||||
|
||||
- **Anchor Search:** Findet Open-Source-Referenzen (OWASP, NIST, ENISA)
|
||||
- **Store:** Persistiert Control mit `verification_method` und `category`
|
||||
@@ -358,6 +423,12 @@ Embeddings werden in Batches vorgeladen (32 Texte/Request) fuer maximale Perform
|
||||
|
||||
Bei der Generierung werden automatisch zugewiesen:
|
||||
|
||||
**Category** wird seit 2026-03-16 **dreigleisig** bestimmt:
|
||||
|
||||
1. **LLM-Klassifikation (primaer):** Anthropic liefert `category` im JSON-Response
|
||||
2. **Keyword-Detection (fallback):** Falls LLM keine Category liefert, greift `_detect_category()`
|
||||
3. **QA-Arbitrierung (bei Mismatch):** Lokales LLM entscheidet bei Widerspruch
|
||||
|
||||
**Verification Method** (Nachweis-Methode):
|
||||
|
||||
| Methode | Beschreibung |
|
||||
@@ -367,10 +438,33 @@ Bei der Generierung werden automatisch zugewiesen:
|
||||
| `tool` | Tool-basierte Pruefung |
|
||||
| `hybrid` | Kombination mehrerer Methoden |
|
||||
|
||||
**Category** (17 thematische Kategorien):
|
||||
encryption, authentication, network, data_protection, logging, incident,
|
||||
continuity, compliance, supply_chain, physical, personnel, application,
|
||||
system, risk, governance, hardware, identity
|
||||
**Category** (22 thematische Kategorien):
|
||||
|
||||
| Kategorie | Beschreibung |
|
||||
|-----------|-------------|
|
||||
| `encryption` | Verschluesselung, Kryptographie |
|
||||
| `authentication` | Authentifizierung, Login, MFA |
|
||||
| `network` | Netzwerk, Firewall, VPN |
|
||||
| `data_protection` | Datenschutz, DSGVO |
|
||||
| `logging` | Protokollierung, Monitoring |
|
||||
| `incident` | Vorfallmanagement |
|
||||
| `continuity` | Business Continuity, Backup |
|
||||
| `compliance` | Konformitaet, Audit, Zertifizierung |
|
||||
| `supply_chain` | Lieferkette, Dienstleister |
|
||||
| `physical` | Physische Sicherheit |
|
||||
| `personnel` | Schulung, Mitarbeiter |
|
||||
| `application` | Software, Code Review, API |
|
||||
| `system` | Haertung, Patch, Konfiguration |
|
||||
| `risk` | Risikobewertung, -management |
|
||||
| `governance` | Sicherheitsorganisation, Richtlinien |
|
||||
| `hardware` | Hardware, Firmware, TPM |
|
||||
| `identity` | IAM, SSO, Verzeichnisdienste |
|
||||
| `public_administration` | Behoerden, Verwaltung |
|
||||
| `labor_law` | Arbeitsrecht, Arbeitsschutz |
|
||||
| `finance` | Finanzregulierung, Rechnungslegung |
|
||||
| `trade_regulation` | Gewerbe, Handelsrecht |
|
||||
| `environmental` | Umweltschutz, Nachhaltigkeit |
|
||||
| `health` | Gesundheit, Medizinprodukte |
|
||||
|
||||
### Konfiguration
|
||||
|
||||
@@ -379,7 +473,7 @@ system, risk, governance, hardware, identity
|
||||
| `ANTHROPIC_API_KEY` | — | API-Key fuer Anthropic Claude |
|
||||
| `CONTROL_GEN_ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Anthropic-Modell fuer Formulierung |
|
||||
| `OLLAMA_URL` | `http://host.docker.internal:11434` | Lokaler Ollama-Server (Vorfilter) |
|
||||
| `CONTROL_GEN_OLLAMA_MODEL` | `qwen3:30b-a3b` | Lokales LLM fuer Vorfilter |
|
||||
| `CONTROL_GEN_OLLAMA_MODEL` | `qwen3.5:35b-a3b` | Lokales LLM fuer Vorfilter + QA |
|
||||
| `CONTROL_GEN_LLM_TIMEOUT` | `180` | Timeout in Sekunden (erhoet fuer Batch-Calls) |
|
||||
|
||||
**Pipeline-Konfiguration (via `GeneratorConfig`):**
|
||||
@@ -388,6 +482,7 @@ system, risk, governance, hardware, identity
|
||||
|-----------|---------|-------------|
|
||||
| `batch_size` | `5` | Chunks pro Anthropic-API-Call |
|
||||
| `max_controls` | `0` | Limit (0 = alle Chunks verarbeiten) |
|
||||
| `max_chunks` | `1000` | Max Chunks pro Job (respektiert Dokumentgrenzen) |
|
||||
| `skip_processed` | `true` | Bereits verarbeitete Chunks ueberspringen |
|
||||
| `dry_run` | `false` | Trockenlauf ohne DB-Schreibzugriffe |
|
||||
| `skip_web_search` | `false` | Web-Suche fuer Anchor-Finder ueberspringen |
|
||||
@@ -423,12 +518,16 @@ curl https://macmini:8002/api/compliance/v1/canonical/generate/jobs \
|
||||
| Collection | Inhalte | Erwartete Regel |
|
||||
|-----------|---------|----------------|
|
||||
| `bp_compliance_gesetze` | Deutsche Gesetze (BDSG, TTDSG, TKG etc.) | Rule 1 |
|
||||
| `bp_compliance_recht` | EU-Verordnungen (DSGVO, NIS2, AI Act etc.) | Rule 1 |
|
||||
| `bp_compliance_datenschutz` | Datenschutz-Leitlinien | Rule 1/2 |
|
||||
| `bp_compliance_datenschutz` | Datenschutz-Leitlinien + EU-Verordnungen | Rule 1/2 |
|
||||
| `bp_compliance_ce` | CE/Sicherheitsstandards | Rule 1/2/3 |
|
||||
| `bp_dsfa_corpus` | DSFA-Korpus | Rule 1/2 |
|
||||
| `bp_legal_templates` | Rechtsvorlagen | Rule 1 |
|
||||
|
||||
!!! warning "bp_compliance_recht entfernt (2026-03-16)"
|
||||
Die Collection `bp_compliance_recht` wurde geloescht, da sie mit `bp_compliance_datenschutz`
|
||||
ueberlappte (~20.000 Duplikat-Chunks). Alle relevanten EU-Verordnungen sind in den anderen
|
||||
Collections enthalten.
|
||||
|
||||
---
|
||||
|
||||
## Processed Chunks Tracking
|
||||
@@ -514,10 +613,10 @@ curl -s https://macmini:8002/api/compliance/v1/canonical/generate/processed-stat
|
||||
|
||||
| Metrik | Wert |
|
||||
|--------|------|
|
||||
| RAG-Chunks gesamt | ~183.000 |
|
||||
| Verarbeitete Chunks | ~183.000 (vollstaendig) |
|
||||
| Generierte Controls | **~2.120** |
|
||||
| Konversionsrate | ~1,2% (nur sicherheitsrelevante Chunks erzeugen Controls) |
|
||||
| RAG-Chunks gesamt | ~105.000 (nach Dedup 2026-03-16) |
|
||||
| Verarbeitete Chunks | ~105.000 |
|
||||
| Generierte Controls | **~4.738** |
|
||||
| Konversionsrate | ~4,5% (nur sicherheitsrelevante Chunks erzeugen Controls) |
|
||||
|
||||
!!! info "Warum so wenige Controls?"
|
||||
Die meisten RAG-Chunks sind Definitionen, Begriffsbestimmungen, Inhaltsverzeichnisse oder
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
# Training Engine (CP-TRAIN)
|
||||
|
||||
KI-generierte Schulungsinhalte, Rollenmatrix, Quiz-Engine und Zertifikatsverwaltung für Compliance-Schulungen.
|
||||
KI-generierte Schulungsinhalte, Rollenmatrix, Quiz-Engine, Zertifikate und Training Blocks fuer Compliance-Schulungen.
|
||||
|
||||
**Prefix:** `CP-TRAIN` · **seq:** 4800 · **Frontend:** `https://macmini:3007/sdk/training`
|
||||
**Learner-Portal:** `https://macmini:3007/sdk/training/learner`
|
||||
**Service:** `ai-compliance-sdk` (Go/Gin, Port 8093)
|
||||
**Proxy:** `/api/sdk/v1/training/[[...path]]` → `ai-compliance-sdk:8090/sdk/v1/training/...`
|
||||
**Developer Portal:** `https://macmini:3006/api/training`
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Rollenbasierte Schulungsmatrix (Wer muss was absolvieren?)
|
||||
- Rollenbasierte Schulungsmatrix (CTM) — 10 Rollen (R1–R10)
|
||||
- KI-generierte Schulungsinhalte (Text, Audio, Video via TTS-Service)
|
||||
- Quiz-Engine mit automatischer Auswertung
|
||||
- Deadline-Tracking und Eskalation bei Überziehung
|
||||
- Quiz-Engine mit automatischer Auswertung und Bestehensgrenze
|
||||
- Deadline-Tracking und 4-stufige Eskalation (7/14/30/45 Tage)
|
||||
- Aufgabenzuweisung (Assignments) mit Fortschrittstracking
|
||||
- Unveränderliches Audit-Log für Compliance-Nachweise
|
||||
- Bulk-Content-Generierung für alle Module auf einmal
|
||||
- PDF-Zertifikate nach erfolgreichem Abschluss
|
||||
- Training Blocks — automatische Modul-Erstellung aus Canonical Controls
|
||||
- Learner-Portal fuer Mitarbeiter (Schulung absolvieren, Quiz, Zertifikat)
|
||||
- Unveraenderliches Audit-Log fuer Compliance-Nachweise
|
||||
- Bulk-Content-Generierung fuer alle Module auf einmal
|
||||
|
||||
---
|
||||
|
||||
@@ -25,13 +30,15 @@ KI-generierte Schulungsinhalte, Rollenmatrix, Quiz-Engine und Zertifikatsverwalt
|
||||
| Artikel | Bezug |
|
||||
|---------|-------|
|
||||
| Art. 39 Abs. 1b DSGVO | DSB-Aufgabe: Sensibilisierung und Schulung |
|
||||
| Art. 5 AI Act | Schulungspflicht für verbotene KI-Praktiken |
|
||||
| Art. 4 Abs. 2 AI Act | Schulung für KI-Alphabetisierung |
|
||||
| Art. 5 AI Act | Schulungspflicht fuer verbotene KI-Praktiken |
|
||||
| Art. 4 Abs. 2 AI Act | Schulung fuer KI-Alphabetisierung |
|
||||
|
||||
---
|
||||
|
||||
## Tabs / Ansichten
|
||||
|
||||
### Admin-Frontend (`/sdk/training`)
|
||||
|
||||
| Tab | Inhalt |
|
||||
|-----|--------|
|
||||
| `overview` | Statistiken, Deadline-Warnung, Eskalation-Check |
|
||||
@@ -39,7 +46,23 @@ KI-generierte Schulungsinhalte, Rollenmatrix, Quiz-Engine und Zertifikatsverwalt
|
||||
| `matrix` | Rollen-Modul-Matrix — wer muss welche Schulung absolvieren |
|
||||
| `assignments` | Zugewiesene Schulungen mit Fortschritt und Deadline |
|
||||
| `content` | KI-generierter Inhalt pro Modul + Audio/Video-Player |
|
||||
| `audit` | Unveränderliches Audit-Log aller Schulungsaktivitäten |
|
||||
| `audit` | Unveraenderliches Audit-Log aller Schulungsaktivitaeten |
|
||||
|
||||
### Learner-Portal (`/sdk/training/learner`)
|
||||
|
||||
| Tab | Inhalt |
|
||||
|-----|--------|
|
||||
| Meine Schulungen | Zuweisungsliste mit Status-Badges, Fortschrittsbalken, Deadline |
|
||||
| Schulungsinhalt | Modul-Content (Markdown), AudioPlayer, VideoPlayer |
|
||||
| Quiz | Fragen mit Antwortauswahl, Timer, Ergebnis-Anzeige |
|
||||
| Zertifikate | Grid mit abgeschlossenen Schulungen, PDF-Download |
|
||||
|
||||
**Learner-Workflow:**
|
||||
|
||||
1. Mitarbeiter sieht seine Zuweisungen
|
||||
2. Klickt "Schulung starten" → Content lesen, Audio/Video anhoeren
|
||||
3. "Quiz starten" → Fragen beantworten
|
||||
4. Bei Bestehen: "Zertifikat generieren" → PDF-Download
|
||||
|
||||
---
|
||||
|
||||
@@ -49,65 +72,122 @@ KI-generierte Schulungsinhalte, Rollenmatrix, Quiz-Engine und Zertifikatsverwalt
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/modules` | Alle Module (`status`, `regulation` Filter) |
|
||||
| `GET` | `/sdk/v1/training/modules/:id` | Modul-Detail |
|
||||
| `GET` | `/sdk/v1/training/modules` | Alle Module (`regulation_area`, `frequency_type`, `search` Filter) |
|
||||
| `GET` | `/sdk/v1/training/modules/:id` | Modul-Detail mit Content und Quiz-Fragen |
|
||||
| `POST` | `/sdk/v1/training/modules` | Modul erstellen |
|
||||
| `PUT` | `/sdk/v1/training/modules/:id` | Modul aktualisieren |
|
||||
| `DELETE` | `/sdk/v1/training/modules/:id` | Modul löschen |
|
||||
| `DELETE` | `/sdk/v1/training/modules/:id` | Modul loeschen |
|
||||
|
||||
### Rollenmatrix
|
||||
### Rollenmatrix (CTM)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/matrix` | Vollständige Rollenmatrix |
|
||||
| `GET` | `/sdk/v1/training/matrix/role/:role` | Matrix für eine Rolle |
|
||||
| `POST` | `/sdk/v1/training/matrix` | Eintrag hinzufügen |
|
||||
| `GET` | `/sdk/v1/training/matrix` | Vollstaendige Rollenmatrix |
|
||||
| `GET` | `/sdk/v1/training/matrix/:role` | Matrix fuer eine Rolle |
|
||||
| `POST` | `/sdk/v1/training/matrix` | Eintrag hinzufuegen |
|
||||
| `DELETE` | `/sdk/v1/training/matrix/:role/:moduleId` | Eintrag entfernen |
|
||||
|
||||
**Rollen:** R1 Geschaeftsfuehrung, R2 IT-Leitung, R3 DSB, R4 ISB, R5 HR, R6 Einkauf, R7 Fachabteilung, R8 IT-Admin, R9 Alle Mitarbeiter, R10 Behoerden
|
||||
|
||||
### Assignments
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/assignments` | Alle Zuweisungen (Filter: `status`, `role`, `moduleId`) |
|
||||
| `GET` | `/sdk/v1/training/assignments` | Alle Zuweisungen (Filter: `user_id`, `module_id`, `role`, `status`) |
|
||||
| `GET` | `/sdk/v1/training/assignments/:id` | Zuweisung-Detail |
|
||||
| `POST` | `/sdk/v1/training/assignments/compute` | Zuweisungen aus Matrix berechnen |
|
||||
| `POST` | `/sdk/v1/training/assignments/:id/start` | Schulung starten |
|
||||
| `POST` | `/sdk/v1/training/assignments/:id/progress` | Fortschritt aktualisieren |
|
||||
| `POST` | `/sdk/v1/training/assignments/:id/complete` | Schulung abschliessen |
|
||||
| `PUT` | `/sdk/v1/training/assignments/:id` | Deadline aktualisieren |
|
||||
| `POST` | `/sdk/v1/training/assignments/:id/complete` | Schulung abschließen |
|
||||
|
||||
### KI-Inhalt
|
||||
### KI-Content-Generierung
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/modules/:id/content` | Aktueller Inhalt |
|
||||
| `POST` | `/sdk/v1/training/modules/:id/generate` | Inhalt KI-generieren (Skript, Audio, Video) |
|
||||
| `POST` | `/sdk/v1/training/content/generate` | Inhalt KI-generieren (Markdown) |
|
||||
| `POST` | `/sdk/v1/training/content/generate-quiz` | Quiz-Fragen KI-generieren |
|
||||
| `GET` | `/sdk/v1/training/content/:moduleId` | Veroeffentlichten Content laden |
|
||||
| `POST` | `/sdk/v1/training/content/:contentId/publish` | Inhalt freigeben |
|
||||
| `POST` | `/sdk/v1/training/content/generate-all` | Alle Module bulk-generieren |
|
||||
| `POST` | `/sdk/v1/training/content/:id/publish` | Inhalt freigeben |
|
||||
| `POST` | `/sdk/v1/training/content/generate-all-quiz` | Alle Quizzes bulk-generieren |
|
||||
|
||||
### Quiz
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/modules/:id/quiz` | Quiz-Fragen |
|
||||
| `POST` | `/sdk/v1/training/modules/:id/quiz/generate` | Quiz KI-generieren |
|
||||
| `POST` | `/sdk/v1/training/modules/:id/quiz/generate-all` | Alle Quizzes bulk-generieren |
|
||||
| `POST` | `/sdk/v1/training/modules/:id/quiz/submit` | Antworten einreichen |
|
||||
| `GET` | `/sdk/v1/training/assignments/:id/attempts` | Quiz-Versuche |
|
||||
| `GET` | `/sdk/v1/training/quiz/:moduleId` | Quiz-Fragen fuer ein Modul |
|
||||
| `POST` | `/sdk/v1/training/quiz/:moduleId/submit` | Antworten einreichen |
|
||||
| `GET` | `/sdk/v1/training/quiz/attempts/:assignmentId` | Quiz-Versuche anzeigen |
|
||||
|
||||
### Media (Audio/Video)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `POST` | `/sdk/v1/training/content/:moduleId/generate-audio` | Audio generieren (Piper TTS) |
|
||||
| `POST` | `/sdk/v1/training/content/:moduleId/generate-video` | Video generieren (TTS + Folien) |
|
||||
| `POST` | `/sdk/v1/training/content/:moduleId/preview-script` | Video-Script Vorschau (JSON) |
|
||||
| `GET` | `/sdk/v1/training/media/module/:moduleId` | Alle Medien eines Moduls |
|
||||
| `GET` | `/sdk/v1/training/media/:mediaId/url` | Metadaten (Bucket, Object Key) |
|
||||
| `POST` | `/sdk/v1/training/media/:mediaId/publish` | Media veroeffentlichen |
|
||||
| `GET` | `/sdk/v1/training/media/:mediaId/stream` | **Media streamen** (307 → Presigned URL) |
|
||||
|
||||
**Media-Streaming:** Der `/stream`-Endpoint liefert einen `307 Temporary Redirect` zu einer zeitlich begrenzten Presigned URL (MinIO/S3). Browser und Player folgen dem Redirect automatisch. Der Next.js-Proxy leitet den Redirect transparent weiter.
|
||||
|
||||
### Deadlines & Eskalation
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/deadlines` | Bevorstehende Deadlines |
|
||||
| `GET` | `/sdk/v1/training/deadlines/overdue` | Überfällige Deadlines |
|
||||
| `POST` | `/sdk/v1/training/escalations/check` | Eskalation auslösen |
|
||||
| `GET` | `/sdk/v1/training/deadlines/overdue` | Ueberfaellige Deadlines |
|
||||
| `POST` | `/sdk/v1/training/escalation/check` | Eskalation ausfuehren |
|
||||
|
||||
**Eskalationsstufen:**
|
||||
|
||||
| Stufe | Tage ueberfaellig | Aktion |
|
||||
|-------|-------------------|--------|
|
||||
| 1 | 7 Tage | Erinnerung an Mitarbeiter |
|
||||
| 2 | 14 Tage | Benachrichtigung Teamleitung |
|
||||
| 3 | 30 Tage | Benachrichtigung Management |
|
||||
| 4 | 45 Tage | Benachrichtigung Compliance Officer |
|
||||
|
||||
### Zertifikate
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `POST` | `/sdk/v1/training/certificates/generate/:assignmentId` | Zertifikat generieren |
|
||||
| `GET` | `/sdk/v1/training/certificates` | Alle Zertifikate des Tenants |
|
||||
| `GET` | `/sdk/v1/training/certificates/:id/pdf` | Zertifikat als PDF herunterladen |
|
||||
| `GET` | `/sdk/v1/training/certificates/:id/verify` | Zertifikat verifizieren |
|
||||
|
||||
**Voraussetzungen:** Status `completed` UND `quiz_passed = true`. Das PDF wird im Querformat (A4 Landscape) generiert und enthaelt Modul-Titel, Benutzer-Name, Datum und eine eindeutige Zertifikats-ID.
|
||||
|
||||
### Training Blocks (Controls → Module)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/blocks` | Alle Block-Konfigurationen |
|
||||
| `POST` | `/sdk/v1/training/blocks` | Block-Konfiguration erstellen |
|
||||
| `GET` | `/sdk/v1/training/blocks/:id` | Block-Konfiguration laden |
|
||||
| `PUT` | `/sdk/v1/training/blocks/:id` | Block-Konfiguration aktualisieren |
|
||||
| `DELETE` | `/sdk/v1/training/blocks/:id` | Block-Konfiguration loeschen |
|
||||
| `POST` | `/sdk/v1/training/blocks/:id/preview` | Vorschau: Welche Controls/Module? |
|
||||
| `POST` | `/sdk/v1/training/blocks/:id/generate` | Module generieren (Content + CTM) |
|
||||
| `GET` | `/sdk/v1/training/blocks/:id/controls` | Verlinkte Controls anzeigen |
|
||||
|
||||
### Canonical Controls
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/canonical/controls` | Controls auflisten (Filter: domain, category, severity, target_audience) |
|
||||
| `GET` | `/sdk/v1/training/canonical/meta` | Metadaten (Domains, Kategorien, Audiences mit Counts) |
|
||||
|
||||
### Statistiken & Audit
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| `GET` | `/sdk/v1/training/stats` | Überblick-Statistiken |
|
||||
| `GET` | `/sdk/v1/training/audit` | Audit-Log (Filter: `action`, `moduleId`, `userId`) |
|
||||
| `GET` | `/sdk/v1/training/stats` | Ueberblick-Statistiken |
|
||||
| `GET` | `/sdk/v1/training/audit-log` | Audit-Log (Filter: `action`, `entity_type`, `limit`, `offset`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +198,7 @@ seq 4700: Academy (Compliance Academy — manuelle Schulungen)
|
||||
seq 4800: Training Engine (KI-generierte Schulungen, Matrix, Quiz)
|
||||
```
|
||||
|
||||
Das Training-Modul erweitert die Academy um KI-generierte Inhalte. Während die Academy einfache Schulungseinheiten verwaltet, bietet die Training Engine automatische Inhaltsgenerierung, eine Rollenmatrix und eine vollständige Quiz-Engine.
|
||||
Das Training-Modul erweitert die Academy um KI-generierte Inhalte. Waehrend die Academy einfache Schulungseinheiten verwaltet, bietet die Training Engine automatische Inhaltsgenerierung, eine Rollenmatrix und eine vollstaendige Quiz-Engine.
|
||||
|
||||
---
|
||||
|
||||
@@ -128,25 +208,39 @@ KI-generierte Inhalte werden via `compliance-tts-service` (Port 8095) in Audio u
|
||||
|
||||
- **Audio:** Piper TTS → MP3 (Modell: `de_DE-thorsten-high.onnx`)
|
||||
- **Video:** FFmpeg → MP4 (Skript + Stimme + Untertitel)
|
||||
- **Storage:** S3-kompatibles Object Storage (TLS)
|
||||
- **Storage:** S3-kompatibles Object Storage (Hetzner, TLS)
|
||||
- **Streaming:** `/media/:id/stream` → 307 Redirect zu MinIO Presigned URL
|
||||
|
||||
```
|
||||
AudioPlayer → /sdk/v1/training/modules/:id/media (audio)
|
||||
VideoPlayer → /sdk/v1/training/modules/:id/media (video)
|
||||
AudioPlayer → /sdk/v1/training/media/:mediaId/stream
|
||||
VideoPlayer → /sdk/v1/training/media/:mediaId/stream
|
||||
TTS Service → POST /presigned-url (returns pre-signed MinIO URL)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Admin-Frontend
|
||||
|
||||
**URL:** `https://macmini:3007/sdk/training`
|
||||
|
||||
6-Tab-Oberfläche mit:
|
||||
6-Tab-Oberflaeche mit:
|
||||
- Statistik-Dashboard (Abschlussquote, offene Schulungen, Deadlines)
|
||||
- Modul-CRUD mit Regulierungs-Badges
|
||||
- Modul-CRUD mit Regulierungs-Badges und Loeschfunktion
|
||||
- Interaktive Rollenmatrix (Checkboxen)
|
||||
- Fortschritts-Balken pro Assignment
|
||||
- Eingebetteter Audio/Video-Player für KI-generierte Inhalte
|
||||
- Eingebetteter Audio/Video-Player fuer KI-generierte Inhalte
|
||||
|
||||
### Learner-Portal
|
||||
|
||||
**URL:** `https://macmini:3007/sdk/training/learner`
|
||||
|
||||
4-Tab-Oberflaeche fuer Mitarbeiter:
|
||||
- Meine Schulungen: Zuweisungsliste mit Status, Fortschritt, Deadline
|
||||
- Schulungsinhalt: Markdown-Rendering, Audio-Player, Video-Player
|
||||
- Quiz: Multiple-Choice mit Timer, Ergebnis-Anzeige, Bestehens-Logik
|
||||
- Zertifikate: Uebersicht abgeschlossener Schulungen, PDF-Download
|
||||
|
||||
---
|
||||
|
||||
@@ -156,10 +250,28 @@ Die Training Engine verwendet eigene Tabellen im `compliance` Schema:
|
||||
|
||||
```sql
|
||||
training_modules -- Schulungsmodule (title, regulation, frequency, ...)
|
||||
training_matrix -- Rollen-Modul-Zuordnungen
|
||||
training_assignments -- Zuweisungen (user, module, status, progress, deadline)
|
||||
training_content -- KI-generierter Inhalt (script, audio_url, video_url, ...)
|
||||
training_quiz -- Quiz-Fragen pro Modul
|
||||
training_quiz_attempts -- Eingereichter Antworten + Score
|
||||
training_audit_log -- Unveränderliches Audit-Log
|
||||
training_matrix -- Rollen-Modul-Zuordnungen (CTM)
|
||||
training_assignments -- Zuweisungen (user, module, status, progress, deadline, certificate_id)
|
||||
training_content -- KI-generierter Inhalt (markdown, summary, llm_model)
|
||||
training_quiz_questions -- Quiz-Fragen pro Modul (options JSONB, correct_index)
|
||||
training_quiz_attempts -- Eingereichte Antworten + Score
|
||||
training_media -- Audio/Video-Dateien (bucket, object_key, status)
|
||||
training_audit_log -- Unveraenderliches Audit-Log
|
||||
training_block_configs -- Block-Konfigurationen (Filter, Prefix, Frequenz)
|
||||
training_block_controls -- Verlinkte Canonical Controls pro Block
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Handler-Tests (47 Tests)
|
||||
cd ai-compliance-sdk && go test -v ./internal/api/handlers/ -run "TestGet|TestCreate|TestUpdate|TestDelete|..."
|
||||
|
||||
# Escalation + Content Generator Tests (43 Tests)
|
||||
cd ai-compliance-sdk && go test -v ./internal/training/ -run "TestEscalation|TestBuildContent|TestParseQuiz|..."
|
||||
|
||||
# Block Generator Tests (14 Tests)
|
||||
cd ai-compliance-sdk && go test -v ./internal/training/ -run "TestBlock"
|
||||
```
|
||||
|
||||
358
scripts/cleanup-qdrant-duplicates.py
Normal file
358
scripts/cleanup-qdrant-duplicates.py
Normal file
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Qdrant Duplicate Cleanup
|
||||
Removes redundant/duplicate chunks from Qdrant collections.
|
||||
|
||||
Targets:
|
||||
1. bp_compliance_recht — entire collection (100% subset of bp_compliance_ce)
|
||||
2. bp_compliance_gesetze — old versions where _komplett exists
|
||||
3. bp_compliance_gesetze — BGB section extracts (subset of bgb_komplett)
|
||||
4. bp_compliance_gesetze — AT law duplicates (renamed copies)
|
||||
5. bp_compliance_gesetze — stubs (1 chunk placeholders)
|
||||
6. bp_compliance_gesetze — EU regulations already in bp_compliance_ce
|
||||
7. bp_compliance_gesetze — dual-naming duplicates (keep newer/longer version)
|
||||
8. bp_compliance_datenschutz — EDPB/WP duplicate ingestions
|
||||
|
||||
Run with --dry-run to preview deletions without executing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Config — targets BOTH local and production Qdrant
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TARGETS = {
|
||||
"local": {
|
||||
"url": "http://macmini:6333",
|
||||
"api_key": None,
|
||||
},
|
||||
"production": {
|
||||
"url": "https://qdrant-dev.breakpilot.ai",
|
||||
"api_key": "z9cKbT74vl1aKPD1QGIlKWfET47VH93u",
|
||||
},
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Deletion plan — regulation_ids to remove per collection
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 1. bp_compliance_recht: DELETE ENTIRE COLLECTION
|
||||
# All 9 regulation_ids are already in bp_compliance_ce with same or more chunks
|
||||
|
||||
# 2. bp_compliance_gesetze: old versions (keep _komplett)
|
||||
GESETZE_OLD_VERSIONS = [
|
||||
"ao", # ao_komplett has 9,669 chunks vs ao's 1,752
|
||||
"bdsg", # bdsg_2018_komplett has 1,056 vs bdsg's 389
|
||||
"egbgb", # egbgb_komplett has 1,412 vs egbgb's 269
|
||||
"hgb", # hgb_komplett has 11,363 vs hgb's 1,937
|
||||
]
|
||||
|
||||
# 3. bp_compliance_gesetze: BGB section extracts (subset of bgb_komplett 4,024 chunks)
|
||||
GESETZE_BGB_EXTRACTS = [
|
||||
"bgb_agb", # 94 chunks
|
||||
"bgb_digital", # 42 chunks
|
||||
"bgb_fernabsatz", # 71 chunks
|
||||
"bgb_kaufrecht", # 147 chunks
|
||||
"bgb_widerruf", # 50 chunks
|
||||
]
|
||||
|
||||
# 4. bp_compliance_gesetze: AT law duplicates (renamed copies with identical chunks)
|
||||
GESETZE_AT_DUPLICATES = [
|
||||
"at_abgb_agb", # 2,521 chunks = exact copy of at_abgb
|
||||
"at_bao_ret", # 2,246 chunks = exact copy of at_bao
|
||||
"at_ugb_ret", # 2,828 chunks = exact copy of at_ugb
|
||||
]
|
||||
|
||||
# 5. bp_compliance_gesetze: stubs (1 chunk, incomplete ingestions)
|
||||
GESETZE_STUBS = [
|
||||
"de_uwg", # 1 chunk (uwg has 157)
|
||||
"de_pangv", # 1 chunk (pangv has 99)
|
||||
"de_bsig", # 1 chunk (standalone stub)
|
||||
]
|
||||
|
||||
# 6. bp_compliance_gesetze: EU regulations already fully in bp_compliance_ce
|
||||
# CE has equal or more chunks for all of these
|
||||
GESETZE_EU_CROSS_COLLECTION = [
|
||||
"eu_2016_679", # GDPR: 423 in both
|
||||
"eu_2024_1689", # AI Act: 726 in both
|
||||
"eu_2024_2847", # CRA: 429 in gesetze, 1365 in CE
|
||||
"eu_2022_2555", # NIS2: 344 in gesetze, 342 in CE (near-identical)
|
||||
"eu_2023_1230", # Machinery: 395 in gesetze, 1271 in CE
|
||||
]
|
||||
|
||||
# 7. bp_compliance_gesetze: dual-naming (keep the longer/newer version)
|
||||
GESETZE_DUAL_NAMING = [
|
||||
"tkg", # 1,391 chunks — de_tkg has 1,631 (keep de_tkg)
|
||||
"ustg", # 915 chunks — de_ustg_ret has 1,071 (keep de_ustg_ret)
|
||||
"ddg_5", # 40 chunks — ddg has 189 (section extract)
|
||||
"egbgb_widerruf", # 36 chunks — egbgb_komplett has 1,412 (section extract)
|
||||
]
|
||||
|
||||
# 8. bp_compliance_datenschutz: EDPB/WP duplicate ingestions (keep the longer-named version)
|
||||
DATENSCHUTZ_DUPLICATES = [
|
||||
"edpb_rtbf_05_2019", # 111 chunks — edpb_right_to_be_forgotten_05_2019 has 111 (keep long name)
|
||||
"edpb_vva_02_2021", # 273 chunks — edpb_virtual_voice_assistant_02_2021 has 273 (keep long name)
|
||||
"edpb_01_2020", # 337 chunks — edpb_transfers_01_2020 has 337 (keep long name)
|
||||
"wp242_portability", # 141 chunks — wp242_right_portability has 141 (keep long name)
|
||||
"wp250_breach", # 201 chunks — wp251_data_breach has 201 (keep long name)
|
||||
"wp244_profiling", # 247 chunks — wp251_profiling has 247 (keep long name)
|
||||
"edpb_legitimate_interest", # 672 chunks — edpb_legitimate_interest_01_2024 has 336 (keep dated version)
|
||||
]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# All gesetze deletions combined
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALL_GESETZE_DELETIONS = (
|
||||
GESETZE_OLD_VERSIONS
|
||||
+ GESETZE_BGB_EXTRACTS
|
||||
+ GESETZE_AT_DUPLICATES
|
||||
+ GESETZE_STUBS
|
||||
+ GESETZE_EU_CROSS_COLLECTION
|
||||
+ GESETZE_DUAL_NAMING
|
||||
)
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"\033[0;32m[OK]\033[0m {msg}")
|
||||
|
||||
def warn(msg):
|
||||
print(f"\033[1;33m[WARN]\033[0m {msg}")
|
||||
|
||||
def info(msg):
|
||||
print(f"\033[0;36m[INFO]\033[0m {msg}")
|
||||
|
||||
def fail(msg):
|
||||
print(f"\033[0;31m[FAIL]\033[0m {msg}")
|
||||
|
||||
|
||||
def make_session(target_config):
|
||||
"""Create a requests session for the given target."""
|
||||
s = requests.Session()
|
||||
s.headers.update({"Content-Type": "application/json"})
|
||||
if target_config["api_key"]:
|
||||
s.headers.update({"api-key": target_config["api_key"]})
|
||||
s.timeout = 60
|
||||
return s
|
||||
|
||||
|
||||
def count_by_regulation_id(session, url, collection, regulation_id):
|
||||
"""Count points in a collection matching a regulation_id."""
|
||||
resp = session.post(
|
||||
f"{url}/collections/{collection}/points/count",
|
||||
json={
|
||||
"filter": {
|
||||
"must": [
|
||||
{"key": "regulation_id", "match": {"value": regulation_id}}
|
||||
]
|
||||
},
|
||||
"exact": True,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("result", {}).get("count", 0)
|
||||
return -1
|
||||
|
||||
|
||||
def count_collection(session, url, collection):
|
||||
"""Get total point count for a collection."""
|
||||
resp = session.get(f"{url}/collections/{collection}")
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("result", {}).get("points_count", 0)
|
||||
return -1
|
||||
|
||||
|
||||
def delete_by_regulation_id(session, url, collection, regulation_id, dry_run=True):
|
||||
"""Delete all points in a collection matching a regulation_id."""
|
||||
count = count_by_regulation_id(session, url, collection, regulation_id)
|
||||
if count <= 0:
|
||||
if count == 0:
|
||||
info(f" {collection}/{regulation_id}: 0 chunks (already clean)")
|
||||
else:
|
||||
warn(f" {collection}/{regulation_id}: count failed")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
info(f" {collection}/{regulation_id}: {count} chunks (would delete)")
|
||||
return count
|
||||
|
||||
resp = session.post(
|
||||
f"{url}/collections/{collection}/points/delete",
|
||||
json={
|
||||
"filter": {
|
||||
"must": [
|
||||
{"key": "regulation_id", "match": {"value": regulation_id}}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
log(f" {collection}/{regulation_id}: {count} chunks deleted")
|
||||
return count
|
||||
else:
|
||||
warn(f" {collection}/{regulation_id}: delete failed ({resp.status_code}: {resp.text[:200]})")
|
||||
return 0
|
||||
|
||||
|
||||
def delete_collection(session, url, collection, dry_run=True):
|
||||
"""Delete an entire collection."""
|
||||
count = count_collection(session, url, collection)
|
||||
if count < 0:
|
||||
warn(f" {collection}: not found or error")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
info(f" {collection}: {count} chunks total (would delete collection)")
|
||||
return count
|
||||
|
||||
resp = session.delete(f"{url}/collections/{collection}")
|
||||
if resp.status_code == 200:
|
||||
log(f" {collection}: deleted ({count} chunks)")
|
||||
return count
|
||||
else:
|
||||
warn(f" {collection}: delete failed ({resp.status_code}: {resp.text[:200]})")
|
||||
return 0
|
||||
|
||||
|
||||
def run_cleanup(target_name, target_config, dry_run=True):
|
||||
"""Run the full cleanup for a single Qdrant target."""
|
||||
url = target_config["url"]
|
||||
session = make_session(target_config)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Target: {target_name} ({url})")
|
||||
print(f"Mode: {'DRY RUN' if dry_run else 'LIVE DELETE'}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Check connectivity
|
||||
try:
|
||||
resp = session.get(f"{url}/collections")
|
||||
resp.raise_for_status()
|
||||
collections = [c["name"] for c in resp.json().get("result", {}).get("collections", [])]
|
||||
info(f"Connected. Collections: {len(collections)}")
|
||||
except Exception as e:
|
||||
warn(f"Cannot connect to {url}: {e}")
|
||||
return
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
# ── Step 1: Delete bp_compliance_recht ──
|
||||
print(f"\n--- Step 1: Delete bp_compliance_recht (100% subset of CE) ---")
|
||||
if "bp_compliance_recht" in collections:
|
||||
total_deleted += delete_collection(session, url, "bp_compliance_recht", dry_run)
|
||||
else:
|
||||
info(" bp_compliance_recht: not found (already deleted)")
|
||||
|
||||
# ── Step 2: Delete old versions in gesetze ──
|
||||
print(f"\n--- Step 2: Delete old versions in bp_compliance_gesetze ---")
|
||||
print(f" (ao, bdsg, egbgb, hgb — _komplett versions exist)")
|
||||
if "bp_compliance_gesetze" in collections:
|
||||
for reg_id in GESETZE_OLD_VERSIONS:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_gesetze", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Step 3: Delete BGB section extracts ──
|
||||
print(f"\n--- Step 3: Delete BGB section extracts (bgb_komplett covers all) ---")
|
||||
if "bp_compliance_gesetze" in collections:
|
||||
for reg_id in GESETZE_BGB_EXTRACTS:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_gesetze", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Step 4: Delete AT law duplicates ──
|
||||
print(f"\n--- Step 4: Delete Austrian law duplicates ---")
|
||||
if "bp_compliance_gesetze" in collections:
|
||||
for reg_id in GESETZE_AT_DUPLICATES:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_gesetze", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Step 5: Delete stubs ──
|
||||
print(f"\n--- Step 5: Delete stub entries (1-chunk placeholders) ---")
|
||||
if "bp_compliance_gesetze" in collections:
|
||||
for reg_id in GESETZE_STUBS:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_gesetze", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Step 6: Delete EU cross-collection duplicates from gesetze ──
|
||||
print(f"\n--- Step 6: Delete EU regulations from gesetze (keep CE) ---")
|
||||
if "bp_compliance_gesetze" in collections:
|
||||
for reg_id in GESETZE_EU_CROSS_COLLECTION:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_gesetze", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Step 7: Delete dual-naming duplicates in gesetze ──
|
||||
print(f"\n--- Step 7: Delete dual-naming duplicates in gesetze ---")
|
||||
if "bp_compliance_gesetze" in collections:
|
||||
for reg_id in GESETZE_DUAL_NAMING:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_gesetze", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Step 8: Delete EDPB/WP duplicates in datenschutz ──
|
||||
print(f"\n--- Step 8: Delete EDPB/WP duplicate ingestions ---")
|
||||
if "bp_compliance_datenschutz" in collections:
|
||||
for reg_id in DATENSCHUTZ_DUPLICATES:
|
||||
total_deleted += delete_by_regulation_id(
|
||||
session, url, "bp_compliance_datenschutz", reg_id, dry_run
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# ── Summary ──
|
||||
print(f"\n{'='*60}")
|
||||
action = "would be deleted" if dry_run else "deleted"
|
||||
print(f"Total chunks {action}: {total_deleted:,}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Qdrant duplicate chunk cleanup")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Preview deletions without executing (default: false)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
choices=["local", "production", "both"],
|
||||
default="both",
|
||||
help="Which Qdrant instance to clean (default: both)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.dry_run:
|
||||
print("\n" + "!" * 60)
|
||||
print(" WARNING: LIVE DELETE MODE — chunks will be permanently removed!")
|
||||
print("!" * 60)
|
||||
answer = input(" Type 'DELETE' to confirm: ")
|
||||
if answer != "DELETE":
|
||||
print(" Aborted.")
|
||||
sys.exit(0)
|
||||
|
||||
targets = (
|
||||
TARGETS.items()
|
||||
if args.target == "both"
|
||||
else [(args.target, TARGETS[args.target])]
|
||||
)
|
||||
|
||||
for name, config in targets:
|
||||
run_cleanup(name, config, dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user