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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user