fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
591
klausur-service/frontend/src/components/EHUploadWizard.tsx
Normal file
591
klausur-service/frontend/src/components/EHUploadWizard.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
interface EHUploadWizardProps {
|
||||
onClose: () => void
|
||||
onComplete?: (ehId: string) => void
|
||||
onSuccess?: (ehId: string) => void // Legacy alias for onComplete
|
||||
klausurSubject?: string
|
||||
klausurYear?: number
|
||||
defaultSubject?: string // Alias for klausurSubject
|
||||
defaultYear?: number // Alias for klausurYear
|
||||
klausurId?: string // If provided, automatically link EH to this Klausur
|
||||
}
|
||||
|
||||
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 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
|
||||
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 [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')
|
||||
}
|
||||
}, [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 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
|
||||
}
|
||||
}, [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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload handler
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !encryptionSupported) return
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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">📄</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">📂</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 ? '👁' : '👀'}
|
||||
</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
|
||||
}
|
||||
}
|
||||
|
||||
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}>×</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>
|
||||
<span className="eh-progress-label">{STEP_LABELS[step]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="eh-wizard-content">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="eh-wizard-footer">
|
||||
<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={goNext}
|
||||
disabled={!isStepValid(currentStep)}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EHUploadWizard
|
||||
38
klausur-service/frontend/src/components/Layout.tsx
Normal file
38
klausur-service/frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const handleBackToStudio = () => {
|
||||
// Navigate back to Studio
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'CLOSE_KLAUSUR_SERVICE' }, '*')
|
||||
} else {
|
||||
window.location.href = '/studio'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<div className="main-content">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<div className="brand-logo">BP</div>
|
||||
<div className="brand-text">
|
||||
<span className="brand-text-main">BreakPilot</span>
|
||||
<span className="brand-text-sub">Klausur-Korrektur</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<button className="btn btn-ghost" onClick={handleBackToStudio}>
|
||||
Zurueck zum Studio
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
klausur-service/frontend/src/components/RAGSearchPanel.tsx
Normal file
255
klausur-service/frontend/src/components/RAGSearchPanel.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* RAGSearchPanel Component
|
||||
*
|
||||
* Enhanced RAG search panel for teachers to query their Erwartungshorizonte.
|
||||
* Features:
|
||||
* - search_info display (which RAG features were active)
|
||||
* - Confidence indicator based on re-ranking scores
|
||||
* - Toggle for "Enhanced Search" with advanced options
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { ehApi, EHRAGResult } from '../services/api'
|
||||
|
||||
interface RAGSearchPanelProps {
|
||||
onClose: () => void
|
||||
defaultSubject?: string
|
||||
passphrase: string
|
||||
}
|
||||
|
||||
interface SearchOptions {
|
||||
rerank: boolean
|
||||
limit: number
|
||||
}
|
||||
|
||||
// Confidence level based on score
|
||||
type ConfidenceLevel = 'high' | 'medium' | 'low'
|
||||
|
||||
function getConfidenceLevel(score: number): ConfidenceLevel {
|
||||
if (score >= 0.8) return 'high'
|
||||
if (score >= 0.5) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function getConfidenceColor(level: ConfidenceLevel): string {
|
||||
switch (level) {
|
||||
case 'high': return 'var(--bp-success, #22c55e)'
|
||||
case 'medium': return 'var(--bp-warning, #f59e0b)'
|
||||
case 'low': return 'var(--bp-danger, #ef4444)'
|
||||
}
|
||||
}
|
||||
|
||||
function getConfidenceLabel(level: ConfidenceLevel): string {
|
||||
switch (level) {
|
||||
case 'high': return 'Hohe Relevanz'
|
||||
case 'medium': return 'Mittlere Relevanz'
|
||||
case 'low': return 'Geringe Relevanz'
|
||||
}
|
||||
}
|
||||
|
||||
export default function RAGSearchPanel({ onClose, defaultSubject, passphrase }: RAGSearchPanelProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [results, setResults] = useState<EHRAGResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Enhanced search options
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
|
||||
const [options, setOptions] = useState<SearchOptions>({
|
||||
rerank: true, // Default: enabled for better results
|
||||
limit: 5
|
||||
})
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!query.trim() || !passphrase) return
|
||||
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await ehApi.ragQuery({
|
||||
query_text: query,
|
||||
passphrase: passphrase,
|
||||
subject: defaultSubject,
|
||||
limit: options.limit,
|
||||
rerank: options.rerank
|
||||
})
|
||||
setResults(result)
|
||||
} catch (err) {
|
||||
console.error('RAG search failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [query, passphrase, defaultSubject, options])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rag-search-overlay">
|
||||
<div className="rag-search-modal">
|
||||
{/* Header */}
|
||||
<div className="rag-search-header">
|
||||
<h2>Erwartungshorizont durchsuchen</h2>
|
||||
<button className="rag-search-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="rag-search-input-container">
|
||||
<textarea
|
||||
className="rag-search-input"
|
||||
placeholder="Suchen Sie nach relevanten Abschnitten im Erwartungshorizont..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
className="rag-search-btn"
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
{searching ? 'Sucht...' : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<div className="rag-advanced-toggle">
|
||||
<button
|
||||
className="rag-toggle-btn"
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
>
|
||||
{showAdvancedOptions ? '▼' : '▶'} Erweiterte Optionen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options Panel */}
|
||||
{showAdvancedOptions && (
|
||||
<div className="rag-advanced-options">
|
||||
<div className="rag-option-row">
|
||||
<label className="rag-option-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.rerank}
|
||||
onChange={(e) => setOptions(prev => ({ ...prev, rerank: e.target.checked }))}
|
||||
/>
|
||||
<span className="rag-option-text">
|
||||
Re-Ranking aktivieren
|
||||
<span className="rag-option-hint">
|
||||
(Cross-Encoder fuer hoehere Genauigkeit)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rag-option-row">
|
||||
<label className="rag-option-label">
|
||||
Anzahl Ergebnisse:
|
||||
<select
|
||||
value={options.limit}
|
||||
onChange={(e) => setOptions(prev => ({ ...prev, limit: Number(e.target.value) }))}
|
||||
className="rag-option-select"
|
||||
>
|
||||
<option value={3}>3</option>
|
||||
<option value={5}>5</option>
|
||||
<option value={10}>10</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rag-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<div className="rag-results">
|
||||
{/* Search Info Badge */}
|
||||
{results.search_info && (
|
||||
<div className="rag-search-info">
|
||||
<span className="rag-info-badge">
|
||||
{results.search_info.rerank_applied && (
|
||||
<span className="rag-info-tag rag-info-rerank">Re-Ranked</span>
|
||||
)}
|
||||
{results.search_info.hybrid_search_applied && (
|
||||
<span className="rag-info-tag rag-info-hybrid">Hybrid Search</span>
|
||||
)}
|
||||
{results.search_info.embedding_model && (
|
||||
<span className="rag-info-tag rag-info-model">
|
||||
{results.search_info.embedding_model.split('/').pop()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="rag-info-count">
|
||||
{results.search_info.total_candidates} Kandidaten gefiltert
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Summary */}
|
||||
{results.context && (
|
||||
<div className="rag-context-summary">
|
||||
<h4>Zusammengefasster Kontext</h4>
|
||||
<p>{results.context}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Results */}
|
||||
<div className="rag-sources">
|
||||
<h4>Relevante Abschnitte ({results.sources.length})</h4>
|
||||
{results.sources.map((source, index) => {
|
||||
const confidence = getConfidenceLevel(source.score)
|
||||
return (
|
||||
<div key={`${source.eh_id}-${source.chunk_index}`} className="rag-source-item">
|
||||
<div className="rag-source-header">
|
||||
<span className="rag-source-rank">#{index + 1}</span>
|
||||
<span className="rag-source-title">{source.eh_title}</span>
|
||||
<span
|
||||
className="rag-confidence-badge"
|
||||
style={{
|
||||
backgroundColor: getConfidenceColor(confidence),
|
||||
color: 'white'
|
||||
}}
|
||||
title={`Score: ${(source.score * 100).toFixed(1)}%`}
|
||||
>
|
||||
{getConfidenceLabel(confidence)}
|
||||
{source.reranked && <span className="rag-reranked-icon" title="Re-ranked">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rag-source-text">
|
||||
{source.text}
|
||||
</div>
|
||||
<div className="rag-source-meta">
|
||||
<span>Chunk #{source.chunk_index}</span>
|
||||
<span>Score: {(source.score * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!results && !error && !searching && (
|
||||
<div className="rag-empty-state">
|
||||
<div className="rag-empty-icon">🔍</div>
|
||||
<p>Geben Sie eine Suchanfrage ein, um relevante Abschnitte aus Ihren Erwartungshorizonten zu finden.</p>
|
||||
<p className="rag-empty-hint">
|
||||
Tipp: Aktivieren Sie "Re-Ranking" fuer praezisere Ergebnisse.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user