[split-required] Split remaining 500-680 LOC files (final batch)

website (17 pages + 3 components):
- multiplayer/wizard, middleware/wizard+test-wizard, communication
- builds/wizard, staff-search, voice, sbom/wizard
- foerderantrag, mail/tasks, tools/communication, sbom
- compliance/evidence, uni-crawler, brandbook (already done)
- CollectionsTab, IngestionTab, RiskHeatmap

backend-lehrer (5 files):
- letters_api (641 → 2), certificates_api (636 → 2)
- alerts_agent/db/models (636 → 3)
- llm_gateway/communication_service (614 → 2)
- game/database already done in prior batch

klausur-service (2 files):
- hybrid_vocab_extractor (664 → 2)
- klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2)

voice-service (3 files):
- bqas/rag_judge (618 → 3), runner (529 → 2)
- enhanced_task_orchestrator (519 → 2)

studio-v2 (6 files):
- korrektur/[klausurId] (578 → 4), fairness (569 → 2)
- AlertsWizard (552 → 2), OnboardingWizard (513 → 2)
- korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 08:56:45 +02:00
parent b4613e26f3
commit 451365a312
115 changed files with 10694 additions and 13839 deletions

View File

@@ -1,586 +1,137 @@
/**
* BYOEH Upload Wizard Component
*
* 5-step wizard for uploading Erwartungshorizonte with client-side encryption:
* 1. File Selection - Choose PDF file
* 2. Metadata - Title, Subject, Niveau, Year
* 3. Rights Confirmation - Legal acknowledgment (required)
* 4. Encryption - Set passphrase (2x confirmation)
* 5. Summary & Upload - Review and confirm
* 5-step wizard for uploading Erwartungshorizonte with client-side encryption.
* Step content extracted to eh-wizard/EHWizardSteps.tsx.
*/
import { useState, useEffect, useCallback } from 'react'
import {
encryptFile,
generateSalt,
isEncryptionSupported
} from '../services/encryption'
import { encryptFile, generateSalt, isEncryptionSupported } from '../services/encryption'
import { ehApi } from '../services/api'
interface EHMetadata {
title: string
subject: string
niveau: 'eA' | 'gA'
year: number
aufgaben_nummer?: string
}
import { FileStep, MetadataStep, RightsStep, EncryptionStep, SummaryStep } from './eh-wizard/EHWizardSteps'
import type { EHMetadata } from './eh-wizard/EHWizardSteps'
interface EHUploadWizardProps {
onClose: () => void
onComplete?: (ehId: string) => void
onSuccess?: (ehId: string) => void // Legacy alias for onComplete
onSuccess?: (ehId: string) => void
klausurSubject?: string
klausurYear?: number
defaultSubject?: string // Alias for klausurSubject
defaultYear?: number // Alias for klausurYear
klausurId?: string // If provided, automatically link EH to this Klausur
defaultSubject?: string
defaultYear?: number
klausurId?: string
}
type WizardStep = 'file' | 'metadata' | 'rights' | 'encryption' | 'summary'
const WIZARD_STEPS: WizardStep[] = ['file', 'metadata', 'rights', 'encryption', 'summary']
const STEP_LABELS: Record<WizardStep, string> = { file: 'Datei', metadata: 'Metadaten', rights: 'Rechte', encryption: 'Verschluesselung', summary: 'Zusammenfassung' }
const STEP_LABELS: Record<WizardStep, string> = {
file: 'Datei',
metadata: 'Metadaten',
rights: 'Rechte',
encryption: 'Verschluesselung',
summary: 'Zusammenfassung'
}
const SUBJECTS = [
'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie',
'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport',
'informatik', 'latein', 'franzoesisch', 'spanisch'
]
const RIGHTS_TEXT = `Ich bestaetige hiermit, dass:
1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem
Erwartungshorizont besitze.
2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet,
sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege
in meinem persoenlichen Arbeitsbereich.
3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter
keinen Zugriff auf den Klartext haben.
4. Ich diesen Erwartungshorizont jederzeit loeschen kann.`
function EHUploadWizard({
onClose,
onComplete,
onSuccess,
klausurSubject,
klausurYear,
defaultSubject,
defaultYear,
klausurId
}: EHUploadWizardProps) {
// Resolve aliases
function EHUploadWizard({ onClose, onComplete, onSuccess, klausurSubject, klausurYear, defaultSubject, defaultYear, klausurId }: EHUploadWizardProps) {
const effectiveSubject = klausurSubject || defaultSubject || 'deutsch'
const effectiveYear = klausurYear || defaultYear || new Date().getFullYear()
const handleComplete = onComplete || onSuccess || (() => {})
// Step state
const [currentStep, setCurrentStep] = useState<WizardStep>('file')
// File step
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [fileError, setFileError] = useState<string | null>(null)
// Metadata step
const [metadata, setMetadata] = useState<EHMetadata>({
title: '',
subject: effectiveSubject,
niveau: 'eA',
year: effectiveYear,
aufgaben_nummer: ''
})
// Rights step
const [metadata, setMetadata] = useState<EHMetadata>({ title: '', subject: effectiveSubject, niveau: 'eA', year: effectiveYear, aufgaben_nummer: '' })
const [rightsConfirmed, setRightsConfirmed] = useState(false)
// Encryption step
const [passphrase, setPassphrase] = useState('')
const [passphraseConfirm, setPassphraseConfirm] = useState('')
const [showPassphrase, setShowPassphrase] = useState(false)
const [passphraseStrength, setPassphraseStrength] = useState<'weak' | 'medium' | 'strong'>('weak')
// Upload state
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
// Check encryption support
const encryptionSupported = isEncryptionSupported()
// Calculate passphrase strength
useEffect(() => {
if (passphrase.length < 8) {
setPassphraseStrength('weak')
} else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) {
setPassphraseStrength('medium')
} else {
setPassphraseStrength('strong')
}
if (passphrase.length < 8) setPassphraseStrength('weak')
else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) setPassphraseStrength('medium')
else setPassphraseStrength('strong')
}, [passphrase])
// Step navigation
const currentStepIndex = WIZARD_STEPS.indexOf(currentStep)
const isFirstStep = currentStepIndex === 0
const isLastStep = currentStepIndex === WIZARD_STEPS.length - 1
const goNext = useCallback(() => {
if (!isLastStep) {
setCurrentStep(WIZARD_STEPS[currentStepIndex + 1])
}
}, [currentStepIndex, isLastStep])
const goNext = useCallback(() => { if (!isLastStep) setCurrentStep(WIZARD_STEPS[currentStepIndex + 1]) }, [currentStepIndex, isLastStep])
const goBack = useCallback(() => { if (!isFirstStep) setCurrentStep(WIZARD_STEPS[currentStepIndex - 1]) }, [currentStepIndex, isFirstStep])
const goBack = useCallback(() => {
if (!isFirstStep) {
setCurrentStep(WIZARD_STEPS[currentStepIndex - 1])
}
}, [currentStepIndex, isFirstStep])
// Validation
const isStepValid = useCallback((step: WizardStep): boolean => {
switch (step) {
case 'file':
return selectedFile !== null && fileError === null
case 'metadata':
return metadata.title.trim().length > 0 && metadata.subject.length > 0
case 'rights':
return rightsConfirmed
case 'encryption':
return passphrase.length >= 8 && passphrase === passphraseConfirm
case 'summary':
return true
default:
return false
case 'file': return selectedFile !== null && fileError === null
case 'metadata': return metadata.title.trim().length > 0 && metadata.subject.length > 0
case 'rights': return rightsConfirmed
case 'encryption': return passphrase.length >= 8 && passphrase === passphraseConfirm
case 'summary': return true
default: return false
}
}, [selectedFile, fileError, metadata, rightsConfirmed, passphrase, passphraseConfirm])
// File handling
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (file.type !== 'application/pdf') {
setFileError('Nur PDF-Dateien sind erlaubt')
setSelectedFile(null)
return
}
if (file.size > 50 * 1024 * 1024) { // 50MB limit
setFileError('Datei ist zu gross (max. 50MB)')
setSelectedFile(null)
return
}
setFileError(null)
setSelectedFile(file)
// Auto-fill title from filename
if (!metadata.title) {
const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' ')
setMetadata(prev => ({ ...prev, title: name }))
}
if (file.type !== 'application/pdf') { setFileError('Nur PDF-Dateien sind erlaubt'); setSelectedFile(null); return }
if (file.size > 50 * 1024 * 1024) { setFileError('Datei ist zu gross (max. 50MB)'); setSelectedFile(null); return }
setFileError(null); setSelectedFile(file)
if (!metadata.title) { const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' '); setMetadata(prev => ({ ...prev, title: name })) }
}
}
// Upload handler
const handleUpload = async () => {
if (!selectedFile || !encryptionSupported) return
setUploading(true)
setUploadProgress(10)
setUploadError(null)
setUploading(true); setUploadProgress(10); setUploadError(null)
try {
// Step 1: Generate salt (used via encrypted.salt below)
generateSalt()
setUploadProgress(20)
// Step 2: Encrypt file client-side
const encrypted = await encryptFile(selectedFile, passphrase)
setUploadProgress(50)
// Step 3: Create form data
generateSalt(); setUploadProgress(20)
const encrypted = await encryptFile(selectedFile, passphrase); setUploadProgress(50)
const formData = new FormData()
const encryptedBlob = new Blob([encrypted.encryptedData], { type: 'application/octet-stream' })
formData.append('file', encryptedBlob, 'encrypted.bin')
const metadataJson = JSON.stringify({
metadata: {
title: metadata.title,
subject: metadata.subject,
niveau: metadata.niveau,
year: metadata.year,
aufgaben_nummer: metadata.aufgaben_nummer || null
},
encryption_key_hash: encrypted.keyHash,
salt: encrypted.salt,
rights_confirmed: true,
original_filename: selectedFile.name
})
formData.append('metadata_json', metadataJson)
formData.append('file', new Blob([encrypted.encryptedData], { type: 'application/octet-stream' }), 'encrypted.bin')
formData.append('metadata_json', JSON.stringify({
metadata: { title: metadata.title, subject: metadata.subject, niveau: metadata.niveau, year: metadata.year, aufgaben_nummer: metadata.aufgaben_nummer || null },
encryption_key_hash: encrypted.keyHash, salt: encrypted.salt, rights_confirmed: true, original_filename: selectedFile.name
}))
setUploadProgress(70)
// Step 4: Upload to server
const response = await ehApi.uploadEH(formData)
setUploadProgress(90)
// Step 5: Link to Klausur if klausurId provided
if (klausurId && response.id) {
try {
await ehApi.linkToKlausur(response.id, klausurId)
} catch (linkError) {
console.warn('Failed to auto-link EH to Klausur:', linkError)
// Don't fail the whole upload if linking fails
}
}
setUploadProgress(100)
// Success!
handleComplete(response.id)
} catch (error) {
console.error('Upload failed:', error)
setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
const response = await ehApi.uploadEH(formData); setUploadProgress(90)
if (klausurId && response.id) { try { await ehApi.linkToKlausur(response.id, klausurId) } catch (linkError) { console.warn('Failed to auto-link EH:', linkError) } }
setUploadProgress(100); handleComplete(response.id)
} catch (error) { console.error('Upload failed:', error); setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen') }
finally { setUploading(false) }
}
// Render step content
const renderStepContent = () => {
switch (currentStep) {
case 'file':
return (
<div className="eh-wizard-step">
<h3>Erwartungshorizont hochladen</h3>
<p className="eh-wizard-description">
Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus.
Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.
</p>
<div className="eh-file-drop">
<input
type="file"
accept=".pdf"
onChange={handleFileSelect}
id="eh-file-input"
/>
<label htmlFor="eh-file-input" className="eh-file-label">
{selectedFile ? (
<>
<span className="eh-file-icon">&#128196;</span>
<span className="eh-file-name">{selectedFile.name}</span>
<span className="eh-file-size">
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
</>
) : (
<>
<span className="eh-file-icon">&#128194;</span>
<span>PDF-Datei hier ablegen oder klicken</span>
</>
)}
</label>
</div>
{fileError && <p className="eh-error">{fileError}</p>}
{!encryptionSupported && (
<p className="eh-warning">
Ihr Browser unterstuetzt keine Verschluesselung.
Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge).
</p>
)}
</div>
)
case 'metadata':
return (
<div className="eh-wizard-step">
<h3>Metadaten</h3>
<p className="eh-wizard-description">
Geben Sie Informationen zum Erwartungshorizont ein.
</p>
<div className="eh-form-group">
<label htmlFor="eh-title">Titel *</label>
<input
id="eh-title"
type="text"
value={metadata.title}
onChange={e => setMetadata(prev => ({ ...prev, title: e.target.value }))}
placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1"
/>
</div>
<div className="eh-form-row">
<div className="eh-form-group">
<label htmlFor="eh-subject">Fach *</label>
<select
id="eh-subject"
value={metadata.subject}
onChange={e => setMetadata(prev => ({ ...prev, subject: e.target.value }))}
>
{SUBJECTS.map(s => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
<div className="eh-form-group">
<label htmlFor="eh-niveau">Niveau *</label>
<select
id="eh-niveau"
value={metadata.niveau}
onChange={e => setMetadata(prev => ({ ...prev, niveau: e.target.value as 'eA' | 'gA' }))}
>
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
</select>
</div>
</div>
<div className="eh-form-row">
<div className="eh-form-group">
<label htmlFor="eh-year">Jahr *</label>
<input
id="eh-year"
type="number"
min={2000}
max={2050}
value={metadata.year}
onChange={e => setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))}
/>
</div>
<div className="eh-form-group">
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
<input
id="eh-aufgabe"
type="text"
value={metadata.aufgaben_nummer || ''}
onChange={e => setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))}
placeholder="z.B. 1a, 2.1"
/>
</div>
</div>
</div>
)
case 'rights':
return (
<div className="eh-wizard-step">
<h3>Rechte-Bestaetigung</h3>
<p className="eh-wizard-description">
Bitte lesen und bestaetigen Sie die folgenden Bedingungen.
</p>
<div className="eh-rights-box">
<pre>{RIGHTS_TEXT}</pre>
</div>
<div className="eh-checkbox-group">
<input
type="checkbox"
id="eh-rights-confirm"
checked={rightsConfirmed}
onChange={e => setRightsConfirmed(e.target.checked)}
/>
<label htmlFor="eh-rights-confirm">
Ich habe die Bedingungen gelesen und stimme ihnen zu.
</label>
</div>
<div className="eh-info-box">
<strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer
KI-Training verwendet. Er dient ausschliesslich als Referenz fuer
Ihre persoenlichen Korrekturvorschlaege.
</div>
</div>
)
case 'encryption':
return (
<div className="eh-wizard-step">
<h3>Verschluesselung</h3>
<p className="eh-wizard-description">
Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont.
Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.
</p>
<div className="eh-form-group">
<label htmlFor="eh-passphrase">Passwort *</label>
<div className="eh-password-input">
<input
id="eh-passphrase"
type={showPassphrase ? 'text' : 'password'}
value={passphrase}
onChange={e => setPassphrase(e.target.value)}
placeholder="Mindestens 8 Zeichen"
/>
<button
type="button"
className="eh-toggle-password"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? '&#128065;' : '&#128064;'}
</button>
</div>
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>
Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}
</div>
</div>
<div className="eh-form-group">
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
<input
id="eh-passphrase-confirm"
type={showPassphrase ? 'text' : 'password'}
value={passphraseConfirm}
onChange={e => setPassphraseConfirm(e.target.value)}
placeholder="Passwort wiederholen"
/>
{passphraseConfirm && passphrase !== passphraseConfirm && (
<p className="eh-error">Passwoerter stimmen nicht ueberein</p>
)}
</div>
<div className="eh-warning-box">
<strong>Achtung:</strong> Merken Sie sich dieses Passwort gut!
Ohne das Passwort kann der Erwartungshorizont nicht fuer
Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr
Passwort nicht wiederherstellen.
</div>
</div>
)
case 'summary':
return (
<div className="eh-wizard-step">
<h3>Zusammenfassung</h3>
<p className="eh-wizard-description">
Pruefen Sie Ihre Eingaben und starten Sie den Upload.
</p>
<div className="eh-summary-table">
<div className="eh-summary-row">
<span className="eh-summary-label">Datei:</span>
<span className="eh-summary-value">{selectedFile?.name}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Titel:</span>
<span className="eh-summary-value">{metadata.title}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Fach:</span>
<span className="eh-summary-value">
{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}
</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Niveau:</span>
<span className="eh-summary-value">{metadata.niveau}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Jahr:</span>
<span className="eh-summary-value">{metadata.year}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Verschluesselung:</span>
<span className="eh-summary-value">AES-256-GCM</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Rechte bestaetigt:</span>
<span className="eh-summary-value">Ja</span>
</div>
</div>
{uploading && (
<div className="eh-upload-progress">
<div
className="eh-progress-bar"
style={{ width: `${uploadProgress}%` }}
/>
<span>{uploadProgress}%</span>
</div>
)}
{uploadError && (
<p className="eh-error">{uploadError}</p>
)}
</div>
)
default:
return null
case 'file': return <FileStep selectedFile={selectedFile} fileError={fileError} encryptionSupported={encryptionSupported} onFileSelect={handleFileSelect} />
case 'metadata': return <MetadataStep metadata={metadata} onMetadataChange={setMetadata} />
case 'rights': return <RightsStep rightsConfirmed={rightsConfirmed} onRightsConfirmedChange={setRightsConfirmed} />
case 'encryption': return <EncryptionStep passphrase={passphrase} passphraseConfirm={passphraseConfirm} showPassphrase={showPassphrase} passphraseStrength={passphraseStrength} onPassphraseChange={setPassphrase} onPassphraseConfirmChange={setPassphraseConfirm} onToggleShow={() => setShowPassphrase(!showPassphrase)} />
case 'summary': return <SummaryStep selectedFile={selectedFile} metadata={metadata} uploading={uploading} uploadProgress={uploadProgress} uploadError={uploadError} />
default: return null
}
}
return (
<div className="eh-wizard-overlay">
<div className="eh-wizard-modal">
{/* Header */}
<div className="eh-wizard-header">
<h2>Erwartungshorizont hochladen</h2>
<button className="eh-wizard-close" onClick={onClose}>&times;</button>
</div>
{/* Progress */}
<div className="eh-wizard-progress">
{WIZARD_STEPS.map((step, index) => (
<div
key={step}
className={`eh-progress-step ${
index < currentStepIndex ? 'completed' :
index === currentStepIndex ? 'active' : ''
}`}
>
<div className="eh-progress-dot">
{index < currentStepIndex ? '\u2713' : index + 1}
</div>
<div key={step} className={`eh-progress-step ${index < currentStepIndex ? 'completed' : index === currentStepIndex ? 'active' : ''}`}>
<div className="eh-progress-dot">{index < currentStepIndex ? '\u2713' : index + 1}</div>
<span className="eh-progress-label">{STEP_LABELS[step]}</span>
</div>
))}
</div>
{/* Content */}
<div className="eh-wizard-content">
{renderStepContent()}
</div>
{/* Footer */}
<div className="eh-wizard-content">{renderStepContent()}</div>
<div className="eh-wizard-footer">
<button
className="eh-btn eh-btn-secondary"
onClick={isFirstStep ? onClose : goBack}
disabled={uploading}
>
{isFirstStep ? 'Abbrechen' : 'Zurueck'}
</button>
<button className="eh-btn eh-btn-secondary" onClick={isFirstStep ? onClose : goBack} disabled={uploading}>{isFirstStep ? 'Abbrechen' : 'Zurueck'}</button>
{isLastStep ? (
<button
className="eh-btn eh-btn-primary"
onClick={handleUpload}
disabled={uploading || !isStepValid(currentStep)}
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
</button>
<button className="eh-btn eh-btn-primary" onClick={handleUpload} disabled={uploading || !isStepValid(currentStep)}>{uploading ? 'Wird hochgeladen...' : 'Hochladen'}</button>
) : (
<button
className="eh-btn eh-btn-primary"
onClick={goNext}
disabled={!isStepValid(currentStep)}
>
Weiter
</button>
<button className="eh-btn eh-btn-primary" onClick={goNext} disabled={!isStepValid(currentStep)}>Weiter</button>
)}
</div>
</div>

View File

@@ -0,0 +1,168 @@
/**
* EH Upload Wizard Steps - Individual step content renderers
*/
import { useState } from 'react'
interface EHMetadata {
title: string
subject: string
niveau: 'eA' | 'gA'
year: number
aufgaben_nummer?: string
}
const SUBJECTS = [
'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie',
'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport',
'informatik', 'latein', 'franzoesisch', 'spanisch'
]
const RIGHTS_TEXT = `Ich bestaetige hiermit, dass:
1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem
Erwartungshorizont besitze.
2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet,
sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege
in meinem persoenlichen Arbeitsbereich.
3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter
keinen Zugriff auf den Klartext haben.
4. Ich diesen Erwartungshorizont jederzeit loeschen kann.`
// Step 1: File Selection
export function FileStep({ selectedFile, fileError, encryptionSupported, onFileSelect }: {
selectedFile: File | null; fileError: string | null; encryptionSupported: boolean;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
}) {
return (
<div className="eh-wizard-step">
<h3>Erwartungshorizont hochladen</h3>
<p className="eh-wizard-description">Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus. Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.</p>
<div className="eh-file-drop">
<input type="file" accept=".pdf" onChange={onFileSelect} id="eh-file-input" />
<label htmlFor="eh-file-input" className="eh-file-label">
{selectedFile ? (<><span className="eh-file-icon">&#128196;</span><span className="eh-file-name">{selectedFile.name}</span><span className="eh-file-size">({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)</span></>) : (<><span className="eh-file-icon">&#128194;</span><span>PDF-Datei hier ablegen oder klicken</span></>)}
</label>
</div>
{fileError && <p className="eh-error">{fileError}</p>}
{!encryptionSupported && <p className="eh-warning">Ihr Browser unterstuetzt keine Verschluesselung. Bitte verwenden Sie einen modernen Browser.</p>}
</div>
)
}
// Step 2: Metadata
export function MetadataStep({ metadata, onMetadataChange }: {
metadata: EHMetadata; onMetadataChange: (metadata: EHMetadata) => void
}) {
return (
<div className="eh-wizard-step">
<h3>Metadaten</h3>
<p className="eh-wizard-description">Geben Sie Informationen zum Erwartungshorizont ein.</p>
<div className="eh-form-group">
<label htmlFor="eh-title">Titel *</label>
<input id="eh-title" type="text" value={metadata.title} onChange={e => onMetadataChange({ ...metadata, title: e.target.value })} placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1" />
</div>
<div className="eh-form-row">
<div className="eh-form-group">
<label htmlFor="eh-subject">Fach *</label>
<select id="eh-subject" value={metadata.subject} onChange={e => onMetadataChange({ ...metadata, subject: e.target.value })}>
{SUBJECTS.map(s => (<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>))}
</select>
</div>
<div className="eh-form-group">
<label htmlFor="eh-niveau">Niveau *</label>
<select id="eh-niveau" value={metadata.niveau} onChange={e => onMetadataChange({ ...metadata, niveau: e.target.value as 'eA' | 'gA' })}>
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
</select>
</div>
</div>
<div className="eh-form-row">
<div className="eh-form-group">
<label htmlFor="eh-year">Jahr *</label>
<input id="eh-year" type="number" min={2000} max={2050} value={metadata.year} onChange={e => onMetadataChange({ ...metadata, year: parseInt(e.target.value) })} />
</div>
<div className="eh-form-group">
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
<input id="eh-aufgabe" type="text" value={metadata.aufgaben_nummer || ''} onChange={e => onMetadataChange({ ...metadata, aufgaben_nummer: e.target.value })} placeholder="z.B. 1a, 2.1" />
</div>
</div>
</div>
)
}
// Step 3: Rights Confirmation
export function RightsStep({ rightsConfirmed, onRightsConfirmedChange }: {
rightsConfirmed: boolean; onRightsConfirmedChange: (confirmed: boolean) => void
}) {
return (
<div className="eh-wizard-step">
<h3>Rechte-Bestaetigung</h3>
<p className="eh-wizard-description">Bitte lesen und bestaetigen Sie die folgenden Bedingungen.</p>
<div className="eh-rights-box"><pre>{RIGHTS_TEXT}</pre></div>
<div className="eh-checkbox-group">
<input type="checkbox" id="eh-rights-confirm" checked={rightsConfirmed} onChange={e => onRightsConfirmedChange(e.target.checked)} />
<label htmlFor="eh-rights-confirm">Ich habe die Bedingungen gelesen und stimme ihnen zu.</label>
</div>
<div className="eh-info-box"><strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer KI-Training verwendet. Er dient ausschliesslich als Referenz fuer Ihre persoenlichen Korrekturvorschlaege.</div>
</div>
)
}
// Step 4: Encryption
export function EncryptionStep({ passphrase, passphraseConfirm, showPassphrase, passphraseStrength, onPassphraseChange, onPassphraseConfirmChange, onToggleShow }: {
passphrase: string; passphraseConfirm: string; showPassphrase: boolean;
passphraseStrength: 'weak' | 'medium' | 'strong';
onPassphraseChange: (v: string) => void; onPassphraseConfirmChange: (v: string) => void; onToggleShow: () => void
}) {
return (
<div className="eh-wizard-step">
<h3>Verschluesselung</h3>
<p className="eh-wizard-description">Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont. Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.</p>
<div className="eh-form-group">
<label htmlFor="eh-passphrase">Passwort *</label>
<div className="eh-password-input">
<input id="eh-passphrase" type={showPassphrase ? 'text' : 'password'} value={passphrase} onChange={e => onPassphraseChange(e.target.value)} placeholder="Mindestens 8 Zeichen" />
<button type="button" className="eh-toggle-password" onClick={onToggleShow}>{showPassphrase ? '&#128065;' : '&#128064;'}</button>
</div>
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}</div>
</div>
<div className="eh-form-group">
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
<input id="eh-passphrase-confirm" type={showPassphrase ? 'text' : 'password'} value={passphraseConfirm} onChange={e => onPassphraseConfirmChange(e.target.value)} placeholder="Passwort wiederholen" />
{passphraseConfirm && passphrase !== passphraseConfirm && <p className="eh-error">Passwoerter stimmen nicht ueberein</p>}
</div>
<div className="eh-warning-box"><strong>Achtung:</strong> Merken Sie sich dieses Passwort gut! Ohne das Passwort kann der Erwartungshorizont nicht fuer Korrekturvorschlaege verwendet werden.</div>
</div>
)
}
// Step 5: Summary
export function SummaryStep({ selectedFile, metadata, uploading, uploadProgress, uploadError }: {
selectedFile: File | null; metadata: EHMetadata; uploading: boolean;
uploadProgress: number; uploadError: string | null
}) {
return (
<div className="eh-wizard-step">
<h3>Zusammenfassung</h3>
<p className="eh-wizard-description">Pruefen Sie Ihre Eingaben und starten Sie den Upload.</p>
<div className="eh-summary-table">
<div className="eh-summary-row"><span className="eh-summary-label">Datei:</span><span className="eh-summary-value">{selectedFile?.name}</span></div>
<div className="eh-summary-row"><span className="eh-summary-label">Titel:</span><span className="eh-summary-value">{metadata.title}</span></div>
<div className="eh-summary-row"><span className="eh-summary-label">Fach:</span><span className="eh-summary-value">{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}</span></div>
<div className="eh-summary-row"><span className="eh-summary-label">Niveau:</span><span className="eh-summary-value">{metadata.niveau}</span></div>
<div className="eh-summary-row"><span className="eh-summary-label">Jahr:</span><span className="eh-summary-value">{metadata.year}</span></div>
<div className="eh-summary-row"><span className="eh-summary-label">Verschluesselung:</span><span className="eh-summary-value">AES-256-GCM</span></div>
<div className="eh-summary-row"><span className="eh-summary-label">Rechte bestaetigt:</span><span className="eh-summary-value">Ja</span></div>
</div>
{uploading && (<div className="eh-upload-progress"><div className="eh-progress-bar" style={{ width: `${uploadProgress}%` }} /><span>{uploadProgress}%</span></div>)}
{uploadError && <p className="eh-error">{uploadError}</p>}
</div>
)
}
export { SUBJECTS, RIGHTS_TEXT }
export type { EHMetadata }

View File

@@ -0,0 +1,92 @@
/**
* BYOEH (Erwartungshorizont) Types
*
* Split from api.ts for file size compliance.
*/
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
}
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[]
}

