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:
@@ -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,13 +408,23 @@ 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"
|
||||
>
|
||||
<ListChecks className="w-4 h-4" />
|
||||
Review ({reviewCount})
|
||||
</button>
|
||||
<>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<span>
|
||||
{totalCount} Controls gefunden
|
||||
{totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`}
|
||||
{loading && <span className="ml-2 text-purple-500">Lade...</span>}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user