feat(academy): bridge Academy with Training Engine for course generation
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 46s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 29s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 46s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 29s
- Add POST /academy/courses/generate endpoint that creates an academy
course from a training module (with content + quiz as lessons)
- Add POST /academy/courses/generate-all to bulk-generate all courses
- Fix academy API response mapping (snake_case → camelCase)
- Fix fetchCourses/fetchCourse/fetchEnrollments/fetchStats to unwrap
backend response wrappers ({courses:[...]}, {course:{...}})
- Add "Alle Kurse generieren" button to academy overview page
- Fix bulkResult.errors crash in training page (optional chaining)
- Add SetAcademyCourseID to training store for bidirectional linking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
|||||||
isEnrollmentOverdue,
|
isEnrollmentOverdue,
|
||||||
getDaysUntilDeadline
|
getDaysUntilDeadline
|
||||||
} from '@/lib/sdk/academy/types'
|
} from '@/lib/sdk/academy/types'
|
||||||
import { fetchSDKAcademyList } from '@/lib/sdk/academy/api'
|
import { fetchSDKAcademyList, generateAllCourses } from '@/lib/sdk/academy/api'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -359,6 +359,8 @@ export default function AcademyPage() {
|
|||||||
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
||||||
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
|
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [generateResult, setGenerateResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
|
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
|
||||||
@@ -366,19 +368,6 @@ export default function AcademyPage() {
|
|||||||
|
|
||||||
// Load data from SDK backend
|
// Load data from SDK backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await fetchSDKAcademyList()
|
|
||||||
setCourses(data.courses)
|
|
||||||
setEnrollments(data.enrollments)
|
|
||||||
setStatistics(data.statistics)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load Academy data:', error)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -446,6 +435,36 @@ export default function AcademyPage() {
|
|||||||
|
|
||||||
const stepInfo = STEP_EXPLANATIONS['academy']
|
const stepInfo = STEP_EXPLANATIONS['academy']
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchSDKAcademyList()
|
||||||
|
setCourses(data.courses)
|
||||||
|
setEnrollments(data.enrollments)
|
||||||
|
setStatistics(data.statistics)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Academy data:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateAll = async () => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
setGenerateResult(null)
|
||||||
|
try {
|
||||||
|
const result = await generateAllCourses()
|
||||||
|
setGenerateResult({ generated: result.generated, skipped: result.skipped, errors: result.errors || [] })
|
||||||
|
// Reload data to show new courses
|
||||||
|
await loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate courses:', error)
|
||||||
|
setGenerateResult({ generated: 0, skipped: 0, errors: [error instanceof Error ? error.message : 'Fehler bei der Generierung'] })
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSelectedCategory('all')
|
setSelectedCategory('all')
|
||||||
setSelectedStatus('all')
|
setSelectedStatus('all')
|
||||||
@@ -461,6 +480,24 @@ export default function AcademyPage() {
|
|||||||
explanation={stepInfo?.explanation}
|
explanation={stepInfo?.explanation}
|
||||||
tips={stepInfo?.tips}
|
tips={stepInfo?.tips}
|
||||||
>
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateAll}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/sdk/academy/new"
|
href="/sdk/academy/new"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
@@ -470,8 +507,27 @@ export default function AcademyPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Kurs erstellen
|
Kurs erstellen
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Generation Result */}
|
||||||
|
{generateResult && (
|
||||||
|
<div className={`p-4 rounded-lg border ${generateResult.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-green-700 font-medium">{generateResult.generated} Kurse generiert</span>
|
||||||
|
<span className="text-gray-500">{generateResult.skipped} uebersprungen</span>
|
||||||
|
{generateResult.errors.length > 0 && (
|
||||||
|
<span className="text-red-600">{generateResult.errors.length} Fehler</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{generateResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-red-600">
|
||||||
|
{generateResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<TabNavigation
|
<TabNavigation
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function TrainingPage() {
|
|||||||
setBulkResult(null)
|
setBulkResult(null)
|
||||||
try {
|
try {
|
||||||
const result = await generateAllContent('de')
|
const result = await generateAllContent('de')
|
||||||
setBulkResult(result)
|
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung')
|
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung')
|
||||||
@@ -155,7 +155,7 @@ export default function TrainingPage() {
|
|||||||
setBulkResult(null)
|
setBulkResult(null)
|
||||||
try {
|
try {
|
||||||
const result = await generateAllQuizzes()
|
const result = await generateAllQuizzes()
|
||||||
setBulkResult(result)
|
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung')
|
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Quiz-Generierung')
|
||||||
@@ -475,11 +475,11 @@ export default function TrainingPage() {
|
|||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||||
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||||
{bulkResult.errors.length > 0 && (
|
{bulkResult.errors?.length > 0 && (
|
||||||
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
|
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{bulkResult.errors.length > 0 && (
|
{bulkResult.errors?.length > 0 && (
|
||||||
<div className="mt-2 text-xs text-red-600">
|
<div className="mt-2 text-xs text-red-600">
|
||||||
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Connects to the ai-compliance-sdk backend via Next.js proxy
|
* Connects to the ai-compliance-sdk backend via Next.js proxy
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
Course,
|
Course,
|
||||||
CourseCategory,
|
CourseCategory,
|
||||||
CourseCreateRequest,
|
CourseCreateRequest,
|
||||||
@@ -18,10 +18,11 @@ import {
|
|||||||
UpdateProgressRequest,
|
UpdateProgressRequest,
|
||||||
Certificate,
|
Certificate,
|
||||||
AcademyStatistics,
|
AcademyStatistics,
|
||||||
|
LessonType,
|
||||||
SubmitQuizRequest,
|
SubmitQuizRequest,
|
||||||
SubmitQuizResponse,
|
SubmitQuizResponse,
|
||||||
isEnrollmentOverdue
|
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import { isEnrollmentOverdue } from './types'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CONFIGURATION
|
// CONFIGURATION
|
||||||
@@ -111,18 +112,72 @@ async function fetchWithTimeout<T>(
|
|||||||
* Alle Kurse abrufen
|
* Alle Kurse abrufen
|
||||||
*/
|
*/
|
||||||
export async function fetchCourses(): Promise<Course[]> {
|
export async function fetchCourses(): Promise<Course[]> {
|
||||||
return fetchWithTimeout<Course[]>(
|
const res = await fetchWithTimeout<{ courses: Course[]; total: number }>(
|
||||||
`${ACADEMY_API_BASE}/courses`
|
`${ACADEMY_API_BASE}/courses`
|
||||||
)
|
)
|
||||||
|
return mapCoursesFromBackend(res.courses || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einzelnen Kurs abrufen
|
* Einzelnen Kurs abrufen
|
||||||
*/
|
*/
|
||||||
export async function fetchCourse(id: string): Promise<Course> {
|
export async function fetchCourse(id: string): Promise<Course> {
|
||||||
return fetchWithTimeout<Course>(
|
const res = await fetchWithTimeout<{ course: BackendCourse }>(
|
||||||
`${ACADEMY_API_BASE}/courses/${id}`
|
`${ACADEMY_API_BASE}/courses/${id}`
|
||||||
)
|
)
|
||||||
|
return mapCourseFromBackend(res.course)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend returns snake_case, frontend uses camelCase
|
||||||
|
interface BackendCourse {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
category: CourseCategory
|
||||||
|
duration_minutes: number
|
||||||
|
required_for_roles: string[]
|
||||||
|
is_active: boolean
|
||||||
|
lessons?: BackendLesson[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendLesson {
|
||||||
|
id: string
|
||||||
|
course_id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
lesson_type: LessonType
|
||||||
|
content_url?: string
|
||||||
|
duration_minutes: number
|
||||||
|
order_index: number
|
||||||
|
quiz_questions?: Array<{ question: string; options: string[]; correct_index: number; explanation: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCourseFromBackend(bc: BackendCourse): Course {
|
||||||
|
return {
|
||||||
|
id: bc.id,
|
||||||
|
title: bc.title,
|
||||||
|
description: bc.description || '',
|
||||||
|
category: bc.category,
|
||||||
|
durationMinutes: bc.duration_minutes || 0,
|
||||||
|
requiredForRoles: bc.required_for_roles || [],
|
||||||
|
lessons: (bc.lessons || []).map(l => ({
|
||||||
|
id: l.id,
|
||||||
|
courseId: l.course_id,
|
||||||
|
title: l.title,
|
||||||
|
type: l.lesson_type,
|
||||||
|
contentMarkdown: l.content_url || '',
|
||||||
|
durationMinutes: l.duration_minutes || 0,
|
||||||
|
order: l.order_index,
|
||||||
|
})),
|
||||||
|
createdAt: bc.created_at,
|
||||||
|
updatedAt: bc.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCoursesFromBackend(courses: BackendCourse[]): Course[] {
|
||||||
|
return courses.map(mapCourseFromBackend)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,12 +228,13 @@ export async function deleteCourse(id: string): Promise<void> {
|
|||||||
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
|
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (courseId) {
|
if (courseId) {
|
||||||
params.set('courseId', courseId)
|
params.set('course_id', courseId)
|
||||||
}
|
}
|
||||||
const queryString = params.toString()
|
const queryString = params.toString()
|
||||||
const url = `${ACADEMY_API_BASE}/enrollments${queryString ? `?${queryString}` : ''}`
|
const url = `${ACADEMY_API_BASE}/enrollments${queryString ? `?${queryString}` : ''}`
|
||||||
|
|
||||||
return fetchWithTimeout<Enrollment[]>(url)
|
const res = await fetchWithTimeout<{ enrollments: Enrollment[]; total: number }>(url)
|
||||||
|
return res.enrollments || []
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,9 +325,22 @@ export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest):
|
|||||||
* Academy-Statistiken abrufen
|
* Academy-Statistiken abrufen
|
||||||
*/
|
*/
|
||||||
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
||||||
return fetchWithTimeout<AcademyStatistics>(
|
const res = await fetchWithTimeout<{
|
||||||
`${ACADEMY_API_BASE}/statistics`
|
total_courses: number
|
||||||
)
|
total_enrollments: number
|
||||||
|
completion_rate: number
|
||||||
|
overdue_count: number
|
||||||
|
avg_completion_days: number
|
||||||
|
}>(`${ACADEMY_API_BASE}/stats`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCourses: res.total_courses || 0,
|
||||||
|
totalEnrollments: res.total_enrollments || 0,
|
||||||
|
completionRate: res.completion_rate || 0,
|
||||||
|
overdueCount: res.overdue_count || 0,
|
||||||
|
byCategory: {} as Record<CourseCategory, number>,
|
||||||
|
byStatus: {} as Record<EnrollmentStatus, number>,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -279,16 +348,27 @@ export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KI-gestuetzten Kurs generieren (nutzt intern Training Content Pipeline)
|
* Academy-Kurs aus einem Training-Modul generieren
|
||||||
*/
|
*/
|
||||||
export async function generateCourse(request: GenerateCourseRequest): Promise<Course> {
|
export async function generateCourse(request: GenerateCourseRequest): Promise<{ course: Course }> {
|
||||||
return fetchWithTimeout<Course>(
|
return fetchWithTimeout<{ course: Course }>(
|
||||||
`${ACADEMY_API_BASE}/courses/generate`,
|
`${ACADEMY_API_BASE}/courses/generate`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(request)
|
body: JSON.stringify({ module_id: request.moduleId || request.title })
|
||||||
},
|
},
|
||||||
120000 // 2 min timeout for LLM generation
|
120000 // 2 min timeout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Academy-Kurse aus Training-Modulen generieren
|
||||||
|
*/
|
||||||
|
export async function generateAllCourses(): Promise<{ generated: number; skipped: number; errors: string[]; total: number }> {
|
||||||
|
return fetchWithTimeout(
|
||||||
|
`${ACADEMY_API_BASE}/courses/generate-all`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
300000 // 5 min timeout
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,8 +216,9 @@ export interface CourseUpdateRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerateCourseRequest {
|
export interface GenerateCourseRequest {
|
||||||
title: string
|
moduleId: string
|
||||||
category: CourseCategory
|
title?: string
|
||||||
|
category?: CourseCategory
|
||||||
description?: string
|
description?: string
|
||||||
regulationArea?: string
|
regulationArea?: string
|
||||||
language?: string
|
language?: string
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func main() {
|
|||||||
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
|
||||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||||
draftingHandlers := handlers.NewDraftingHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder)
|
draftingHandlers := handlers.NewDraftingHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder)
|
||||||
academyHandlers := handlers.NewAcademyHandlers(academyStore)
|
academyHandlers := handlers.NewAcademyHandlers(academyStore, trainingStore)
|
||||||
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
|
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
|
||||||
incidentHandlers := handlers.NewIncidentHandlers(incidentStore)
|
incidentHandlers := handlers.NewIncidentHandlers(incidentStore)
|
||||||
vendorHandlers := handlers.NewVendorHandlers(vendorStore)
|
vendorHandlers := handlers.NewVendorHandlers(vendorStore)
|
||||||
@@ -483,6 +483,13 @@ func main() {
|
|||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
academyRoutes.GET("/stats", academyHandlers.GetStatistics)
|
academyRoutes.GET("/stats", academyHandlers.GetStatistics)
|
||||||
|
|
||||||
|
// Course Generation from Training Modules
|
||||||
|
academyRoutes.POST("/courses/generate", academyHandlers.GenerateCourseFromTraining)
|
||||||
|
academyRoutes.POST("/courses/generate-all", academyHandlers.GenerateAllCourses)
|
||||||
|
|
||||||
|
// Certificate PDF
|
||||||
|
academyRoutes.GET("/certificates/:id/pdf", academyHandlers.DownloadCertificatePDF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Training Engine routes - Compliance Training Content Pipeline
|
// Training Engine routes - Compliance Training Content Pipeline
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||||
|
"github.com/breakpilot/ai-compliance-sdk/internal/training"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -14,11 +17,12 @@ import (
|
|||||||
// AcademyHandlers handles academy HTTP requests
|
// AcademyHandlers handles academy HTTP requests
|
||||||
type AcademyHandlers struct {
|
type AcademyHandlers struct {
|
||||||
store *academy.Store
|
store *academy.Store
|
||||||
|
trainingStore *training.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAcademyHandlers creates new academy handlers
|
// NewAcademyHandlers creates new academy handlers
|
||||||
func NewAcademyHandlers(store *academy.Store) *AcademyHandlers {
|
func NewAcademyHandlers(store *academy.Store, trainingStore *training.Store) *AcademyHandlers {
|
||||||
return &AcademyHandlers{store: store}
|
return &AcademyHandlers{store: store, trainingStore: trainingStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -630,3 +634,288 @@ func (h *AcademyHandlers) DownloadCertificatePDF(c *gin.Context) {
|
|||||||
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf")
|
c.Header("Content-Disposition", "attachment; filename=zertifikat-"+shortID+".pdf")
|
||||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Course Generation from Training Modules
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// regulationToCategory maps training regulation areas to academy categories
|
||||||
|
var regulationToCategory = map[training.RegulationArea]academy.CourseCategory{
|
||||||
|
training.RegulationDSGVO: academy.CourseCategoryDSGVOBasics,
|
||||||
|
training.RegulationNIS2: academy.CourseCategoryITSecurity,
|
||||||
|
training.RegulationISO27001: academy.CourseCategoryITSecurity,
|
||||||
|
training.RegulationAIAct: academy.CourseCategoryAILiteracy,
|
||||||
|
training.RegulationGeschGehG: academy.CourseCategoryWhistleblowerProtection,
|
||||||
|
training.RegulationHinSchG: academy.CourseCategoryWhistleblowerProtection,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCourseFromTraining creates an academy course from a training module
|
||||||
|
// POST /sdk/v1/academy/courses/generate
|
||||||
|
func (h *AcademyHandlers) GenerateCourseFromTraining(c *gin.Context) {
|
||||||
|
if h.trainingStore == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ModuleID string `json:"module_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleID, err := uuid.Parse(req.ModuleID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid module_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
// 1. Get the training module
|
||||||
|
module, err := h.trainingStore.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": "training module not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If module already linked to an academy course, return that
|
||||||
|
if module.AcademyCourseID != nil {
|
||||||
|
existing, err := h.store.GetCourse(c.Request.Context(), *module.AcademyCourseID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"course": existing, "message": "course already exists for this module"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get generated content (if any)
|
||||||
|
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), moduleID)
|
||||||
|
|
||||||
|
// 3. Get quiz questions (if any)
|
||||||
|
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), moduleID)
|
||||||
|
|
||||||
|
// 4. Determine academy category from regulation area
|
||||||
|
category, ok := regulationToCategory[module.RegulationArea]
|
||||||
|
if !ok {
|
||||||
|
category = academy.CourseCategoryCustom
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build lessons from content + quiz
|
||||||
|
var lessons []academy.Lesson
|
||||||
|
orderIdx := 0
|
||||||
|
|
||||||
|
// Lesson 1: Text content (if generated)
|
||||||
|
if content != nil && content.ContentBody != "" {
|
||||||
|
lessons = append(lessons, academy.Lesson{
|
||||||
|
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
||||||
|
Description: content.Summary,
|
||||||
|
LessonType: academy.LessonTypeText,
|
||||||
|
ContentURL: content.ContentBody, // Store markdown in content_url for text lessons
|
||||||
|
DurationMinutes: estimateReadingTime(content.ContentBody),
|
||||||
|
OrderIndex: orderIdx,
|
||||||
|
})
|
||||||
|
orderIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lesson 2: Quiz (if questions exist)
|
||||||
|
if len(quizQuestions) > 0 {
|
||||||
|
var academyQuiz []academy.QuizQuestion
|
||||||
|
for _, q := range quizQuestions {
|
||||||
|
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
||||||
|
Question: q.Question,
|
||||||
|
Options: q.Options,
|
||||||
|
CorrectIndex: q.CorrectIndex,
|
||||||
|
Explanation: q.Explanation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lessons = append(lessons, academy.Lesson{
|
||||||
|
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
||||||
|
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
||||||
|
LessonType: academy.LessonTypeQuiz,
|
||||||
|
DurationMinutes: len(quizQuestions) * 2, // ~2 min per question
|
||||||
|
OrderIndex: orderIdx,
|
||||||
|
QuizQuestions: academyQuiz,
|
||||||
|
})
|
||||||
|
orderIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no content or quiz exists, create a placeholder
|
||||||
|
if len(lessons) == 0 {
|
||||||
|
lessons = append(lessons, academy.Lesson{
|
||||||
|
Title: module.Title,
|
||||||
|
Description: module.Description,
|
||||||
|
LessonType: academy.LessonTypeText,
|
||||||
|
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
||||||
|
DurationMinutes: module.DurationMinutes,
|
||||||
|
OrderIndex: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create the academy course
|
||||||
|
course := &academy.Course{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Title: module.Title,
|
||||||
|
Description: module.Description,
|
||||||
|
Category: category,
|
||||||
|
DurationMinutes: module.DurationMinutes,
|
||||||
|
RequiredForRoles: []string{},
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create course: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Create lessons
|
||||||
|
for i := range lessons {
|
||||||
|
lessons[i].CourseID = course.ID
|
||||||
|
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create lesson: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
course.Lessons = lessons
|
||||||
|
|
||||||
|
// 8. Link training module to academy course
|
||||||
|
if err := h.trainingStore.SetAcademyCourseID(c.Request.Context(), moduleID, course.ID); err != nil {
|
||||||
|
// Non-fatal: course is created, just not linked
|
||||||
|
fmt.Printf("Warning: failed to link training module %s to academy course %s: %v\n", moduleID, course.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"course": course})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllCourses creates academy courses for all training modules that don't have one yet
|
||||||
|
// POST /sdk/v1/academy/courses/generate-all
|
||||||
|
func (h *AcademyHandlers) GenerateAllCourses(c *gin.Context) {
|
||||||
|
if h.trainingStore == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "training store not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := rbac.GetTenantID(c)
|
||||||
|
|
||||||
|
// Get all training modules
|
||||||
|
modules, _, err := h.trainingStore.ListModules(c.Request.Context(), tenantID, &training.ModuleFilters{Limit: 100})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generated := 0
|
||||||
|
skipped := 0
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, module := range modules {
|
||||||
|
// Skip if already linked
|
||||||
|
if module.AcademyCourseID != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content and quiz
|
||||||
|
content, _ := h.trainingStore.GetLatestContent(c.Request.Context(), module.ID)
|
||||||
|
quizQuestions, _ := h.trainingStore.ListQuizQuestions(c.Request.Context(), module.ID)
|
||||||
|
|
||||||
|
category, ok := regulationToCategory[module.RegulationArea]
|
||||||
|
if !ok {
|
||||||
|
category = academy.CourseCategoryCustom
|
||||||
|
}
|
||||||
|
|
||||||
|
var lessons []academy.Lesson
|
||||||
|
orderIdx := 0
|
||||||
|
|
||||||
|
if content != nil && content.ContentBody != "" {
|
||||||
|
lessons = append(lessons, academy.Lesson{
|
||||||
|
Title: fmt.Sprintf("%s - Schulungsinhalt", module.Title),
|
||||||
|
Description: content.Summary,
|
||||||
|
LessonType: academy.LessonTypeText,
|
||||||
|
ContentURL: content.ContentBody,
|
||||||
|
DurationMinutes: estimateReadingTime(content.ContentBody),
|
||||||
|
OrderIndex: orderIdx,
|
||||||
|
})
|
||||||
|
orderIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(quizQuestions) > 0 {
|
||||||
|
var academyQuiz []academy.QuizQuestion
|
||||||
|
for _, q := range quizQuestions {
|
||||||
|
academyQuiz = append(academyQuiz, academy.QuizQuestion{
|
||||||
|
Question: q.Question,
|
||||||
|
Options: q.Options,
|
||||||
|
CorrectIndex: q.CorrectIndex,
|
||||||
|
Explanation: q.Explanation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lessons = append(lessons, academy.Lesson{
|
||||||
|
Title: fmt.Sprintf("%s - Quiz", module.Title),
|
||||||
|
Description: fmt.Sprintf("Wissenstest mit %d Fragen", len(quizQuestions)),
|
||||||
|
LessonType: academy.LessonTypeQuiz,
|
||||||
|
DurationMinutes: len(quizQuestions) * 2,
|
||||||
|
OrderIndex: orderIdx,
|
||||||
|
QuizQuestions: academyQuiz,
|
||||||
|
})
|
||||||
|
orderIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lessons) == 0 {
|
||||||
|
lessons = append(lessons, academy.Lesson{
|
||||||
|
Title: module.Title,
|
||||||
|
Description: module.Description,
|
||||||
|
LessonType: academy.LessonTypeText,
|
||||||
|
ContentURL: fmt.Sprintf("# %s\n\n%s\n\nInhalte werden noch generiert.", module.Title, module.Description),
|
||||||
|
DurationMinutes: module.DurationMinutes,
|
||||||
|
OrderIndex: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
course := &academy.Course{
|
||||||
|
TenantID: tenantID,
|
||||||
|
Title: module.Title,
|
||||||
|
Description: module.Description,
|
||||||
|
Category: category,
|
||||||
|
DurationMinutes: module.DurationMinutes,
|
||||||
|
RequiredForRoles: []string{},
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", module.ModuleCode, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range lessons {
|
||||||
|
lessons[i].CourseID = course.ID
|
||||||
|
if err := h.store.CreateLesson(c.Request.Context(), &lessons[i]); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s lesson: %v", module.ModuleCode, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.trainingStore.SetAcademyCourseID(c.Request.Context(), module.ID, course.ID)
|
||||||
|
generated++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"generated": generated,
|
||||||
|
"skipped": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"total": len(modules),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateReadingTime estimates reading time in minutes from markdown content
|
||||||
|
// Average reading speed: ~200 words per minute
|
||||||
|
func estimateReadingTime(content string) int {
|
||||||
|
words := len(strings.Fields(content))
|
||||||
|
minutes := words / 200
|
||||||
|
if minutes < 5 {
|
||||||
|
minutes = 5
|
||||||
|
}
|
||||||
|
return minutes
|
||||||
|
}
|
||||||
|
|||||||
@@ -235,6 +235,14 @@ func (s *Store) UpdateModule(ctx context.Context, module *TrainingModule) error
|
|||||||
return err
|
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, `
|
||||||
|
UPDATE training_modules SET academy_course_id = $2, updated_at = $3 WHERE id = $1
|
||||||
|
`, moduleID, courseID, time.Now().UTC())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Matrix Operations
|
// Matrix Operations
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user