View File

@@ -0,0 +1,98 @@
/**
* BYOEH (Erwartungshorizont) API
*
* Split from api.ts for file size compliance.
*/
import { apiCall, getAuthToken } from './api'
import type {
Erwartungshorizont, EHRAGResult, EHAuditEntry, EHKeyShare,
SharedEHInfo, LinkedEHInfo, PendingInvitationInfo, SentInvitationInfo,
EHAccessChain,
} from './api-eh-types'
export const ehApi = {
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}` : ''}`)
},
getEH: (id: string): Promise<Erwartungshorizont> => apiCall(`/eh/${id}`),
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()
},
deleteEH: (id: string): Promise<{ status: string; id: string }> =>
apiCall(`/eh/${id}`, { method: 'DELETE' }),
indexEH: (id: string, passphrase: string): Promise<{ status: string; id: string; chunk_count: number }> =>
apiCall(`/eh/${id}/index`, { method: 'POST', body: JSON.stringify({ passphrase }) }),
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 }) }),
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}` : ''}`)
},
getRightsText: (): Promise<{ text: string; version: string }> => apiCall('/eh/rights-text'),
getQdrantStatus: (): Promise<{ name: string; vectors_count: number; points_count: number; status: string }> =>
apiCall('/eh/qdrant-status'),
// Key Sharing
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) }),
listShares: (ehId: string): Promise<EHKeyShare[]> => apiCall(`/eh/${ehId}/shares`),
revokeShare: (ehId: string, shareId: string): Promise<{ status: string; share_id: string }> =>
apiCall(`/eh/${ehId}/shares/${shareId}`, { method: 'DELETE' }),
getSharedWithMe: (): Promise<SharedEHInfo[]> => apiCall('/eh/shared-with-me'),
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 }) }),
unlinkFromKlausur: (ehId: string, klausurId: string): Promise<{ status: string; eh_id: string; klausur_id: string }> =>
apiCall(`/eh/${ehId}/link-klausur/${klausurId}`, { method: 'DELETE' }),
// Invitation Flow
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) }),
getPendingInvitations: (): Promise<PendingInvitationInfo[]> => apiCall('/eh/invitations/pending'),
getSentInvitations: (): Promise<SentInvitationInfo[]> => apiCall('/eh/invitations/sent'),
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 }) }),
declineInvitation: (invitationId: string): Promise<{ status: string; invitation_id: string; eh_id: string }> =>
apiCall(`/eh/invitations/${invitationId}/decline`, { method: 'POST' }),
revokeInvitation: (invitationId: string): Promise<{ status: string; invitation_id: string; eh_id: string }> =>
apiCall(`/eh/invitations/${invitationId}`, { method: 'DELETE' }),
getAccessChain: (ehId: string): Promise<EHAccessChain> => apiCall(`/eh/${ehId}/access-chain`),
}
export const klausurEHApi = {
getLinkedEH: (klausurId: string): Promise<LinkedEHInfo[]> => apiCall(`/klausuren/${klausurId}/linked-eh`),
}

