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>
621 lines
16 KiB
TypeScript
621 lines
16 KiB
TypeScript
// API Types
|
|
export interface StudentKlausur {
|
|
id: string
|
|
klausur_id: string
|
|
student_name: string
|
|
student_id: string | null
|
|
file_path: string | null
|
|
ocr_text: string | null
|
|
status: string
|
|
criteria_scores: Record<string, { score: number; annotations: string[] }>
|
|
gutachten: {
|
|
einleitung: string
|
|
hauptteil: string
|
|
fazit: string
|
|
staerken: string[]
|
|
schwaechen: string[]
|
|
} | null
|
|
raw_points: number
|
|
grade_points: number
|
|
created_at: string
|
|
}
|
|
|
|
export interface Klausur {
|
|
id: string
|
|
title: string
|
|
subject: string
|
|
modus: 'landes_abitur' | 'vorabitur'
|
|
class_id: string | null
|
|
year: number
|
|
semester: string
|
|
erwartungshorizont: Record<string, unknown> | null
|
|
student_count: number
|
|
students: StudentKlausur[]
|
|
created_at: string
|
|
teacher_id: string
|
|
}
|
|
|
|
export interface GradeInfo {
|
|
thresholds: Record<number, number>
|
|
labels: Record<number, string>
|
|
criteria: Record<string, { weight: number; label: string }>
|
|
}
|
|
|
|
// Get auth token from parent window or localStorage
|
|
function getAuthToken(): string | null {
|
|
// Try to get from parent window (iframe scenario)
|
|
try {
|
|
if (window.parent !== window) {
|
|
const parentToken = (window.parent as unknown as { authToken?: string }).authToken
|
|
if (parentToken) return parentToken
|
|
}
|
|
} catch {
|
|
// Cross-origin access denied
|
|
}
|
|
|
|
// Try localStorage
|
|
return localStorage.getItem('auth_token')
|
|
}
|
|
|
|
// Base API call
|
|
async function apiCall<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const token = getAuthToken()
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(options.headers as Record<string, string> || {})
|
|
}
|
|
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`
|
|
}
|
|
|
|
const response = await fetch(`/api/v1${endpoint}`, {
|
|
...options,
|
|
headers
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ detail: 'Request failed' }))
|
|
throw new Error(error.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
// Klausuren API
|
|
export const klausurApi = {
|
|
listKlausuren: (): Promise<Klausur[]> =>
|
|
apiCall('/klausuren'),
|
|
|
|
getKlausur: (id: string): Promise<Klausur> =>
|
|
apiCall(`/klausuren/${id}`),
|
|
|
|
createKlausur: (data: Partial<Klausur>): Promise<Klausur> =>
|
|
apiCall('/klausuren', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
updateKlausur: (id: string, data: Partial<Klausur>): Promise<Klausur> =>
|
|
apiCall(`/klausuren/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
deleteKlausur: (id: string): Promise<{ success: boolean }> =>
|
|
apiCall(`/klausuren/${id}`, { method: 'DELETE' }),
|
|
|
|
// Students
|
|
listStudents: (klausurId: string): Promise<StudentKlausur[]> =>
|
|
apiCall(`/klausuren/${klausurId}/students`),
|
|
|
|
deleteStudent: (studentId: string): Promise<{ success: boolean }> =>
|
|
apiCall(`/students/${studentId}`, { method: 'DELETE' }),
|
|
|
|
// Grading
|
|
updateCriteria: (
|
|
studentId: string,
|
|
criterion: string,
|
|
score: number,
|
|
annotations?: string[]
|
|
): Promise<StudentKlausur> =>
|
|
apiCall(`/students/${studentId}/criteria`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ criterion, score, annotations })
|
|
}),
|
|
|
|
updateGutachten: (
|
|
studentId: string,
|
|
gutachten: {
|
|
einleitung: string
|
|
hauptteil: string
|
|
fazit: string
|
|
staerken?: string[]
|
|
schwaechen?: string[]
|
|
}
|
|
): Promise<StudentKlausur> =>
|
|
apiCall(`/students/${studentId}/gutachten`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(gutachten)
|
|
}),
|
|
|
|
finalizeStudent: (studentId: string): Promise<StudentKlausur> =>
|
|
apiCall(`/students/${studentId}/finalize`, { method: 'POST' }),
|
|
|
|
// KI-Gutachten Generation
|
|
generateGutachten: (
|
|
studentId: string,
|
|
options: {
|
|
include_strengths?: boolean
|
|
include_weaknesses?: boolean
|
|
tone?: 'formal' | 'friendly' | 'constructive'
|
|
} = {}
|
|
): Promise<{
|
|
einleitung: string
|
|
hauptteil: string
|
|
fazit: string
|
|
staerken: string[]
|
|
schwaechen: string[]
|
|
generated_at: string
|
|
is_ki_generated: boolean
|
|
tone: string
|
|
}> =>
|
|
apiCall(`/students/${studentId}/gutachten/generate`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
include_strengths: options.include_strengths ?? true,
|
|
include_weaknesses: options.include_weaknesses ?? true,
|
|
tone: options.tone ?? 'formal'
|
|
})
|
|
}),
|
|
|
|
// Fairness Analysis
|
|
getFairnessAnalysis: (klausurId: string): Promise<{
|
|
klausur_id: string
|
|
students_count: number
|
|
graded_count: number
|
|
statistics: {
|
|
average_grade: number
|
|
average_raw_points: number
|
|
min_grade: number
|
|
max_grade: number
|
|
spread: number
|
|
standard_deviation: number
|
|
}
|
|
criteria_breakdown: Record<string, { average: number; min: number; max: number; count: number }>
|
|
outliers: Array<{
|
|
student_id: string
|
|
student_name: string
|
|
grade_points: number
|
|
deviation: number
|
|
direction: 'above' | 'below'
|
|
}>
|
|
fairness_score: number
|
|
warnings: string[]
|
|
recommendation: string
|
|
}> =>
|
|
apiCall(`/klausuren/${klausurId}/fairness`),
|
|
|
|
// Audit Log
|
|
getStudentAuditLog: (studentId: string): Promise<Array<{
|
|
id: string
|
|
timestamp: string
|
|
user_id: string
|
|
action: string
|
|
entity_type: string
|
|
entity_id: string
|
|
field: string | null
|
|
old_value: string | null
|
|
new_value: string | null
|
|
details: Record<string, unknown> | null
|
|
}>> =>
|
|
apiCall(`/students/${studentId}/audit-log`),
|
|
|
|
// Utilities
|
|
getGradeInfo: (): Promise<GradeInfo> =>
|
|
apiCall('/grade-info')
|
|
}
|
|
|
|
// File upload (special handling for multipart)
|
|
export async function uploadStudentWork(
|
|
klausurId: string,
|
|
studentName: string,
|
|
file: File
|
|
): Promise<StudentKlausur> {
|
|
const token = getAuthToken()
|
|
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('student_name', studentName)
|
|
|
|
const headers: Record<string, string> = {}
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`
|
|
}
|
|
|
|
const response = await fetch(`/api/v1/klausuren/${klausurId}/students`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ detail: 'Upload failed' }))
|
|
throw new Error(error.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
// =============================================
|
|
// BYOEH (Erwartungshorizont) Types & API
|
|
// =============================================
|
|
|
|
export interface Erwartungshorizont {
|
|
id: string
|
|
tenant_id: string
|
|
teacher_id: string
|
|
title: string
|
|
subject: string
|
|
niveau: 'eA' | 'gA'
|
|
year: number
|
|
aufgaben_nummer: string | null
|
|
status: 'pending_rights' | 'processing' | 'indexed' | 'error'
|
|
chunk_count: number
|
|
rights_confirmed: boolean
|
|
rights_confirmed_at: string | null
|
|
indexed_at: string | null
|
|
file_size_bytes: number
|
|
original_filename: string
|
|
training_allowed: boolean
|
|
created_at: string
|
|
deleted_at: string | null
|
|
}
|
|
|
|
export interface EHRAGResult {
|
|
context: string
|
|
sources: Array<{
|
|
text: string
|
|
eh_id: string
|
|
eh_title: string
|
|
chunk_index: number
|
|
score: number
|
|
reranked?: boolean
|
|
}>
|
|
query: string
|
|
search_info?: {
|
|
retrieval_time_ms?: number
|
|
rerank_time_ms?: number
|
|
total_time_ms?: number
|
|
reranked?: boolean
|
|
rerank_applied?: boolean
|
|
hybrid_search_applied?: boolean
|
|
embedding_model?: string
|
|
total_candidates?: number
|
|
original_count?: number
|
|
}
|
|
}
|
|
|
|
export interface EHAuditEntry {
|
|
id: string
|
|
eh_id: string | null
|
|
tenant_id: string
|
|
user_id: string
|
|
action: string
|
|
details: Record<string, unknown> | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface EHKeyShare {
|
|
id: string
|
|
eh_id: string
|
|
user_id: string
|
|
passphrase_hint: string
|
|
granted_by: string
|
|
granted_at: string
|
|
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head'
|
|
klausur_id: string | null
|
|
active: boolean
|
|
}
|
|
|
|
export interface EHKlausurLink {
|
|
id: string
|
|
eh_id: string
|
|
klausur_id: string
|
|
linked_by: string
|
|
linked_at: string
|
|
}
|
|
|
|
export interface SharedEHInfo {
|
|
eh: Erwartungshorizont
|
|
share: EHKeyShare
|
|
}
|
|
|
|
export interface LinkedEHInfo {
|
|
eh: Erwartungshorizont
|
|
link: EHKlausurLink
|
|
is_owner: boolean
|
|
share: EHKeyShare | null
|
|
}
|
|
|
|
// Invitation types for Invite/Accept/Revoke flow
|
|
export interface EHShareInvitation {
|
|
id: string
|
|
eh_id: string
|
|
inviter_id: string
|
|
invitee_id: string
|
|
invitee_email: string
|
|
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz'
|
|
klausur_id: string | null
|
|
message: string | null
|
|
status: 'pending' | 'accepted' | 'declined' | 'expired' | 'revoked'
|
|
expires_at: string
|
|
created_at: string
|
|
accepted_at: string | null
|
|
declined_at: string | null
|
|
}
|
|
|
|
export interface PendingInvitationInfo {
|
|
invitation: EHShareInvitation
|
|
eh: {
|
|
id: string
|
|
title: string
|
|
subject: string
|
|
niveau: string
|
|
year: number
|
|
} | null
|
|
}
|
|
|
|
export interface SentInvitationInfo {
|
|
invitation: EHShareInvitation
|
|
eh: {
|
|
id: string
|
|
title: string
|
|
subject: string
|
|
} | null
|
|
}
|
|
|
|
export interface EHAccessChain {
|
|
eh_id: string
|
|
eh_title: string
|
|
owner: {
|
|
user_id: string
|
|
role: string
|
|
}
|
|
active_shares: EHKeyShare[]
|
|
pending_invitations: EHShareInvitation[]
|
|
revoked_shares: EHKeyShare[]
|
|
}
|
|
|
|
// Erwartungshorizont API
|
|
export const ehApi = {
|
|
// List all EH for current teacher
|
|
listEH: (params?: { subject?: string; year?: number }): Promise<Erwartungshorizont[]> => {
|
|
const query = new URLSearchParams()
|
|
if (params?.subject) query.append('subject', params.subject)
|
|
if (params?.year) query.append('year', params.year.toString())
|
|
const queryStr = query.toString()
|
|
return apiCall(`/eh${queryStr ? `?${queryStr}` : ''}`)
|
|
},
|
|
|
|
// Get single EH by ID
|
|
getEH: (id: string): Promise<Erwartungshorizont> =>
|
|
apiCall(`/eh/${id}`),
|
|
|
|
// Upload encrypted EH (special handling for FormData)
|
|
uploadEH: async (formData: FormData): Promise<Erwartungshorizont> => {
|
|
const token = getAuthToken()
|
|
const headers: Record<string, string> = {}
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`
|
|
}
|
|
|
|
const response = await fetch('/api/v1/eh/upload', {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ detail: 'Upload failed' }))
|
|
throw new Error(error.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
return response.json()
|
|
},
|
|
|
|
// Delete EH (soft delete)
|
|
deleteEH: (id: string): Promise<{ status: string; id: string }> =>
|
|
apiCall(`/eh/${id}`, { method: 'DELETE' }),
|
|
|
|
// Index EH for RAG (requires passphrase)
|
|
indexEH: (id: string, passphrase: string): Promise<{ status: string; id: string; chunk_count: number }> =>
|
|
apiCall(`/eh/${id}/index`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ passphrase })
|
|
}),
|
|
|
|
// RAG query against EH
|
|
ragQuery: (params: {
|
|
query_text: string
|
|
passphrase: string
|
|
subject?: string
|
|
limit?: number
|
|
rerank?: boolean
|
|
}): Promise<EHRAGResult> =>
|
|
apiCall('/eh/rag-query', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
query_text: params.query_text,
|
|
passphrase: params.passphrase,
|
|
subject: params.subject,
|
|
limit: params.limit ?? 5,
|
|
rerank: params.rerank ?? false
|
|
})
|
|
}),
|
|
|
|
// Get audit log
|
|
getAuditLog: (ehId?: string, limit?: number): Promise<EHAuditEntry[]> => {
|
|
const query = new URLSearchParams()
|
|
if (ehId) query.append('eh_id', ehId)
|
|
if (limit) query.append('limit', limit.toString())
|
|
const queryStr = query.toString()
|
|
return apiCall(`/eh/audit-log${queryStr ? `?${queryStr}` : ''}`)
|
|
},
|
|
|
|
// Get rights confirmation text
|
|
getRightsText: (): Promise<{ text: string; version: string }> =>
|
|
apiCall('/eh/rights-text'),
|
|
|
|
// Get Qdrant status (admin only)
|
|
getQdrantStatus: (): Promise<{
|
|
name: string
|
|
vectors_count: number
|
|
points_count: number
|
|
status: string
|
|
}> =>
|
|
apiCall('/eh/qdrant-status'),
|
|
|
|
// =============================================
|
|
// KEY SHARING
|
|
// =============================================
|
|
|
|
// Share EH with another examiner
|
|
shareEH: (
|
|
ehId: string,
|
|
params: {
|
|
user_id: string
|
|
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head'
|
|
encrypted_passphrase: string
|
|
passphrase_hint?: string
|
|
klausur_id?: string
|
|
}
|
|
): Promise<{
|
|
status: string
|
|
share_id: string
|
|
eh_id: string
|
|
shared_with: string
|
|
role: string
|
|
}> =>
|
|
apiCall(`/eh/${ehId}/share`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(params)
|
|
}),
|
|
|
|
// List shares for an EH (owner only)
|
|
listShares: (ehId: string): Promise<EHKeyShare[]> =>
|
|
apiCall(`/eh/${ehId}/shares`),
|
|
|
|
// Revoke a share
|
|
revokeShare: (ehId: string, shareId: string): Promise<{ status: string; share_id: string }> =>
|
|
apiCall(`/eh/${ehId}/shares/${shareId}`, { method: 'DELETE' }),
|
|
|
|
// Get EH shared with current user
|
|
getSharedWithMe: (): Promise<SharedEHInfo[]> =>
|
|
apiCall('/eh/shared-with-me'),
|
|
|
|
// Link EH to a Klausur
|
|
linkToKlausur: (ehId: string, klausurId: string): Promise<{
|
|
status: string
|
|
link_id: string
|
|
eh_id: string
|
|
klausur_id: string
|
|
}> =>
|
|
apiCall(`/eh/${ehId}/link-klausur`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ klausur_id: klausurId })
|
|
}),
|
|
|
|
// Unlink EH from a Klausur
|
|
unlinkFromKlausur: (ehId: string, klausurId: string): Promise<{
|
|
status: string
|
|
eh_id: string
|
|
klausur_id: string
|
|
}> =>
|
|
apiCall(`/eh/${ehId}/link-klausur/${klausurId}`, { method: 'DELETE' }),
|
|
|
|
// =============================================
|
|
// INVITATION FLOW (Invite / Accept / Revoke)
|
|
// =============================================
|
|
|
|
// Send invitation to share EH
|
|
inviteToEH: (
|
|
ehId: string,
|
|
params: {
|
|
invitee_email: string
|
|
invitee_id?: string
|
|
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz'
|
|
klausur_id?: string
|
|
message?: string
|
|
expires_in_days?: number
|
|
}
|
|
): Promise<{
|
|
status: string
|
|
invitation_id: string
|
|
eh_id: string
|
|
invitee_email: string
|
|
role: string
|
|
expires_at: string
|
|
eh_title: string
|
|
}> =>
|
|
apiCall(`/eh/${ehId}/invite`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(params)
|
|
}),
|
|
|
|
// Get pending invitations for current user
|
|
getPendingInvitations: (): Promise<PendingInvitationInfo[]> =>
|
|
apiCall('/eh/invitations/pending'),
|
|
|
|
// Get sent invitations (as inviter)
|
|
getSentInvitations: (): Promise<SentInvitationInfo[]> =>
|
|
apiCall('/eh/invitations/sent'),
|
|
|
|
// Accept an invitation
|
|
acceptInvitation: (
|
|
invitationId: string,
|
|
encryptedPassphrase: string
|
|
): Promise<{
|
|
status: string
|
|
share_id: string
|
|
eh_id: string
|
|
role: string
|
|
klausur_id: string | null
|
|
}> =>
|
|
apiCall(`/eh/invitations/${invitationId}/accept`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ encrypted_passphrase: encryptedPassphrase })
|
|
}),
|
|
|
|
// Decline an invitation
|
|
declineInvitation: (invitationId: string): Promise<{
|
|
status: string
|
|
invitation_id: string
|
|
eh_id: string
|
|
}> =>
|
|
apiCall(`/eh/invitations/${invitationId}/decline`, { method: 'POST' }),
|
|
|
|
// Revoke an invitation (as inviter)
|
|
revokeInvitation: (invitationId: string): Promise<{
|
|
status: string
|
|
invitation_id: string
|
|
eh_id: string
|
|
}> =>
|
|
apiCall(`/eh/invitations/${invitationId}`, { method: 'DELETE' }),
|
|
|
|
// Get the complete access chain for an EH
|
|
getAccessChain: (ehId: string): Promise<EHAccessChain> =>
|
|
apiCall(`/eh/${ehId}/access-chain`)
|
|
}
|
|
|
|
// Get linked EH for a Klausur (separate from ehApi for clarity)
|
|
export const klausurEHApi = {
|
|
// Get all EH linked to a Klausur that the user has access to
|
|
getLinkedEH: (klausurId: string): Promise<LinkedEHInfo[]> =>
|
|
apiCall(`/klausuren/${klausurId}/linked-eh`)
|
|
}
|