fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
129
admin-v2/app/api/admin/companion/feedback/route.ts
Normal file
129
admin-v2/app/api/admin/companion/feedback/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/admin/companion/feedback
|
||||
* Submit feedback (bug report, feature request, general feedback)
|
||||
* Proxy to backend /api/feedback
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.type || !body.title || !body.description) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: type, title, description',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate feedback type
|
||||
const validTypes = ['bug', 'feature', 'feedback']
|
||||
if (!validTypes.includes(body.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/feedback`, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// type: body.type,
|
||||
// title: body.title,
|
||||
// description: body.description,
|
||||
// screenshot: body.screenshot,
|
||||
// sessionId: body.sessionId,
|
||||
// metadata: {
|
||||
// ...body.metadata,
|
||||
// source: 'companion',
|
||||
// timestamp: new Date().toISOString(),
|
||||
// userAgent: request.headers.get('user-agent'),
|
||||
// },
|
||||
// }),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the submission
|
||||
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
console.log('Feedback received:', {
|
||||
id: feedbackId,
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
description: body.description.substring(0, 100) + '...',
|
||||
hasScreenshot: !!body.screenshot,
|
||||
sessionId: body.sessionId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Feedback submitted successfully',
|
||||
data: {
|
||||
feedbackId,
|
||||
submittedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Submit feedback error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion/feedback
|
||||
* Get feedback history (admin only)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const type = searchParams.get('type')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
// Mock response - empty list for now
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
feedback: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get feedback error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
194
admin-v2/app/api/admin/companion/lesson/route.ts
Normal file
194
admin-v2/app/api/admin/companion/lesson/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/admin/companion/lesson
|
||||
* Start a new lesson session
|
||||
* Proxy to backend /api/classroom/sessions
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify(body),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - create a new session
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const mockSession = {
|
||||
success: true,
|
||||
data: {
|
||||
sessionId,
|
||||
classId: body.classId,
|
||||
className: body.className || body.classId,
|
||||
subject: body.subject,
|
||||
topic: body.topic,
|
||||
startTime: new Date().toISOString(),
|
||||
phases: [
|
||||
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
|
||||
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
|
||||
],
|
||||
totalPlannedDuration: 50,
|
||||
currentPhaseIndex: 0,
|
||||
elapsedTime: 0,
|
||||
isPaused: false,
|
||||
pauseDuration: 0,
|
||||
overtimeMinutes: 0,
|
||||
status: 'in_progress',
|
||||
homeworkList: [],
|
||||
materials: [],
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(mockSession)
|
||||
} catch (error) {
|
||||
console.error('Start lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion/lesson
|
||||
* Get current lesson session or list of recent sessions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const url = sessionId
|
||||
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
|
||||
// : `${backendUrl}/api/classroom/sessions`
|
||||
//
|
||||
// const response = await fetch(url)
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response
|
||||
if (sessionId) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null, // No active session stored on server in mock
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [], // Empty list for now
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/companion/lesson
|
||||
* Update lesson session (timer state, phase changes, etc.)
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId, ...updates } = body
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Session ID required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates),
|
||||
// })
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the update
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Session updated',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/companion/lesson
|
||||
* End/delete a lesson session
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Session ID required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Session ended',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('End lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
admin-v2/app/api/admin/companion/route.ts
Normal file
102
admin-v2/app/api/admin/companion/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion
|
||||
* Proxy to backend /api/state/dashboard for companion dashboard data
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
// TODO: Replace with actual backend call when endpoint is available
|
||||
// const response = await fetch(`${backendUrl}/api/state/dashboard`, {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response for development
|
||||
const mockData = {
|
||||
success: true,
|
||||
data: {
|
||||
context: {
|
||||
currentPhase: 'erarbeitung',
|
||||
phaseDisplayName: 'Erarbeitung',
|
||||
},
|
||||
stats: {
|
||||
classesCount: 4,
|
||||
studentsCount: 96,
|
||||
learningUnitsCreated: 23,
|
||||
gradesEntered: 156,
|
||||
},
|
||||
phases: [
|
||||
{ id: 'einstieg', shortName: 'E', displayName: 'Einstieg', duration: 8, status: 'completed', color: '#4A90E2' },
|
||||
{ id: 'erarbeitung', shortName: 'A', displayName: 'Erarbeitung', duration: 20, status: 'active', color: '#F5A623' },
|
||||
{ id: 'sicherung', shortName: 'S', displayName: 'Sicherung', duration: 10, status: 'planned', color: '#7ED321' },
|
||||
{ id: 'transfer', shortName: 'T', displayName: 'Transfer', duration: 7, status: 'planned', color: '#9013FE' },
|
||||
{ id: 'reflexion', shortName: 'R', displayName: 'Reflexion', duration: 5, status: 'planned', color: '#6B7280' },
|
||||
],
|
||||
progress: {
|
||||
percentage: 65,
|
||||
completed: 13,
|
||||
total: 20,
|
||||
},
|
||||
suggestions: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Klausuren korrigieren',
|
||||
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
|
||||
priority: 'urgent',
|
||||
icon: 'ClipboardCheck',
|
||||
actionTarget: '/ai/klausur-korrektur',
|
||||
estimatedTime: 120,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Elternsprechtag vorbereiten',
|
||||
description: 'Notenuebersicht fuer 8b erstellen',
|
||||
priority: 'high',
|
||||
icon: 'Users',
|
||||
actionTarget: '/education/grades',
|
||||
estimatedTime: 30,
|
||||
},
|
||||
],
|
||||
upcomingEvents: [
|
||||
{
|
||||
id: 'e1',
|
||||
title: 'Mathe-Test 9b',
|
||||
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
type: 'exam',
|
||||
inDays: 2,
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
title: 'Elternsprechtag',
|
||||
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
type: 'parent_meeting',
|
||||
inDays: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(mockData)
|
||||
} catch (error) {
|
||||
console.error('Companion dashboard error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
admin-v2/app/api/admin/companion/settings/route.ts
Normal file
137
admin-v2/app/api/admin/companion/settings/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
defaultPhaseDurations: {
|
||||
einstieg: 8,
|
||||
erarbeitung: 20,
|
||||
sicherung: 10,
|
||||
transfer: 7,
|
||||
reflexion: 5,
|
||||
},
|
||||
preferredLessonLength: 45,
|
||||
autoAdvancePhases: true,
|
||||
soundNotifications: true,
|
||||
showKeyboardShortcuts: true,
|
||||
highContrastMode: false,
|
||||
onboardingCompleted: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion/settings
|
||||
* Get teacher settings
|
||||
* Proxy to backend /api/teacher/settings
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - return default settings
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: DEFAULT_SETTINGS,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/companion/settings
|
||||
* Update teacher settings
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the settings structure
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid settings data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
|
||||
// method: 'PUT',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// body: JSON.stringify(body),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the save
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Settings saved',
|
||||
data: body,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/companion/settings
|
||||
* Partially update teacher settings
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Settings updated',
|
||||
data: body,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
215
admin-v2/app/api/ai/rag-pipeline/route.ts
Normal file
215
admin-v2/app/api/ai/rag-pipeline/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Backend URL - klausur-service
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Helper to proxy requests to backend
|
||||
async function proxyRequest(
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
body?: any
|
||||
): Promise<Response> {
|
||||
const url = `${KLAUSUR_SERVICE_URL}${endpoint}`
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
switch (action) {
|
||||
case 'jobs':
|
||||
response = await proxyRequest('/api/v1/admin/training/jobs')
|
||||
break
|
||||
|
||||
case 'job':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`)
|
||||
break
|
||||
|
||||
case 'models':
|
||||
response = await proxyRequest('/api/v1/admin/training/models')
|
||||
break
|
||||
|
||||
case 'model':
|
||||
if (!versionId) {
|
||||
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`)
|
||||
break
|
||||
|
||||
case 'dataset-stats':
|
||||
response = await proxyRequest('/api/v1/admin/training/dataset/stats')
|
||||
break
|
||||
|
||||
case 'status':
|
||||
response = await proxyRequest('/api/v1/admin/training/status')
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unknown action',
|
||||
validActions: ['jobs', 'job', 'models', 'model', 'dataset-stats', 'status']
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Backend error: ${response.status} - ${errorText}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', detail: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
let body: any = null
|
||||
|
||||
// Parse body if present
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text) {
|
||||
body = JSON.parse(text)
|
||||
}
|
||||
} catch {
|
||||
// No body or invalid JSON
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'create-job':
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Body required for job creation' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest('/api/v1/admin/training/jobs', 'POST', body)
|
||||
break
|
||||
|
||||
case 'pause':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/pause`, 'POST')
|
||||
break
|
||||
|
||||
case 'resume':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/resume`, 'POST')
|
||||
break
|
||||
|
||||
case 'cancel':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/cancel`, 'POST')
|
||||
break
|
||||
|
||||
case 'activate-model':
|
||||
if (!versionId) {
|
||||
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}/activate`, 'POST')
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unknown action',
|
||||
validActions: ['create-job', 'pause', 'resume', 'cancel', 'activate-model']
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
if (jobId) {
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`, 'DELETE')
|
||||
} else if (versionId) {
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`, 'DELETE')
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either job_id or version_id required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
68
admin-v2/app/api/development/content/route.ts
Normal file
68
admin-v2/app/api/development/content/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Content API Route
|
||||
*
|
||||
* GET: Load current website content
|
||||
* POST: Save changed content (Admin only)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getContent, saveContent } from '@/lib/content'
|
||||
import type { WebsiteContent } from '@/lib/content-types'
|
||||
|
||||
// GET - Load content
|
||||
export async function GET() {
|
||||
try {
|
||||
const content = getContent()
|
||||
return NextResponse.json(content)
|
||||
} catch (error) {
|
||||
console.error('Error loading content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Save content
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Simple admin check via header or query
|
||||
// In production: JWT/Session-based auth
|
||||
const adminKey = request.headers.get('x-admin-key')
|
||||
const expectedKey = process.env.ADMIN_API_KEY || 'breakpilot-admin-2024'
|
||||
|
||||
if (adminKey !== expectedKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const content: WebsiteContent = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!content.hero || !content.features || !content.faq || !content.pricing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid content structure' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = saveContent(content)
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true, message: 'Content saved' })
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to save content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to save content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
246
admin-v2/app/api/education/abitur-archiv/route.ts
Normal file
246
admin-v2/app/api/education/abitur-archiv/route.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Abitur-Archiv API Route
|
||||
* Extends abitur-docs with theme search and enhanced filtering
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Check for theme/semantic search
|
||||
const thema = searchParams.get('thema')
|
||||
|
||||
if (thema) {
|
||||
// Use semantic search endpoint
|
||||
return await handleSemanticSearch(thema, searchParams)
|
||||
}
|
||||
|
||||
// Forward all query params to backend abitur-docs
|
||||
const queryString = searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Return mock data for development if backend is not available
|
||||
if (response.status === 404 || response.status === 502) {
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
throw new Error(`Backend responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// If backend returns empty, use mock data for demo
|
||||
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
|
||||
// Enhance response with theme information
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
themes: extractThemes(data.documents || [])
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Abitur-Archiv error:', error)
|
||||
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSemanticSearch(thema: string, searchParams: URLSearchParams) {
|
||||
try {
|
||||
// Try to call RAG search endpoint
|
||||
const url = `${BACKEND_URL}/api/rag/search`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: thema,
|
||||
collection: 'abitur_documents',
|
||||
limit: parseInt(searchParams.get('limit') || '20'),
|
||||
filters: {
|
||||
fach: searchParams.get('fach') || undefined,
|
||||
jahr: searchParams.get('jahr') ? parseInt(searchParams.get('jahr')!) : undefined,
|
||||
bundesland: searchParams.get('bundesland') || undefined,
|
||||
niveau: searchParams.get('niveau') || undefined,
|
||||
typ: searchParams.get('typ') || undefined,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
documents: data.results || [],
|
||||
total: data.total || 0,
|
||||
page: 1,
|
||||
limit: parseInt(searchParams.get('limit') || '20'),
|
||||
total_pages: 1,
|
||||
search_query: thema
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('RAG search not available, falling back to mock')
|
||||
}
|
||||
|
||||
// Fallback to filtered mock data
|
||||
return NextResponse.json(getMockDocumentsWithTheme(thema, searchParams))
|
||||
}
|
||||
|
||||
function getMockDocuments(searchParams: URLSearchParams) {
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const fach = searchParams.get('fach')
|
||||
const jahr = searchParams.get('jahr')
|
||||
const bundesland = searchParams.get('bundesland')
|
||||
const niveau = searchParams.get('niveau')
|
||||
const typ = searchParams.get('typ')
|
||||
|
||||
// Generate mock documents
|
||||
const allDocs = generateMockDocs()
|
||||
|
||||
// Apply filters
|
||||
let filtered = allDocs
|
||||
if (fach) filtered = filtered.filter(d => d.fach === fach)
|
||||
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
|
||||
if (bundesland) filtered = filtered.filter(d => d.bundesland === bundesland)
|
||||
if (niveau) filtered = filtered.filter(d => d.niveau === niveau)
|
||||
if (typ) filtered = filtered.filter(d => d.typ === typ)
|
||||
|
||||
// Paginate
|
||||
const start = (page - 1) * limit
|
||||
const docs = filtered.slice(start, start + limit)
|
||||
|
||||
return {
|
||||
documents: docs,
|
||||
total: filtered.length,
|
||||
page,
|
||||
limit,
|
||||
total_pages: Math.ceil(filtered.length / limit),
|
||||
themes: extractThemes(docs)
|
||||
}
|
||||
}
|
||||
|
||||
function getMockDocumentsWithTheme(thema: string, searchParams: URLSearchParams) {
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const allDocs = generateMockDocs()
|
||||
|
||||
// Simple theme matching (in production this would be semantic search)
|
||||
const themaLower = thema.toLowerCase()
|
||||
let filtered = allDocs
|
||||
|
||||
// Match theme to aufgabentyp keywords
|
||||
if (themaLower.includes('gedicht')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('gedichtanalyse'))
|
||||
} else if (themaLower.includes('drama')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('dramenanalyse'))
|
||||
} else if (themaLower.includes('prosa') || themaLower.includes('roman')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('prosaanalyse'))
|
||||
} else if (themaLower.includes('eroerterung')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('eroerterung'))
|
||||
} else if (themaLower.includes('text') || themaLower.includes('analyse')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('textanalyse'))
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
const fach = searchParams.get('fach')
|
||||
const jahr = searchParams.get('jahr')
|
||||
if (fach) filtered = filtered.filter(d => d.fach === fach)
|
||||
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
|
||||
|
||||
return {
|
||||
documents: filtered.slice(0, limit),
|
||||
total: filtered.length,
|
||||
page: 1,
|
||||
limit,
|
||||
total_pages: Math.ceil(filtered.length / limit),
|
||||
search_query: thema,
|
||||
themes: extractThemes(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockDocs() {
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
const aufgabentypen = [
|
||||
{ nummer: 'I', themes: ['textanalyse', 'sachtext'] },
|
||||
{ nummer: 'II', themes: ['gedichtanalyse', 'lyrik'] },
|
||||
{ nummer: 'III', themes: ['prosaanalyse', 'epik'] },
|
||||
]
|
||||
|
||||
const docs = []
|
||||
let id = 1
|
||||
|
||||
for (const jahr of jahre) {
|
||||
for (const fach of faecher) {
|
||||
for (const niveau of niveaus) {
|
||||
for (const aufgabe of aufgabentypen) {
|
||||
for (const typ of typen) {
|
||||
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
|
||||
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_Aufgabe_${aufgabe.nummer}${suffix}.pdf`
|
||||
|
||||
docs.push({
|
||||
id: `doc-${id++}`,
|
||||
dateiname,
|
||||
original_dateiname: dateiname,
|
||||
bundesland: 'niedersachsen',
|
||||
fach,
|
||||
jahr,
|
||||
niveau,
|
||||
typ,
|
||||
aufgaben_nummer: aufgabe.nummer,
|
||||
themes: aufgabe.themes,
|
||||
status: 'indexed' as const,
|
||||
confidence: 0.92 + Math.random() * 0.08,
|
||||
file_path: `/api/education/abitur-archiv/file/${dateiname}`,
|
||||
file_size: Math.floor(Math.random() * 500000) + 100000,
|
||||
indexed: true,
|
||||
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
|
||||
created_at: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs
|
||||
}
|
||||
|
||||
function extractThemes(documents: any[]) {
|
||||
const themeCounts = new Map<string, number>()
|
||||
|
||||
for (const doc of documents) {
|
||||
const themes = doc.themes || []
|
||||
for (const theme of themes) {
|
||||
themeCounts.set(theme, (themeCounts.get(theme) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(themeCounts.entries())
|
||||
.map(([label, count]) => ({
|
||||
label: capitalize(label),
|
||||
count,
|
||||
aufgabentyp: label,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
105
admin-v2/app/api/education/abitur-archiv/suggest/route.ts
Normal file
105
admin-v2/app/api/education/abitur-archiv/suggest/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Theme Suggestions API for Abitur-Archiv
|
||||
* Returns autocomplete suggestions for semantic search
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('q') || ''
|
||||
|
||||
if (query.length < 2) {
|
||||
return NextResponse.json({ suggestions: [], query })
|
||||
}
|
||||
|
||||
// Try to get suggestions from backend
|
||||
try {
|
||||
const url = `${BACKEND_URL}/api/abitur-archiv/suggest?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Backend suggest not available, using static suggestions')
|
||||
}
|
||||
|
||||
// Fallback to static suggestions
|
||||
return NextResponse.json({
|
||||
suggestions: getStaticSuggestions(query),
|
||||
query
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
return NextResponse.json({ suggestions: [], query: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function getStaticSuggestions(query: string) {
|
||||
const allSuggestions = [
|
||||
// Textanalyse
|
||||
{ label: 'Textanalyse', count: 45, aufgabentyp: 'textanalyse', kategorie: 'Analyse' },
|
||||
{ label: 'Textanalyse Sachtext', count: 28, aufgabentyp: 'textanalyse_pragmatisch', kategorie: 'Analyse' },
|
||||
{ label: 'Textanalyse Rede', count: 12, aufgabentyp: 'textanalyse_rede', kategorie: 'Analyse' },
|
||||
{ label: 'Textanalyse Kommentar', count: 8, aufgabentyp: 'textanalyse_kommentar', kategorie: 'Analyse' },
|
||||
|
||||
// Gedichtanalyse
|
||||
{ label: 'Gedichtanalyse', count: 38, aufgabentyp: 'gedichtanalyse', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Romantik', count: 15, aufgabentyp: 'gedichtanalyse', zeitraum: 'Romantik', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Expressionismus', count: 12, aufgabentyp: 'gedichtanalyse', zeitraum: 'Expressionismus', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Barock', count: 8, aufgabentyp: 'gedichtanalyse', zeitraum: 'Barock', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Klassik', count: 10, aufgabentyp: 'gedichtanalyse', zeitraum: 'Klassik', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Moderne', count: 14, aufgabentyp: 'gedichtanalyse', zeitraum: 'Moderne', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtvergleich', count: 18, aufgabentyp: 'gedichtvergleich', kategorie: 'Lyrik' },
|
||||
|
||||
// Dramenanalyse
|
||||
{ label: 'Dramenanalyse', count: 28, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Dramenanalyse Faust', count: 14, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Dramenanalyse Woyzeck', count: 8, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Episches Theater Brecht', count: 10, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Szenenanalyse', count: 22, aufgabentyp: 'szenenanalyse', kategorie: 'Drama' },
|
||||
|
||||
// Prosaanalyse
|
||||
{ label: 'Prosaanalyse', count: 25, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Romananalyse', count: 18, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Kurzgeschichte', count: 20, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Novelle', count: 12, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Erzaehlung', count: 15, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
|
||||
// Eroerterung
|
||||
{ label: 'Eroerterung', count: 32, aufgabentyp: 'eroerterung', kategorie: 'Argumentation' },
|
||||
{ label: 'Eroerterung textgebunden', count: 18, aufgabentyp: 'eroerterung_textgebunden', kategorie: 'Argumentation' },
|
||||
{ label: 'Eroerterung materialgestuetzt', count: 14, aufgabentyp: 'eroerterung_materialgestuetzt', kategorie: 'Argumentation' },
|
||||
{ label: 'Stellungnahme', count: 10, aufgabentyp: 'stellungnahme', kategorie: 'Argumentation' },
|
||||
|
||||
// Sprachreflexion
|
||||
{ label: 'Sprachreflexion', count: 15, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
|
||||
{ label: 'Sprachwandel', count: 8, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
|
||||
{ label: 'Sprachkritik', count: 6, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
|
||||
{ label: 'Kommunikation', count: 10, aufgabentyp: 'kommunikation', kategorie: 'Sprache' },
|
||||
|
||||
// Vergleich
|
||||
{ label: 'Vergleichende Analyse', count: 20, aufgabentyp: 'vergleich', kategorie: 'Vergleich' },
|
||||
{ label: 'Epochenvergleich', count: 12, aufgabentyp: 'epochenvergleich', kategorie: 'Vergleich' },
|
||||
]
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
// Filter suggestions based on query
|
||||
return allSuggestions
|
||||
.filter(s =>
|
||||
s.label.toLowerCase().includes(queryLower) ||
|
||||
s.aufgabentyp.toLowerCase().includes(queryLower) ||
|
||||
(s.zeitraum && s.zeitraum.toLowerCase().includes(queryLower)) ||
|
||||
s.kategorie.toLowerCase().includes(queryLower)
|
||||
)
|
||||
.slice(0, 8)
|
||||
}
|
||||
139
admin-v2/app/api/education/abitur-docs/route.ts
Normal file
139
admin-v2/app/api/education/abitur-docs/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Proxy to backend /api/abitur-docs
|
||||
* Lists and manages Abitur documents (NiBiS, etc.)
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Forward all query params to backend
|
||||
const queryString = searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Return mock data for development if backend is not available
|
||||
if (response.status === 404 || response.status === 502) {
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
throw new Error(`Backend responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// If backend returns empty array, use mock data for demo purposes
|
||||
// (Backend uses in-memory storage which is lost on restart)
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
console.log('Backend returned empty array, using mock data')
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
|
||||
// Handle paginated response with empty documents
|
||||
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
|
||||
console.log('Backend returned empty documents, using mock data')
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Abitur docs list error:', error)
|
||||
// Return mock data for development
|
||||
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
|
||||
}
|
||||
}
|
||||
|
||||
function getMockDocuments(searchParams: URLSearchParams) {
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const fach = searchParams.get('fach')
|
||||
const jahr = searchParams.get('jahr')
|
||||
const bundesland = searchParams.get('bundesland')
|
||||
|
||||
// Generate mock documents
|
||||
const allDocs = generateMockDocs()
|
||||
|
||||
// Apply filters
|
||||
let filtered = allDocs
|
||||
if (fach) {
|
||||
filtered = filtered.filter(d => d.fach === fach)
|
||||
}
|
||||
if (jahr) {
|
||||
filtered = filtered.filter(d => d.jahr === parseInt(jahr))
|
||||
}
|
||||
if (bundesland) {
|
||||
filtered = filtered.filter(d => d.bundesland === bundesland)
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const start = (page - 1) * limit
|
||||
const docs = filtered.slice(start, start + limit)
|
||||
|
||||
return {
|
||||
documents: docs,
|
||||
total: filtered.length,
|
||||
page,
|
||||
limit,
|
||||
total_pages: Math.ceil(filtered.length / limit),
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockDocs() {
|
||||
const faecher = ['deutsch', 'mathematik', 'englisch', 'biologie', 'physik', 'chemie', 'geschichte']
|
||||
const jahre = [2024, 2025]
|
||||
const niveaus = ['eA', 'gA']
|
||||
const typen = ['aufgabe', 'erwartungshorizont']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
|
||||
const docs = []
|
||||
let id = 1
|
||||
|
||||
for (const jahr of jahre) {
|
||||
for (const fach of faecher) {
|
||||
for (const niveau of niveaus) {
|
||||
for (const nummer of nummern) {
|
||||
for (const typ of typen) {
|
||||
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
|
||||
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_${nummer}${suffix}.pdf`
|
||||
|
||||
docs.push({
|
||||
id: `doc-${id++}`,
|
||||
dateiname,
|
||||
original_dateiname: dateiname,
|
||||
bundesland: 'niedersachsen',
|
||||
fach,
|
||||
jahr,
|
||||
niveau,
|
||||
typ,
|
||||
aufgaben_nummer: nummer,
|
||||
status: 'indexed',
|
||||
confidence: 0.95,
|
||||
file_path: `/tmp/abitur-docs/${dateiname}`,
|
||||
file_size: Math.floor(Math.random() * 500000) + 100000,
|
||||
indexed: true,
|
||||
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
|
||||
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs
|
||||
}
|
||||
|
||||
function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
395
admin-v2/app/api/infrastructure/log-extract/extract/route.ts
Normal file
395
admin-v2/app/api/infrastructure/log-extract/extract/route.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { ExtractedError, ErrorCategory, LogExtractionResponse } from '@/types/infrastructure-modules'
|
||||
|
||||
// Woodpecker API configuration
|
||||
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
|
||||
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
|
||||
|
||||
// =============================================================================
|
||||
// Error Pattern Matching
|
||||
// =============================================================================
|
||||
|
||||
interface ErrorPattern {
|
||||
pattern: RegExp
|
||||
category: ErrorCategory
|
||||
extractMessage?: (match: RegExpMatchArray, line: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns fuer verschiedene Fehlertypen in CI/CD Logs
|
||||
*/
|
||||
const ERROR_PATTERNS: ErrorPattern[] = [
|
||||
// Test Failures
|
||||
{
|
||||
pattern: /^(FAIL|FAILED|ERROR):?\s+(.+)$/i,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match, line) => match[2] || line,
|
||||
},
|
||||
{
|
||||
pattern: /^---\s+FAIL:\s+(.+)\s+\([\d.]+s\)$/,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match) => `Test failed: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /pytest.*FAILED\s+(.+)$/,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match) => `pytest: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /AssertionError:\s+(.+)$/,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match) => `Assertion failed: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /FAIL\s+[\w\/]+\s+\[build failed\]/,
|
||||
category: 'build_error',
|
||||
},
|
||||
|
||||
// Build Errors
|
||||
{
|
||||
pattern: /^(error|Error)\[[\w-]+\]:\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => match[2],
|
||||
},
|
||||
{
|
||||
pattern: /cannot find (module|package)\s+["'](.+)["']/i,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `Missing ${match[1]}: ${match[2]}`,
|
||||
},
|
||||
{
|
||||
pattern: /undefined:\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `Undefined: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /compilation failed/i,
|
||||
category: 'build_error',
|
||||
},
|
||||
{
|
||||
pattern: /npm ERR!\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `npm error: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /go:\s+(.+):\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `Go: ${match[1]}: ${match[2]}`,
|
||||
},
|
||||
|
||||
// Security Warnings
|
||||
{
|
||||
pattern: /\[CRITICAL\]\s+(.+)$/i,
|
||||
category: 'security_warning',
|
||||
extractMessage: (match) => `Critical: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /\[HIGH\]\s+(.+)$/i,
|
||||
category: 'security_warning',
|
||||
extractMessage: (match) => `High severity: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /CVE-\d{4}-\d+/,
|
||||
category: 'security_warning',
|
||||
extractMessage: (match, line) => line.trim(),
|
||||
},
|
||||
{
|
||||
pattern: /vulnerability found/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
{
|
||||
pattern: /secret.*detected/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
{
|
||||
pattern: /gitleaks.*found/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
{
|
||||
pattern: /semgrep.*finding/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
|
||||
// License Violations
|
||||
{
|
||||
pattern: /license.*violation/i,
|
||||
category: 'license_violation',
|
||||
},
|
||||
{
|
||||
pattern: /incompatible license/i,
|
||||
category: 'license_violation',
|
||||
},
|
||||
{
|
||||
pattern: /AGPL|GPL-3|SSPL/,
|
||||
category: 'license_violation',
|
||||
extractMessage: (match, line) => `Potentially problematic license found: ${match[0]}`,
|
||||
},
|
||||
|
||||
// Dependency Issues
|
||||
{
|
||||
pattern: /dependency.*not found/i,
|
||||
category: 'dependency_issue',
|
||||
},
|
||||
{
|
||||
pattern: /outdated.*dependency/i,
|
||||
category: 'dependency_issue',
|
||||
},
|
||||
{
|
||||
pattern: /version conflict/i,
|
||||
category: 'dependency_issue',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns to extract file paths from error lines
|
||||
*/
|
||||
const FILE_PATH_PATTERNS = [
|
||||
/([\/\w.-]+\.(go|py|ts|tsx|js|jsx|rs)):(\d+)/,
|
||||
/File "([^"]+)", line (\d+)/,
|
||||
/at ([\/\w.-]+):(\d+):\d+/,
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns to extract service names from log lines or paths
|
||||
*/
|
||||
const SERVICE_PATTERNS = [
|
||||
/service[s]?\/([a-z-]+)/i,
|
||||
/\/([a-z-]+-service)\//i,
|
||||
/^([a-z-]+):\s/,
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// Log Parsing Functions
|
||||
// =============================================================================
|
||||
|
||||
interface LogLine {
|
||||
pos: number
|
||||
out: string
|
||||
time: number
|
||||
}
|
||||
|
||||
function extractFilePath(line: string): { path?: string; lineNumber?: number } {
|
||||
for (const pattern of FILE_PATH_PATTERNS) {
|
||||
const match = line.match(pattern)
|
||||
if (match) {
|
||||
return {
|
||||
path: match[1],
|
||||
lineNumber: parseInt(match[2] || match[3], 10) || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function extractService(line: string, filePath?: string): string | undefined {
|
||||
// First try to extract from file path
|
||||
if (filePath) {
|
||||
for (const pattern of SERVICE_PATTERNS) {
|
||||
const match = filePath.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Then try from the line itself
|
||||
for (const pattern of SERVICE_PATTERNS) {
|
||||
const match = line.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseLogLines(logs: LogLine[], stepName: string): ExtractedError[] {
|
||||
const errors: ExtractedError[] = []
|
||||
const seenMessages = new Set<string>()
|
||||
|
||||
for (const logLine of logs) {
|
||||
const line = logLine.out.trim()
|
||||
if (!line) continue
|
||||
|
||||
for (const errorPattern of ERROR_PATTERNS) {
|
||||
const match = line.match(errorPattern.pattern)
|
||||
if (match) {
|
||||
const message = errorPattern.extractMessage
|
||||
? errorPattern.extractMessage(match, line)
|
||||
: line
|
||||
|
||||
// Deduplicate similar errors
|
||||
const messageKey = `${errorPattern.category}:${message.substring(0, 100)}`
|
||||
if (seenMessages.has(messageKey)) continue
|
||||
seenMessages.add(messageKey)
|
||||
|
||||
const fileInfo = extractFilePath(line)
|
||||
const service = extractService(line, fileInfo.path)
|
||||
|
||||
errors.push({
|
||||
step: stepName,
|
||||
line: logLine.pos,
|
||||
message,
|
||||
category: errorPattern.category,
|
||||
file_path: fileInfo.path,
|
||||
service,
|
||||
})
|
||||
|
||||
break // Only match first pattern per line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/infrastructure/logs/extract
|
||||
*
|
||||
* Extrahiert Fehler aus Woodpecker Pipeline Logs.
|
||||
*
|
||||
* Request Body:
|
||||
* - pipeline_number: number (required)
|
||||
* - repo_id?: string (default: '1')
|
||||
*
|
||||
* Response:
|
||||
* - errors: ExtractedError[]
|
||||
* - pipeline_number: number
|
||||
* - extracted_at: string
|
||||
* - lines_parsed: number
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { pipeline_number, repo_id = '1' } = body
|
||||
|
||||
if (!pipeline_number) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pipeline_number ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Fetch pipeline details to get step IDs
|
||||
const pipelineResponse = await fetch(
|
||||
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
|
||||
if (!pipelineResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Pipeline ${pipeline_number} nicht gefunden` },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const pipeline = await pipelineResponse.json()
|
||||
|
||||
// 2. Extract step IDs from workflows
|
||||
const failedSteps: { id: number; name: string }[] = []
|
||||
|
||||
if (pipeline.workflows) {
|
||||
for (const workflow of pipeline.workflows) {
|
||||
if (workflow.children) {
|
||||
for (const child of workflow.children) {
|
||||
if (child.state === 'failure' || child.state === 'error') {
|
||||
failedSteps.push({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch logs for each failed step
|
||||
const allErrors: ExtractedError[] = []
|
||||
let totalLinesParsed = 0
|
||||
|
||||
for (const step of failedSteps) {
|
||||
try {
|
||||
const logsResponse = await fetch(
|
||||
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}/logs/${step.id}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (logsResponse.ok) {
|
||||
const logs: LogLine[] = await logsResponse.json()
|
||||
totalLinesParsed += logs.length
|
||||
|
||||
const stepErrors = parseLogLines(logs, step.name)
|
||||
allErrors.push(...stepErrors)
|
||||
}
|
||||
} catch (logError) {
|
||||
console.error(`Failed to fetch logs for step ${step.name}:`, logError)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Sort errors by severity (security > license > build > test > dependency)
|
||||
const categoryPriority: Record<ErrorCategory, number> = {
|
||||
'security_warning': 1,
|
||||
'license_violation': 2,
|
||||
'build_error': 3,
|
||||
'test_failure': 4,
|
||||
'dependency_issue': 5,
|
||||
}
|
||||
|
||||
allErrors.sort((a, b) => categoryPriority[a.category] - categoryPriority[b.category])
|
||||
|
||||
const response: LogExtractionResponse = {
|
||||
errors: allErrors,
|
||||
pipeline_number,
|
||||
extracted_at: new Date().toISOString(),
|
||||
lines_parsed: totalLinesParsed,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Log extraction error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Fehler bei der Log-Extraktion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/infrastructure/logs/extract?pipeline_number=123
|
||||
*
|
||||
* Convenience method - calls POST internally
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const pipeline_number = searchParams.get('pipeline_number')
|
||||
const repo_id = searchParams.get('repo_id') || '1'
|
||||
|
||||
if (!pipeline_number) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pipeline_number Query-Parameter ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create a mock request with JSON body
|
||||
const mockRequest = new NextRequest(request.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pipeline_number: parseInt(pipeline_number, 10), repo_id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
return POST(mockRequest)
|
||||
}
|
||||
@@ -9,18 +9,10 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Checkpoint definitions
|
||||
const CHECKPOINTS = {
|
||||
'CP-PROF': {
|
||||
id: 'CP-PROF',
|
||||
step: 'company-profile',
|
||||
name: 'Unternehmensprofil Checkpoint',
|
||||
type: 'REQUIRED',
|
||||
blocksProgress: true,
|
||||
requiresReview: 'NONE',
|
||||
},
|
||||
'CP-UC': {
|
||||
id: 'CP-UC',
|
||||
step: 'use-case-assessment',
|
||||
name: 'Anwendungsfall Checkpoint',
|
||||
step: 'use-case-workshop',
|
||||
name: 'Use Case Checkpoint',
|
||||
type: 'REQUIRED',
|
||||
blocksProgress: true,
|
||||
requiresReview: 'NONE',
|
||||
|
||||
@@ -10,15 +10,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_STEPS = [
|
||||
// Phase 1
|
||||
{ id: 'company-profile', phase: 1, order: 1, name: 'Unternehmensprofil', url: '/sdk/company-profile' },
|
||||
{ id: 'use-case-assessment', phase: 1, order: 2, name: 'Anwendungsfall-Erfassung', url: '/sdk/advisory-board' },
|
||||
{ id: 'screening', phase: 1, order: 3, name: 'System Screening', url: '/sdk/screening' },
|
||||
{ id: 'modules', phase: 1, order: 4, name: 'Compliance Modules', url: '/sdk/modules' },
|
||||
{ id: 'requirements', phase: 1, order: 5, name: 'Requirements', url: '/sdk/requirements' },
|
||||
{ id: 'controls', phase: 1, order: 6, name: 'Controls', url: '/sdk/controls' },
|
||||
{ id: 'evidence', phase: 1, order: 7, name: 'Evidence', url: '/sdk/evidence' },
|
||||
{ id: 'audit-checklist', phase: 1, order: 8, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
|
||||
{ id: 'risks', phase: 1, order: 9, name: 'Risk Matrix', url: '/sdk/risks' },
|
||||
{ id: 'use-case-workshop', phase: 1, order: 1, name: 'Use Case Workshop', url: '/sdk/advisory-board' },
|
||||
{ id: 'screening', phase: 1, order: 2, name: 'System Screening', url: '/sdk/screening' },
|
||||
{ id: 'modules', phase: 1, order: 3, name: 'Compliance Modules', url: '/sdk/modules' },
|
||||
{ id: 'requirements', phase: 1, order: 4, name: 'Requirements', url: '/sdk/requirements' },
|
||||
{ id: 'controls', phase: 1, order: 5, name: 'Controls', url: '/sdk/controls' },
|
||||
{ id: 'evidence', phase: 1, order: 6, name: 'Evidence', url: '/sdk/evidence' },
|
||||
{ id: 'audit-checklist', phase: 1, order: 7, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
|
||||
{ id: 'risks', phase: 1, order: 8, name: 'Risk Matrix', url: '/sdk/risks' },
|
||||
// Phase 2
|
||||
{ id: 'ai-act', phase: 2, order: 1, name: 'AI Act Klassifizierung', url: '/sdk/ai-act' },
|
||||
{ id: 'obligations', phase: 2, order: 2, name: 'Pflichtenübersicht', url: '/sdk/obligations' },
|
||||
@@ -56,7 +55,7 @@ function getPreviousStep(currentStepId: string) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const currentStepId = searchParams.get('currentStep') || 'company-profile'
|
||||
const currentStepId = searchParams.get('currentStep') || 'use-case-workshop'
|
||||
|
||||
const currentStep = SDK_STEPS.find(s => s.id === currentStepId)
|
||||
const nextStep = getNextStep(currentStepId)
|
||||
|
||||
273
admin-v2/app/api/webhooks/woodpecker/route.ts
Normal file
273
admin-v2/app/api/webhooks/woodpecker/route.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { WoodpeckerWebhookPayload, ExtractedError, BacklogSource } from '@/types/infrastructure-modules'
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
// Webhook secret for verification (optional but recommended)
|
||||
const WEBHOOK_SECRET = process.env.WOODPECKER_WEBHOOK_SECRET || ''
|
||||
|
||||
// Internal API URL for log extraction
|
||||
const LOG_EXTRACT_URL = process.env.NEXT_PUBLIC_APP_URL
|
||||
? `${process.env.NEXT_PUBLIC_APP_URL}/api/infrastructure/log-extract/extract`
|
||||
: 'http://localhost:3002/api/infrastructure/log-extract/extract'
|
||||
|
||||
// Test service API URL for backlog insertion
|
||||
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Verify webhook signature (if secret is configured)
|
||||
*/
|
||||
function verifySignature(request: NextRequest, body: string): boolean {
|
||||
if (!WEBHOOK_SECRET) return true // Skip verification if no secret configured
|
||||
|
||||
const signature = request.headers.get('X-Woodpecker-Signature')
|
||||
if (!signature) return false
|
||||
|
||||
// Simple HMAC verification (Woodpecker uses SHA256)
|
||||
const crypto = require('crypto')
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
|
||||
return signature === `sha256=${expectedSignature}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error category to backlog priority
|
||||
*/
|
||||
function categoryToPriority(category: string): 'critical' | 'high' | 'medium' | 'low' {
|
||||
switch (category) {
|
||||
case 'security_warning':
|
||||
return 'critical'
|
||||
case 'build_error':
|
||||
return 'high'
|
||||
case 'license_violation':
|
||||
return 'high'
|
||||
case 'test_failure':
|
||||
return 'medium'
|
||||
case 'dependency_issue':
|
||||
return 'low'
|
||||
default:
|
||||
return 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error category to error_type for backlog
|
||||
*/
|
||||
function categoryToErrorType(category: string): string {
|
||||
switch (category) {
|
||||
case 'security_warning':
|
||||
return 'security'
|
||||
case 'build_error':
|
||||
return 'build'
|
||||
case 'license_violation':
|
||||
return 'license'
|
||||
case 'test_failure':
|
||||
return 'test'
|
||||
case 'dependency_issue':
|
||||
return 'dependency'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert extracted errors into backlog
|
||||
*/
|
||||
async function insertIntoBacklog(
|
||||
errors: ExtractedError[],
|
||||
pipelineNumber: number,
|
||||
source: BacklogSource
|
||||
): Promise<{ inserted: number; failed: number }> {
|
||||
let inserted = 0
|
||||
let failed = 0
|
||||
|
||||
for (const error of errors) {
|
||||
try {
|
||||
// Create backlog item
|
||||
const backlogItem = {
|
||||
test_name: error.message.substring(0, 200), // Truncate long messages
|
||||
test_file: error.file_path || null,
|
||||
service: error.service || 'unknown',
|
||||
framework: `ci_cd_pipeline_${pipelineNumber}`,
|
||||
error_message: error.message,
|
||||
error_type: categoryToErrorType(error.category),
|
||||
status: 'open',
|
||||
priority: categoryToPriority(error.category),
|
||||
fix_suggestion: error.suggested_fix || null,
|
||||
notes: `Auto-generated from pipeline #${pipelineNumber}, step: ${error.step}, line: ${error.line}`,
|
||||
source, // Custom field to track origin
|
||||
}
|
||||
|
||||
// Try to insert into test service backlog
|
||||
const response = await fetch(`${TEST_SERVICE_URL}/api/v1/backlog`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(backlogItem),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
inserted++
|
||||
} else {
|
||||
console.warn(`Failed to insert backlog item: ${response.status}`)
|
||||
failed++
|
||||
}
|
||||
} catch (insertError) {
|
||||
console.error('Backlog insertion error:', insertError)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, failed }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/woodpecker
|
||||
*
|
||||
* Webhook endpoint fuer Woodpecker CI/CD Events.
|
||||
*
|
||||
* Bei Pipeline-Failure:
|
||||
* 1. Extrahiert Logs mit /api/infrastructure/logs/extract
|
||||
* 2. Parsed Fehler nach Kategorie
|
||||
* 3. Traegt automatisch in Backlog ein
|
||||
*
|
||||
* Request Body (Woodpecker Webhook Format):
|
||||
* - event: 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
|
||||
* - repo_id: number
|
||||
* - pipeline_number: number
|
||||
* - branch?: string
|
||||
* - commit?: string
|
||||
* - author?: string
|
||||
* - message?: string
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const bodyText = await request.text()
|
||||
|
||||
// Verify webhook signature
|
||||
if (!verifySignature(request, bodyText)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid webhook signature' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const payload: WoodpeckerWebhookPayload = JSON.parse(bodyText)
|
||||
|
||||
// Log all events for debugging
|
||||
console.log(`Woodpecker webhook: ${payload.event} for pipeline #${payload.pipeline_number}`)
|
||||
|
||||
// Only process pipeline_failure events
|
||||
if (payload.event !== 'pipeline_failure') {
|
||||
return NextResponse.json({
|
||||
status: 'ignored',
|
||||
message: `Event ${payload.event} wird nicht verarbeitet`,
|
||||
pipeline_number: payload.pipeline_number,
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Extract logs from failed pipeline
|
||||
console.log(`Extracting logs for failed pipeline #${payload.pipeline_number}`)
|
||||
|
||||
const extractResponse = await fetch(LOG_EXTRACT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pipeline_number: payload.pipeline_number,
|
||||
repo_id: String(payload.repo_id),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!extractResponse.ok) {
|
||||
const errorText = await extractResponse.text()
|
||||
console.error('Log extraction failed:', errorText)
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: 'Log-Extraktion fehlgeschlagen',
|
||||
pipeline_number: payload.pipeline_number,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
const extractionResult = await extractResponse.json()
|
||||
const errors: ExtractedError[] = extractionResult.errors || []
|
||||
|
||||
console.log(`Extracted ${errors.length} errors from pipeline #${payload.pipeline_number}`)
|
||||
|
||||
// 2. Insert errors into backlog
|
||||
if (errors.length > 0) {
|
||||
const backlogResult = await insertIntoBacklog(
|
||||
errors,
|
||||
payload.pipeline_number,
|
||||
'ci_cd'
|
||||
)
|
||||
|
||||
console.log(`Backlog: ${backlogResult.inserted} inserted, ${backlogResult.failed} failed`)
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'processed',
|
||||
pipeline_number: payload.pipeline_number,
|
||||
branch: payload.branch,
|
||||
commit: payload.commit,
|
||||
errors_found: errors.length,
|
||||
backlog_inserted: backlogResult.inserted,
|
||||
backlog_failed: backlogResult.failed,
|
||||
categories: {
|
||||
test_failure: errors.filter(e => e.category === 'test_failure').length,
|
||||
build_error: errors.filter(e => e.category === 'build_error').length,
|
||||
security_warning: errors.filter(e => e.category === 'security_warning').length,
|
||||
license_violation: errors.filter(e => e.category === 'license_violation').length,
|
||||
dependency_issue: errors.filter(e => e.category === 'dependency_issue').length,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'processed',
|
||||
pipeline_number: payload.pipeline_number,
|
||||
message: 'Keine Fehler extrahiert',
|
||||
errors_found: 0,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook-Verarbeitung fehlgeschlagen' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/webhooks/woodpecker
|
||||
*
|
||||
* Health check endpoint
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ready',
|
||||
endpoint: '/api/webhooks/woodpecker',
|
||||
events: ['pipeline_failure'],
|
||||
description: 'Woodpecker CI/CD Webhook Handler',
|
||||
configured: {
|
||||
webhook_secret: WEBHOOK_SECRET ? 'yes' : 'no',
|
||||
log_extract_url: LOG_EXTRACT_URL,
|
||||
test_service_url: TEST_SERVICE_URL,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user