Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
/**
|
|
* Korrekturplattform API Service Layer
|
|
*
|
|
* Connects to klausur-service (Port 8086) for all correction-related operations.
|
|
* Uses dynamic host detection for local network compatibility.
|
|
*/
|
|
|
|
import type {
|
|
Klausur,
|
|
StudentWork,
|
|
CriteriaScores,
|
|
Annotation,
|
|
AnnotationPosition,
|
|
AnnotationType,
|
|
FairnessAnalysis,
|
|
EHSuggestion,
|
|
GradeInfo,
|
|
CreateKlausurData,
|
|
} from '@/app/korrektur/types'
|
|
|
|
// Get API base URL dynamically
|
|
// On localhost: direct connection to port 8086
|
|
// On macmini: use nginx proxy /klausur-api/ to avoid certificate issues
|
|
const getApiBase = (): string => {
|
|
if (typeof window === 'undefined') return 'http://localhost:8086'
|
|
const { hostname } = window.location
|
|
// Use nginx proxy on macmini to avoid cross-origin certificate issues
|
|
return hostname === 'localhost' ? 'http://localhost:8086' : '/klausur-api'
|
|
}
|
|
|
|
// Generic fetch wrapper with error handling
|
|
async function apiFetch<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const url = `${getApiBase()}${endpoint}`
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
|
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
// ============================================================================
|
|
// KLAUSUREN API
|
|
// ============================================================================
|
|
|
|
export async function getKlausuren(): Promise<Klausur[]> {
|
|
const data = await apiFetch<{ klausuren: Klausur[] }>('/api/v1/klausuren')
|
|
return data.klausuren || []
|
|
}
|
|
|
|
export async function getKlausur(id: string): Promise<Klausur> {
|
|
return apiFetch<Klausur>(`/api/v1/klausuren/${id}`)
|
|
}
|
|
|
|
export async function createKlausur(data: CreateKlausurData): Promise<Klausur> {
|
|
return apiFetch<Klausur>('/api/v1/klausuren', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
})
|
|
}
|
|
|
|
export async function deleteKlausur(id: string): Promise<void> {
|
|
await apiFetch(`/api/v1/klausuren/${id}`, { method: 'DELETE' })
|
|
}
|
|
|
|
// ============================================================================
|
|
// STUDENTS API
|
|
// ============================================================================
|
|
|
|
export async function getStudents(klausurId: string): Promise<StudentWork[]> {
|
|
const data = await apiFetch<{ students: StudentWork[] }>(
|
|
`/api/v1/klausuren/${klausurId}/students`
|
|
)
|
|
return data.students || []
|
|
}
|
|
|
|
export async function getStudent(studentId: string): Promise<StudentWork> {
|
|
return apiFetch<StudentWork>(`/api/v1/students/${studentId}`)
|
|
}
|
|
|
|
export async function uploadStudentWork(
|
|
klausurId: string,
|
|
file: File,
|
|
anonymId: string
|
|
): Promise<StudentWork> {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('anonym_id', anonymId)
|
|
|
|
const response = await fetch(
|
|
`${getApiBase()}/api/v1/klausuren/${klausurId}/students`,
|
|
{
|
|
method: 'POST',
|
|
body: formData,
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: 'Upload failed' }))
|
|
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
export async function deleteStudent(studentId: string): Promise<void> {
|
|
await apiFetch(`/api/v1/students/${studentId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
// ============================================================================
|
|
// CRITERIA & GUTACHTEN API
|
|
// ============================================================================
|
|
|
|
export async function updateCriteria(
|
|
studentId: string,
|
|
criteria: CriteriaScores
|
|
): Promise<StudentWork> {
|
|
return apiFetch<StudentWork>(`/api/v1/students/${studentId}/criteria`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ criteria_scores: criteria }),
|
|
})
|
|
}
|
|
|
|
export async function updateGutachten(
|
|
studentId: string,
|
|
gutachten: string
|
|
): Promise<StudentWork> {
|
|
return apiFetch<StudentWork>(`/api/v1/students/${studentId}/gutachten`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ gutachten }),
|
|
})
|
|
}
|
|
|
|
export async function generateGutachten(
|
|
studentId: string
|
|
): Promise<{ gutachten: string }> {
|
|
return apiFetch<{ gutachten: string }>(
|
|
`/api/v1/students/${studentId}/gutachten/generate`,
|
|
{ method: 'POST' }
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// ANNOTATIONS API
|
|
// ============================================================================
|
|
|
|
export async function getAnnotations(studentId: string): Promise<Annotation[]> {
|
|
const data = await apiFetch<{ annotations: Annotation[] }>(
|
|
`/api/v1/students/${studentId}/annotations`
|
|
)
|
|
return data.annotations || []
|
|
}
|
|
|
|
export async function createAnnotation(
|
|
studentId: string,
|
|
annotation: {
|
|
page: number
|
|
position: AnnotationPosition
|
|
type: AnnotationType
|
|
text: string
|
|
severity?: 'minor' | 'major' | 'critical'
|
|
suggestion?: string
|
|
linked_criterion?: string
|
|
}
|
|
): Promise<Annotation> {
|
|
return apiFetch<Annotation>(`/api/v1/students/${studentId}/annotations`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(annotation),
|
|
})
|
|
}
|
|
|
|
export async function updateAnnotation(
|
|
annotationId: string,
|
|
updates: Partial<{
|
|
text: string
|
|
severity: 'minor' | 'major' | 'critical'
|
|
suggestion: string
|
|
}>
|
|
): Promise<Annotation> {
|
|
return apiFetch<Annotation>(`/api/v1/annotations/${annotationId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(updates),
|
|
})
|
|
}
|
|
|
|
export async function deleteAnnotation(annotationId: string): Promise<void> {
|
|
await apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
// ============================================================================
|
|
// EH/RAG API (500+ NiBiS Dokumente)
|
|
// ============================================================================
|
|
|
|
export async function getEHSuggestions(
|
|
studentId: string,
|
|
criterion?: string
|
|
): Promise<EHSuggestion[]> {
|
|
const data = await apiFetch<{ suggestions: EHSuggestion[] }>(
|
|
`/api/v1/students/${studentId}/eh-suggestions`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ criterion }),
|
|
}
|
|
)
|
|
return data.suggestions || []
|
|
}
|
|
|
|
export async function queryRAG(
|
|
query: string,
|
|
topK: number = 5
|
|
): Promise<{ results: Array<{ text: string; score: number; metadata: any }> }> {
|
|
return apiFetch('/api/v1/eh/rag-query', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ query, top_k: topK }),
|
|
})
|
|
}
|
|
|
|
export async function uploadEH(file: File): Promise<{ id: string; name: string }> {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const response = await fetch(
|
|
`${getApiBase()}/api/v1/eh/upload`,
|
|
{
|
|
method: 'POST',
|
|
body: formData,
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: 'EH Upload failed' }))
|
|
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
// ============================================================================
|
|
// FAIRNESS & EXPORT API
|
|
// ============================================================================
|
|
|
|
export async function getFairnessAnalysis(
|
|
klausurId: string
|
|
): Promise<FairnessAnalysis> {
|
|
return apiFetch<FairnessAnalysis>(`/api/v1/klausuren/${klausurId}/fairness`)
|
|
}
|
|
|
|
export async function getGradeInfo(): Promise<GradeInfo> {
|
|
return apiFetch<GradeInfo>('/api/v1/grade-info')
|
|
}
|
|
|
|
// Export endpoints return file downloads
|
|
export function getGutachtenExportUrl(studentId: string): string {
|
|
return `${getApiBase()}/api/v1/students/${studentId}/export/gutachten`
|
|
}
|
|
|
|
export function getAnnotationsExportUrl(studentId: string): string {
|
|
return `${getApiBase()}/api/v1/students/${studentId}/export/annotations`
|
|
}
|
|
|
|
export function getOverviewExportUrl(klausurId: string): string {
|
|
return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/overview`
|
|
}
|
|
|
|
export function getAllGutachtenExportUrl(klausurId: string): string {
|
|
return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/all-gutachten`
|
|
}
|
|
|
|
// Get student file URL (PDF/Image)
|
|
export function getStudentFileUrl(studentId: string): string {
|
|
return `${getApiBase()}/api/v1/students/${studentId}/file`
|
|
}
|
|
|
|
// ============================================================================
|
|
// ARCHIV API (NiBiS Zentralabitur Documents)
|
|
// ============================================================================
|
|
|
|
export interface ArchivDokument {
|
|
id: string
|
|
title: string
|
|
subject: string
|
|
niveau: string
|
|
year: number
|
|
task_number?: number
|
|
doc_type: string
|
|
variant?: string
|
|
bundesland: string
|
|
minio_path?: string
|
|
preview_url?: string
|
|
}
|
|
|
|
export interface ArchivSearchResponse {
|
|
total: number
|
|
documents: ArchivDokument[]
|
|
filters: {
|
|
subjects: string[]
|
|
years: number[]
|
|
niveaus: string[]
|
|
doc_types: string[]
|
|
}
|
|
}
|
|
|
|
export interface ArchivFilters {
|
|
subject?: string
|
|
year?: number
|
|
bundesland?: string
|
|
niveau?: string
|
|
doc_type?: string
|
|
search?: string
|
|
limit?: number
|
|
offset?: number
|
|
}
|
|
|
|
export async function getArchivDocuments(filters: ArchivFilters = {}): Promise<ArchivSearchResponse> {
|
|
const params = new URLSearchParams()
|
|
|
|
if (filters.subject && filters.subject !== 'Alle') params.append('subject', filters.subject)
|
|
if (filters.year) params.append('year', filters.year.toString())
|
|
if (filters.bundesland && filters.bundesland !== 'Alle') params.append('bundesland', filters.bundesland)
|
|
if (filters.niveau && filters.niveau !== 'Alle') params.append('niveau', filters.niveau)
|
|
if (filters.doc_type && filters.doc_type !== 'Alle') params.append('doc_type', filters.doc_type)
|
|
if (filters.search) params.append('search', filters.search)
|
|
if (filters.limit) params.append('limit', filters.limit.toString())
|
|
if (filters.offset) params.append('offset', filters.offset.toString())
|
|
|
|
const queryString = params.toString()
|
|
const endpoint = `/api/v1/archiv${queryString ? `?${queryString}` : ''}`
|
|
|
|
return apiFetch<ArchivSearchResponse>(endpoint)
|
|
}
|
|
|
|
export async function getArchivDocument(docId: string): Promise<ArchivDokument & { text_preview?: string }> {
|
|
return apiFetch<ArchivDokument & { text_preview?: string }>(`/api/v1/archiv/${docId}`)
|
|
}
|
|
|
|
export async function getArchivDocumentUrl(docId: string, expires: number = 3600): Promise<{ url: string; expires_in: number; filename: string }> {
|
|
return apiFetch<{ url: string; expires_in: number; filename: string }>(`/api/v1/archiv/${docId}/url?expires=${expires}`)
|
|
}
|
|
|
|
export async function searchArchivSemantic(
|
|
query: string,
|
|
options: { year?: number; subject?: string; niveau?: string; limit?: number } = {}
|
|
): Promise<Array<{
|
|
id: string
|
|
score: number
|
|
text: string
|
|
year: number
|
|
subject: string
|
|
niveau: string
|
|
task_number?: number
|
|
doc_type: string
|
|
}>> {
|
|
const params = new URLSearchParams({ query })
|
|
|
|
if (options.year) params.append('year', options.year.toString())
|
|
if (options.subject) params.append('subject', options.subject)
|
|
if (options.niveau) params.append('niveau', options.niveau)
|
|
if (options.limit) params.append('limit', options.limit.toString())
|
|
|
|
return apiFetch(`/api/v1/archiv/search/semantic?${params.toString()}`)
|
|
}
|
|
|
|
export async function getArchivSuggestions(query: string): Promise<Array<{ label: string; type: string }>> {
|
|
return apiFetch<Array<{ label: string; type: string }>>(`/api/v1/archiv/suggest?query=${encodeURIComponent(query)}`)
|
|
}
|
|
|
|
export async function getArchivStats(): Promise<{
|
|
total_documents: number
|
|
total_chunks: number
|
|
by_year: Record<string, number>
|
|
by_subject: Record<string, number>
|
|
by_niveau: Record<string, number>
|
|
}> {
|
|
return apiFetch('/api/v1/archiv/stats')
|
|
}
|
|
|
|
// ============================================================================
|
|
// STATS API (for Dashboard)
|
|
// ============================================================================
|
|
|
|
export interface KorrekturStats {
|
|
totalKlausuren: number
|
|
totalStudents: number
|
|
openCorrections: number
|
|
completedThisWeek: number
|
|
averageGrade: number
|
|
timeSavedHours: number
|
|
}
|
|
|
|
export async function getKorrekturStats(): Promise<KorrekturStats> {
|
|
try {
|
|
const klausuren = await getKlausuren()
|
|
|
|
let totalStudents = 0
|
|
let openCorrections = 0
|
|
let completedCount = 0
|
|
let gradeSum = 0
|
|
let gradedCount = 0
|
|
|
|
for (const klausur of klausuren) {
|
|
totalStudents += klausur.student_count || 0
|
|
completedCount += klausur.completed_count || 0
|
|
openCorrections += (klausur.student_count || 0) - (klausur.completed_count || 0)
|
|
}
|
|
|
|
// Get average from all klausuren
|
|
for (const klausur of klausuren) {
|
|
if (klausur.status === 'completed' || klausur.status === 'in_progress') {
|
|
try {
|
|
const fairness = await getFairnessAnalysis(klausur.id)
|
|
if (fairness.average_grade > 0) {
|
|
gradeSum += fairness.average_grade
|
|
gradedCount++
|
|
}
|
|
} catch {
|
|
// Skip if fairness analysis not available
|
|
}
|
|
}
|
|
}
|
|
|
|
const averageGrade = gradedCount > 0 ? gradeSum / gradedCount : 0
|
|
// Estimate time saved: ~30 minutes per correction with AI assistance
|
|
const timeSavedHours = Math.round(completedCount * 0.5)
|
|
|
|
return {
|
|
totalKlausuren: klausuren.length,
|
|
totalStudents,
|
|
openCorrections,
|
|
completedThisWeek: completedCount, // Simplified, would need date filtering
|
|
averageGrade: Math.round(averageGrade * 10) / 10,
|
|
timeSavedHours,
|
|
}
|
|
} catch {
|
|
return {
|
|
totalKlausuren: 0,
|
|
totalStudents: 0,
|
|
openCorrections: 0,
|
|
completedThisWeek: 0,
|
|
averageGrade: 0,
|
|
timeSavedHours: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export all functions as a namespace
|
|
export const korrekturApi = {
|
|
// Klausuren
|
|
getKlausuren,
|
|
getKlausur,
|
|
createKlausur,
|
|
deleteKlausur,
|
|
|
|
// Students
|
|
getStudents,
|
|
getStudent,
|
|
uploadStudentWork,
|
|
deleteStudent,
|
|
|
|
// Criteria & Gutachten
|
|
updateCriteria,
|
|
updateGutachten,
|
|
generateGutachten,
|
|
|
|
// Annotations
|
|
getAnnotations,
|
|
createAnnotation,
|
|
updateAnnotation,
|
|
deleteAnnotation,
|
|
|
|
// EH/RAG
|
|
getEHSuggestions,
|
|
queryRAG,
|
|
uploadEH,
|
|
|
|
// Fairness & Export
|
|
getFairnessAnalysis,
|
|
getGradeInfo,
|
|
getGutachtenExportUrl,
|
|
getAnnotationsExportUrl,
|
|
getOverviewExportUrl,
|
|
getAllGutachtenExportUrl,
|
|
getStudentFileUrl,
|
|
|
|
// Archiv (NiBiS)
|
|
getArchivDocuments,
|
|
getArchivDocument,
|
|
getArchivDocumentUrl,
|
|
searchArchivSemantic,
|
|
getArchivSuggestions,
|
|
getArchivStats,
|
|
|
|
// Stats
|
|
getKorrekturStats,
|
|
}
|