Initial commit: breakpilot-lehrer - Lehrer KI Platform

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>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions

View File

@@ -0,0 +1,620 @@
// 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`)
}

View File

@@ -0,0 +1,298 @@
/**
* BYOEH Client-Side Encryption Service
*
* Provides AES-256-GCM encryption for Erwartungshorizonte.
* The passphrase NEVER leaves the browser - only the encrypted data
* and a hash of the derived key are sent to the server.
*
* Security Flow:
* 1. User enters passphrase
* 2. PBKDF2 derives a 256-bit key from passphrase + random salt
* 3. AES-256-GCM encrypts the file content
* 4. SHA-256 hash of derived key is created for server-side verification
* 5. Encrypted blob + key hash + salt are uploaded (NOT the passphrase!)
*/
export interface EncryptionResult {
encryptedData: ArrayBuffer
keyHash: string
salt: string
iv: string
}
export interface DecryptionResult {
decryptedData: ArrayBuffer
success: boolean
error?: string
}
/**
* Convert ArrayBuffer to hex string
*/
function bufferToHex(buffer: Uint8Array): string {
return Array.from(buffer)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Convert hex string to ArrayBuffer
*/
function hexToBuffer(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Derive an AES-256 key from passphrase using PBKDF2
*/
async function deriveKey(
passphrase: string,
salt: Uint8Array
): Promise<CryptoKey> {
// Import passphrase as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
'PBKDF2',
false,
['deriveKey']
)
// Derive AES-256-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt.buffer as ArrayBuffer,
iterations: 100000, // High iteration count for security
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true, // extractable for hashing
['encrypt', 'decrypt']
)
}
/**
* Create SHA-256 hash of the derived key for server verification
*/
async function hashKey(key: CryptoKey): Promise<string> {
const rawKey = await crypto.subtle.exportKey('raw', key)
const hashBuffer = await crypto.subtle.digest('SHA-256', rawKey)
return bufferToHex(new Uint8Array(hashBuffer))
}
/**
* Encrypt a file using AES-256-GCM
*
* @param file - File to encrypt
* @param passphrase - User's passphrase (never sent to server)
* @returns Encrypted data + metadata for upload
*/
export async function encryptFile(
file: File,
passphrase: string
): Promise<EncryptionResult> {
// 1. Generate random 16-byte salt
const salt = crypto.getRandomValues(new Uint8Array(16))
// 2. Derive key from passphrase
const key = await deriveKey(passphrase, salt)
// 3. Generate random 12-byte IV (required for AES-GCM)
const iv = crypto.getRandomValues(new Uint8Array(12))
// 4. Read file content
const fileBuffer = await file.arrayBuffer()
// 5. Encrypt using AES-256-GCM
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
fileBuffer
)
// 6. Create key hash for server-side verification
const keyHash = await hashKey(key)
// 7. Combine IV + ciphertext (IV is needed for decryption)
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(encrypted), iv.length)
return {
encryptedData: combined.buffer,
keyHash,
salt: bufferToHex(salt),
iv: bufferToHex(iv)
}
}
/**
* Encrypt text content using AES-256-GCM
*
* @param text - Text to encrypt
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Base64-encoded encrypted content
*/
export async function encryptText(
text: string,
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(text)
)
// Combine IV + ciphertext
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(encrypted), iv.length)
// Return as base64
return btoa(String.fromCharCode(...combined))
}
/**
* Decrypt text content using AES-256-GCM
*
* @param encryptedBase64 - Base64-encoded encrypted content (IV + ciphertext)
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Decrypted text
*/
export async function decryptText(
encryptedBase64: string,
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
// Decode base64
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0))
// Extract IV (first 12 bytes) and ciphertext
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return new TextDecoder().decode(decrypted)
}
/**
* Decrypt a file using AES-256-GCM
*
* @param encryptedData - Encrypted data (IV + ciphertext)
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Decrypted file content
*/
export async function decryptFile(
encryptedData: ArrayBuffer,
passphrase: string,
saltHex: string
): Promise<DecryptionResult> {
try {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const combined = new Uint8Array(encryptedData)
// Extract IV (first 12 bytes) and ciphertext
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return {
decryptedData: decrypted,
success: true
}
} catch (error) {
return {
decryptedData: new ArrayBuffer(0),
success: false,
error: error instanceof Error ? error.message : 'Decryption failed'
}
}
}
/**
* Verify a passphrase against stored key hash
*
* @param passphrase - Passphrase to verify
* @param saltHex - Salt as hex string
* @param expectedHash - Expected key hash
* @returns true if passphrase is correct
*/
export async function verifyPassphrase(
passphrase: string,
saltHex: string,
expectedHash: string
): Promise<boolean> {
try {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const computedHash = await hashKey(key)
return computedHash === expectedHash
} catch {
return false
}
}
/**
* Generate a key hash for a given passphrase and salt
* Used when creating a new encrypted document
*
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Key hash for storage
*/
export async function generateKeyHash(
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
return hashKey(key)
}
/**
* Generate a random salt for encryption
*
* @returns 16-byte salt as hex string
*/
export function generateSalt(): string {
const salt = crypto.getRandomValues(new Uint8Array(16))
return bufferToHex(salt)
}
/**
* Check if Web Crypto API is available
*/
export function isEncryptionSupported(): boolean {
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
}