View File

@@ -1,620 +1,123 @@
// API Types
/**
* Klausur Service API - Core types and Klausur/Student API
*
* Split into:
* - api.ts (this file): Core types, auth, base API, klausurApi, uploadStudentWork
* - api-eh-types.ts: BYOEH type definitions
* - api-eh.ts: ehApi and klausurEHApi
*/
// Re-export EH types and API for backward compatibility
export type {
Erwartungshorizont, EHRAGResult, EHAuditEntry, EHKeyShare, EHKlausurLink,
SharedEHInfo, LinkedEHInfo, EHShareInvitation, PendingInvitationInfo,
SentInvitationInfo, EHAccessChain,
} from './api-eh-types'
export { ehApi, klausurEHApi } from './api-eh'
// ============================================================================
// Core 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
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
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>
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)
// ============================================================================
// Auth & Base API
// ============================================================================
export function getAuthToken(): string | null {
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
} catch { /* Cross-origin */ }
return localStorage.getItem('auth_token')
}
// Base API call
async function apiCall<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
export 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 (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'),
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' }),
getKlausur: (id: string): Promise<Klausur> =>
apiCall(`/klausuren/${id}`),
listStudents: (klausurId: string): Promise<StudentKlausur[]> => apiCall(`/klausuren/${klausurId}/students`),
deleteStudent: (studentId: string): Promise<{ success: boolean }> => apiCall(`/students/${studentId}`, { method: 'DELETE' }),
createKlausur: (data: Partial<Klausur>): Promise<Klausur> =>
apiCall('/klausuren', {
method: 'POST',
body: JSON.stringify(data)
}),
updateCriteria: (studentId: string, criterion: string, score: number, annotations?: string[]): Promise<StudentKlausur> =>
apiCall(`/students/${studentId}/criteria`, { method: 'PUT', body: JSON.stringify({ criterion, score, annotations }) }),
updateKlausur: (id: string, data: Partial<Klausur>): Promise<Klausur> =>
apiCall(`/klausuren/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
}),
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) }),
deleteKlausur: (id: string): Promise<{ success: boolean }> =>
apiCall(`/klausuren/${id}`, { method: 'DELETE' }),
finalizeStudent: (studentId: string): Promise<StudentKlausur> => apiCall(`/students/${studentId}/finalize`, { method: 'POST' }),
// Students
listStudents: (klausurId: string): Promise<StudentKlausur[]> =>
apiCall(`/klausuren/${klausurId}/students`),
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' }) }),
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
}> =>
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
}>> =>
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')
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()
// ============================================================================
// File Upload
// ============================================================================
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 (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`)
}