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,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import { fetchSDKAcademyList } from '@/lib/sdk/academy/api'
|
||||
import { fetchSDKAcademyList, generateAllCourses } from '@/lib/sdk/academy/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -359,6 +359,8 @@ export default function AcademyPage() {
|
||||
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
||||
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [generateResult, setGenerateResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
|
||||
// Filters
|
||||
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
|
||||
@@ -366,19 +368,6 @@ export default function AcademyPage() {
|
||||
|
||||
// Load data from SDK backend
|
||||
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()
|
||||
}, [])
|
||||
|
||||
@@ -446,6 +435,36 @@ export default function AcademyPage() {
|
||||
|
||||
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 = () => {
|
||||
setSelectedCategory('all')
|
||||
setSelectedStatus('all')
|
||||
@@ -461,17 +480,54 @@ export default function AcademyPage() {
|
||||
explanation={stepInfo?.explanation}
|
||||
tips={stepInfo?.tips}
|
||||
>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Kurs erstellen
|
||||
</Link>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Kurs erstellen
|
||||
</Link>
|
||||
</div>
|
||||
</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 */}
|
||||
<TabNavigation
|
||||
tabs={tabs}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function TrainingPage() {
|
||||
setBulkResult(null)
|
||||
try {
|
||||
const result = await generateAllContent('de')
|
||||
setBulkResult(result)
|
||||
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Bulk-Generierung')
|
||||
@@ -155,7 +155,7 @@ export default function TrainingPage() {
|
||||
setBulkResult(null)
|
||||
try {
|
||||
const result = await generateAllQuizzes()
|
||||
setBulkResult(result)
|
||||
setBulkResult({ generated: result.generated ?? 0, skipped: result.skipped ?? 0, errors: result.errors ?? [] })
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
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">
|
||||
<span className="text-green-700">Generiert: {bulkResult.generated}</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>
|
||||
)}
|
||||
</div>
|
||||
{bulkResult.errors.length > 0 && (
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Connects to the ai-compliance-sdk backend via Next.js proxy
|
||||
*/
|
||||
|
||||
import {
|
||||
import type {
|
||||
Course,
|
||||
CourseCategory,
|
||||
CourseCreateRequest,
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
UpdateProgressRequest,
|
||||
Certificate,
|
||||
AcademyStatistics,
|
||||
LessonType,
|
||||
SubmitQuizRequest,
|
||||
SubmitQuizResponse,
|
||||
isEnrollmentOverdue
|
||||
} from './types'
|
||||
import { isEnrollmentOverdue } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// CONFIGURATION
|
||||
@@ -111,18 +112,72 @@ async function fetchWithTimeout<T>(
|
||||
* Alle Kurse abrufen
|
||||
*/
|
||||
export async function fetchCourses(): Promise<Course[]> {
|
||||
return fetchWithTimeout<Course[]>(
|
||||
const res = await fetchWithTimeout<{ courses: Course[]; total: number }>(
|
||||
`${ACADEMY_API_BASE}/courses`
|
||||
)
|
||||
return mapCoursesFromBackend(res.courses || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Kurs abrufen
|
||||
*/
|
||||
export async function fetchCourse(id: string): Promise<Course> {
|
||||
return fetchWithTimeout<Course>(
|
||||
const res = await fetchWithTimeout<{ course: BackendCourse }>(
|
||||
`${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[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (courseId) {
|
||||
params.set('courseId', courseId)
|
||||
params.set('course_id', courseId)
|
||||
}
|
||||
const queryString = params.toString()
|
||||
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
|
||||
*/
|
||||
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
||||
return fetchWithTimeout<AcademyStatistics>(
|
||||
`${ACADEMY_API_BASE}/statistics`
|
||||
)
|
||||
const res = await fetchWithTimeout<{
|
||||
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> {
|
||||
return fetchWithTimeout<Course>(
|
||||
export async function generateCourse(request: GenerateCourseRequest): Promise<{ course: Course }> {
|
||||
return fetchWithTimeout<{ course: Course }>(
|
||||
`${ACADEMY_API_BASE}/courses/generate`,
|
||||
{
|
||||
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 {
|
||||
title: string
|
||||
category: CourseCategory
|
||||
moduleId: string
|
||||
title?: string
|
||||
category?: CourseCategory
|
||||
description?: string
|
||||
regulationArea?: string
|
||||
language?: string
|
||||
|
||||
Reference in New Issue
Block a user