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>
1482 lines
38 KiB
Python
1482 lines
38 KiB
Python
"""
|
|
BreakPilot Studio - Klausurkorrektur Modul
|
|
|
|
Funktionen:
|
|
- Klausuren hochladen (PDF/Bilder)
|
|
- OCR-Verarbeitung
|
|
- Automatische Bewertung mit AI
|
|
- Pipeline-Visualisierung
|
|
- Review-Interface
|
|
- Export (PDF/CSV/Excel)
|
|
"""
|
|
|
|
|
|
class CorrectionModule:
|
|
"""Modul fuer Klausurkorrektur mit OCR und AI-Bewertung."""
|
|
|
|
@staticmethod
|
|
def get_css() -> str:
|
|
"""CSS fuer das Correction-Modul."""
|
|
return """
|
|
/* =============================================
|
|
CORRECTION MODULE - Klausurkorrektur
|
|
============================================= */
|
|
|
|
/* Panel Layout */
|
|
.panel-correction {
|
|
display: none;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.panel-correction.active {
|
|
display: flex;
|
|
}
|
|
|
|
/* Header */
|
|
.correction-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.correction-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.correction-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* Content Layout */
|
|
.correction-content {
|
|
display: flex;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Linke Spalte - Setup */
|
|
.correction-setup-panel {
|
|
width: 320px;
|
|
border-right: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg-secondary);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.correction-section {
|
|
padding: 20px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.correction-section h3 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
/* Upload Area */
|
|
.correction-upload-area {
|
|
border: 2px dashed var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 32px 24px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.correction-upload-area:hover {
|
|
border-color: var(--accent-primary);
|
|
background: rgba(59, 130, 246, 0.05);
|
|
}
|
|
|
|
.correction-upload-area.dragover {
|
|
border-color: var(--accent-primary);
|
|
background: rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.correction-upload-area .upload-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.correction-upload-area h3 {
|
|
margin-bottom: 8px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.correction-upload-area p {
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.correction-upload-area .file-types {
|
|
margin-top: 12px;
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
/* Form Fields */
|
|
.correction-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.correction-form label {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.correction-form input,
|
|
.correction-form select {
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.correction-form input:focus,
|
|
.correction-form select:focus {
|
|
outline: none;
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.correction-form .form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.correction-form .form-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.correction-form .form-row .form-group {
|
|
flex: 1;
|
|
}
|
|
|
|
/* Start Button */
|
|
.correction-start-btn {
|
|
padding: 14px 24px;
|
|
background: var(--accent-primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.correction-start-btn:hover:not(:disabled) {
|
|
background: var(--accent-hover);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.correction-start-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Mittlere Spalte - Pipeline */
|
|
.correction-pipeline-panel {
|
|
width: 200px;
|
|
border-right: 1px solid var(--border-color);
|
|
padding: 24px 16px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.pipeline-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text-tertiary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.pipeline-steps {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.pipeline-step {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.pipeline-step-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-tertiary);
|
|
font-size: 14px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.pipeline-step-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.pipeline-step-label {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.pipeline-step-status {
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.pipeline-step.active {
|
|
background: rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.pipeline-step.active .pipeline-step-icon {
|
|
background: var(--accent-primary);
|
|
color: white;
|
|
animation: pulse 1.5s infinite;
|
|
}
|
|
|
|
.pipeline-step.completed .pipeline-step-icon {
|
|
background: #10b981;
|
|
color: white;
|
|
}
|
|
|
|
.pipeline-step.error .pipeline-step-icon {
|
|
background: #ef4444;
|
|
color: white;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { transform: scale(1); opacity: 1; }
|
|
50% { transform: scale(1.05); opacity: 0.8; }
|
|
}
|
|
|
|
/* Rechte Spalte - Results/Review */
|
|
.correction-results-panel {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.results-header {
|
|
padding: 16px 24px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.results-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.results-stats {
|
|
display: flex;
|
|
gap: 24px;
|
|
}
|
|
|
|
.stat-item {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 11px;
|
|
color: var(--text-tertiary);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* Results Content */
|
|
.correction-results {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
.results-placeholder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.results-placeholder .icon {
|
|
font-size: 64px;
|
|
margin-bottom: 16px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.results-placeholder h3 {
|
|
margin-bottom: 8px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* Exam List */
|
|
.exam-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.exam-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 16px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border-color);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.exam-item:hover {
|
|
border-color: var(--accent-primary);
|
|
transform: translateX(4px);
|
|
}
|
|
|
|
.exam-item.active {
|
|
border-color: var(--accent-primary);
|
|
background: rgba(59, 130, 246, 0.05);
|
|
}
|
|
|
|
.exam-thumb {
|
|
width: 60px;
|
|
height: 80px;
|
|
border-radius: 6px;
|
|
background: var(--bg-tertiary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.exam-thumb img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.exam-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.exam-name {
|
|
font-weight: 500;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.exam-meta {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.exam-grade {
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.exam-grade.excellent {
|
|
background: rgba(16, 185, 129, 0.1);
|
|
color: #10b981;
|
|
}
|
|
|
|
.exam-grade.good {
|
|
background: rgba(59, 130, 246, 0.1);
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.exam-grade.average {
|
|
background: rgba(245, 158, 11, 0.1);
|
|
color: #f59e0b;
|
|
}
|
|
|
|
.exam-grade.poor {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: #ef4444;
|
|
}
|
|
|
|
/* Review Interface */
|
|
.review-interface {
|
|
display: none;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.review-interface.active {
|
|
display: flex;
|
|
}
|
|
|
|
.review-left {
|
|
flex: 1;
|
|
border-right: 1px solid var(--border-color);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.review-image-container {
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding: 16px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: flex-start;
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.review-image {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.review-right {
|
|
width: 400px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.review-section {
|
|
padding: 20px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.review-section-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text-tertiary);
|
|
text-transform: uppercase;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
/* OCR Text */
|
|
.ocr-text-container {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
font-family: 'Monaco', 'Consolas', monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Answer Sections */
|
|
.answer-section {
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.answer-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.answer-task {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.answer-points {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.answer-points input {
|
|
width: 50px;
|
|
padding: 6px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.answer-text {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 12px;
|
|
padding: 12px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.ai-feedback {
|
|
font-size: 12px;
|
|
color: var(--text-tertiary);
|
|
padding: 10px;
|
|
background: rgba(59, 130, 246, 0.05);
|
|
border-left: 3px solid var(--accent-primary);
|
|
border-radius: 0 6px 6px 0;
|
|
}
|
|
|
|
/* Total Score */
|
|
.total-score-section {
|
|
padding: 20px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.total-score {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px;
|
|
background: var(--bg-primary);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.total-score-label {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.total-score-value {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.grade-select {
|
|
padding: 10px 16px;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
border: 2px solid var(--accent-primary);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
color: var(--accent-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Review Actions */
|
|
.review-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
border-top: 1px solid var(--border-color);
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.review-actions .btn {
|
|
flex: 1;
|
|
}
|
|
|
|
/* Export Options */
|
|
.export-options {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.export-btn {
|
|
padding: 8px 16px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.export-btn:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.export-btn .icon {
|
|
margin-right: 6px;
|
|
}
|
|
|
|
/* Empty State */
|
|
.correction-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
padding: 48px;
|
|
text-align: center;
|
|
}
|
|
|
|
.correction-empty .icon {
|
|
font-size: 80px;
|
|
margin-bottom: 24px;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.correction-empty h2 {
|
|
margin-bottom: 12px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.correction-empty p {
|
|
color: var(--text-tertiary);
|
|
max-width: 400px;
|
|
}
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_html() -> str:
|
|
"""HTML fuer das Correction-Modul."""
|
|
return """
|
|
<!-- Correction Panel -->
|
|
<div id="panel-correction" class="panel-correction">
|
|
<!-- Header -->
|
|
<div class="correction-header">
|
|
<div class="correction-title">Klausurkorrektur</div>
|
|
<div class="correction-actions">
|
|
<div class="export-options">
|
|
<button class="export-btn" onclick="exportCorrectionResults('pdf')">
|
|
<span class="icon">📄</span>PDF
|
|
</button>
|
|
<button class="export-btn" onclick="exportCorrectionResults('csv')">
|
|
<span class="icon">📊</span>CSV
|
|
</button>
|
|
<button class="export-btn" onclick="exportCorrectionResults('excel')">
|
|
<span class="icon">📗</span>Excel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="correction-content">
|
|
<!-- Linke Spalte: Setup -->
|
|
<div class="correction-setup-panel">
|
|
<!-- Upload Section -->
|
|
<div class="correction-section">
|
|
<h3>Klausuren hochladen</h3>
|
|
<div class="correction-upload-area" id="exam-upload-area">
|
|
<div class="upload-icon">📝</div>
|
|
<h3>Klausuren hochladen</h3>
|
|
<p>Dateien hierher ziehen oder klicken</p>
|
|
<div class="file-types">PDF, JPG, PNG - max. 50MB</div>
|
|
<input type="file" id="exam-file-input" multiple accept=".pdf,.jpg,.jpeg,.png" hidden>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Exam Info -->
|
|
<div class="correction-section">
|
|
<h3>Klausur-Informationen</h3>
|
|
<div class="correction-form">
|
|
<div class="form-group">
|
|
<label>Fach</label>
|
|
<select id="exam-subject">
|
|
<option value="">Fach waehlen...</option>
|
|
<option value="deutsch">Deutsch</option>
|
|
<option value="mathe">Mathematik</option>
|
|
<option value="englisch">Englisch</option>
|
|
<option value="physik">Physik</option>
|
|
<option value="chemie">Chemie</option>
|
|
<option value="biologie">Biologie</option>
|
|
<option value="geschichte">Geschichte</option>
|
|
<option value="erdkunde">Erdkunde</option>
|
|
<option value="andere">Andere</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Klasse</label>
|
|
<input type="text" id="exam-class" placeholder="z.B. 10a">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Max. Punkte</label>
|
|
<input type="number" id="exam-max-points" placeholder="100" value="100">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Titel/Thema</label>
|
|
<input type="text" id="exam-title" placeholder="z.B. Klassenarbeit Nr. 3">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Start Button -->
|
|
<div class="correction-section">
|
|
<button class="correction-start-btn" id="start-correction-btn" disabled onclick="startCorrectionJob()">
|
|
Korrektur starten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mittlere Spalte: Pipeline -->
|
|
<div class="correction-pipeline-panel">
|
|
<div class="pipeline-title">Verarbeitungs-Pipeline</div>
|
|
<div class="pipeline-steps">
|
|
<div class="pipeline-step" data-step="upload">
|
|
<div class="pipeline-step-icon">📤</div>
|
|
<div class="pipeline-step-content">
|
|
<div class="pipeline-step-label">Upload</div>
|
|
<div class="pipeline-step-status">Dateien hochladen</div>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-step" data-step="preprocess">
|
|
<div class="pipeline-step-icon">🔧</div>
|
|
<div class="pipeline-step-content">
|
|
<div class="pipeline-step-label">Vorverarbeitung</div>
|
|
<div class="pipeline-step-status">Bildoptimierung</div>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-step" data-step="ocr">
|
|
<div class="pipeline-step-icon">👁️</div>
|
|
<div class="pipeline-step-content">
|
|
<div class="pipeline-step-label">OCR</div>
|
|
<div class="pipeline-step-status">Texterkennung</div>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-step" data-step="segment">
|
|
<div class="pipeline-step-icon">✂️</div>
|
|
<div class="pipeline-step-content">
|
|
<div class="pipeline-step-label">Segmentierung</div>
|
|
<div class="pipeline-step-status">Aufgaben erkennen</div>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-step" data-step="grade">
|
|
<div class="pipeline-step-icon">🤖</div>
|
|
<div class="pipeline-step-content">
|
|
<div class="pipeline-step-label">AI-Bewertung</div>
|
|
<div class="pipeline-step-status">Automatisch benoten</div>
|
|
</div>
|
|
</div>
|
|
<div class="pipeline-step" data-step="review">
|
|
<div class="pipeline-step-icon">✅</div>
|
|
<div class="pipeline-step-content">
|
|
<div class="pipeline-step-label">Review</div>
|
|
<div class="pipeline-step-status">Ueberpruefen</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Rechte Spalte: Results -->
|
|
<div class="correction-results-panel">
|
|
<div class="results-header">
|
|
<div class="results-title">Ergebnisse</div>
|
|
<div class="results-stats">
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-total">0</div>
|
|
<div class="stat-label">Klausuren</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-corrected">0</div>
|
|
<div class="stat-label">Korrigiert</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-average">-</div>
|
|
<div class="stat-label">Durchschnitt</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results/Review Content -->
|
|
<div class="correction-results" id="correction-results">
|
|
<div class="correction-empty">
|
|
<div class="icon">📝</div>
|
|
<h2>Keine Klausuren geladen</h2>
|
|
<p>Lade Klausuren hoch und starte die automatische Korrektur, um Ergebnisse zu sehen.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Review Interface (hidden by default) -->
|
|
<div class="review-interface" id="review-interface">
|
|
<div class="review-left">
|
|
<div class="review-image-container">
|
|
<img id="review-image" class="review-image" src="" alt="Klausur">
|
|
</div>
|
|
</div>
|
|
<div class="review-right">
|
|
<!-- OCR Text -->
|
|
<div class="review-section">
|
|
<div class="review-section-title">Erkannter Text (OCR)</div>
|
|
<div class="ocr-text-container" id="ocr-text">
|
|
Hier erscheint der erkannte Text...
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Answers -->
|
|
<div class="review-section">
|
|
<div class="review-section-title">Antworten & Bewertung</div>
|
|
<div id="answers-container">
|
|
<!-- Wird dynamisch gefuellt -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Total Score -->
|
|
<div class="total-score-section">
|
|
<div class="total-score">
|
|
<div>
|
|
<div class="total-score-label">Gesamtpunkte</div>
|
|
<div class="total-score-value" id="total-points">0 / 100</div>
|
|
</div>
|
|
<select class="grade-select" id="grade-select">
|
|
<option value="1">1</option>
|
|
<option value="2">2</option>
|
|
<option value="3">3</option>
|
|
<option value="4">4</option>
|
|
<option value="5">5</option>
|
|
<option value="6">6</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Review Actions -->
|
|
<div class="review-actions">
|
|
<button class="btn btn-secondary" onclick="previousExam()">Zurueck</button>
|
|
<button class="btn btn-primary" onclick="saveAndNextExam()">Speichern & Weiter</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
@staticmethod
|
|
def get_js() -> str:
|
|
"""JavaScript fuer das Correction-Modul."""
|
|
return """
|
|
// =============================================
|
|
// CORRECTION MODULE - Klausurkorrektur
|
|
// =============================================
|
|
|
|
let correctionInitialized = false;
|
|
let currentExamJob = null;
|
|
let examUploadedFiles = [];
|
|
let correctedExams = [];
|
|
let currentExamIndex = 0;
|
|
|
|
function loadCorrectionModule() {
|
|
if (correctionInitialized) {
|
|
console.log('Correction module already initialized');
|
|
return;
|
|
}
|
|
|
|
console.log('Loading Correction Module...');
|
|
|
|
// DOM Elements
|
|
const uploadArea = document.getElementById('exam-upload-area');
|
|
const fileInput = document.getElementById('exam-file-input');
|
|
const startBtn = document.getElementById('start-correction-btn');
|
|
const resultsContainer = document.getElementById('correction-results');
|
|
const reviewInterface = document.getElementById('review-interface');
|
|
|
|
// --- Upload Handler ---
|
|
if (uploadArea && fileInput) {
|
|
uploadArea.addEventListener('click', () => fileInput.click());
|
|
|
|
uploadArea.addEventListener('dragover', (ev) => {
|
|
ev.preventDefault();
|
|
uploadArea.classList.add('dragover');
|
|
});
|
|
|
|
uploadArea.addEventListener('dragleave', () => {
|
|
uploadArea.classList.remove('dragover');
|
|
});
|
|
|
|
uploadArea.addEventListener('drop', (ev) => {
|
|
ev.preventDefault();
|
|
uploadArea.classList.remove('dragover');
|
|
handleExamFiles(ev.dataTransfer.files);
|
|
});
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
handleExamFiles(fileInput.files);
|
|
});
|
|
}
|
|
|
|
correctionInitialized = true;
|
|
console.log('Correction Module loaded successfully');
|
|
}
|
|
|
|
// Handle exam file upload
|
|
function handleExamFiles(files) {
|
|
if (!files.length) return;
|
|
|
|
examUploadedFiles = Array.from(files);
|
|
console.log(`${examUploadedFiles.length} Dateien fuer Klausur-Korrektur ausgewaehlt`);
|
|
|
|
// Update UI
|
|
const uploadArea = document.getElementById('exam-upload-area');
|
|
if (uploadArea) {
|
|
uploadArea.innerHTML = `
|
|
<div style="color: #10b981; font-size: 48px;">✓</div>
|
|
<h3>${examUploadedFiles.length} Datei(en) ausgewaehlt</h3>
|
|
<p>${examUploadedFiles.map(f => f.name).slice(0, 3).join(', ')}${examUploadedFiles.length > 3 ? '...' : ''}</p>
|
|
<button onclick="document.getElementById('exam-file-input').click()"
|
|
style="margin-top: 16px; padding: 8px 16px; background: var(--bg-tertiary); border: none; border-radius: 8px; color: var(--text-primary); cursor: pointer;">
|
|
Andere Dateien waehlen
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
// Enable start button
|
|
const startBtn = document.getElementById('start-correction-btn');
|
|
if (startBtn) {
|
|
startBtn.disabled = false;
|
|
}
|
|
|
|
// Update stats
|
|
document.getElementById('stat-total').textContent = examUploadedFiles.length;
|
|
}
|
|
|
|
// Start correction job - connects to /api/corrections API
|
|
async function startCorrectionJob() {
|
|
if (!examUploadedFiles.length) {
|
|
alert('Bitte waehle zuerst Dateien aus.');
|
|
return;
|
|
}
|
|
|
|
const subject = document.getElementById('exam-subject')?.value || 'unbekannt';
|
|
const className = document.getElementById('exam-class')?.value || '';
|
|
const title = document.getElementById('exam-title')?.value || 'Klausur';
|
|
const maxPoints = parseInt(document.getElementById('exam-max-points')?.value) || 100;
|
|
|
|
console.log('Starte Korrektur-Job:', { subject, className, title, files: examUploadedFiles.length });
|
|
|
|
// Show pipeline progress
|
|
updatePipelineStep('upload', 'active');
|
|
|
|
// Reset state
|
|
correctedExams = [];
|
|
currentExamIndex = 0;
|
|
|
|
try {
|
|
// Process each file: create correction -> upload -> analyze
|
|
for (let i = 0; i < examUploadedFiles.length; i++) {
|
|
const file = examUploadedFiles[i];
|
|
console.log(`Verarbeite Datei ${i + 1}/${examUploadedFiles.length}: ${file.name}`);
|
|
|
|
// Step 1: Create correction entry
|
|
const createResp = await fetch('/api/corrections/', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
student_id: `student-${i + 1}`,
|
|
student_name: file.name.replace(/\\.[^.]+$/, ''), // Use filename as student name
|
|
class_name: className,
|
|
exam_title: title,
|
|
subject: subject,
|
|
max_points: maxPoints
|
|
})
|
|
});
|
|
|
|
if (!createResp.ok) {
|
|
throw new Error(`Korrektur erstellen fehlgeschlagen fuer ${file.name}`);
|
|
}
|
|
|
|
const createResult = await createResp.json();
|
|
if (!createResult.success) {
|
|
throw new Error(createResult.error || 'Korrektur erstellen fehlgeschlagen');
|
|
}
|
|
|
|
const correctionId = createResult.correction.id;
|
|
updatePipelineStep('preprocess', 'active');
|
|
updatePipelineStep('upload', 'completed');
|
|
|
|
// Step 2: Upload file
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const uploadResp = await fetch(`/api/corrections/${correctionId}/upload`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!uploadResp.ok) {
|
|
throw new Error(`Upload fehlgeschlagen fuer ${file.name}`);
|
|
}
|
|
|
|
updatePipelineStep('ocr', 'active');
|
|
updatePipelineStep('preprocess', 'completed');
|
|
|
|
// Step 3: Poll for OCR completion
|
|
let correction = await pollForOCRCompletion(correctionId);
|
|
|
|
updatePipelineStep('segment', 'active');
|
|
updatePipelineStep('ocr', 'completed');
|
|
|
|
// Step 4: Analyze
|
|
updatePipelineStep('grade', 'active');
|
|
updatePipelineStep('segment', 'completed');
|
|
|
|
const analyzeResp = await fetch(`/api/corrections/${correctionId}/analyze`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
if (analyzeResp.ok) {
|
|
const analysisResult = await analyzeResp.json();
|
|
if (analysisResult.success) {
|
|
// Refresh correction data
|
|
const getResp = await fetch(`/api/corrections/${correctionId}`);
|
|
if (getResp.ok) {
|
|
const getResult = await getResp.json();
|
|
if (getResult.success) {
|
|
correction = getResult.correction;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add to results
|
|
correctedExams.push({
|
|
id: correction.id,
|
|
filename: file.name,
|
|
student_name: correction.student_name,
|
|
points: correction.total_points,
|
|
max_points: correction.max_points,
|
|
percentage: correction.percentage,
|
|
grade: correction.grade || _calculateLocalGrade(correction.percentage),
|
|
ocr_text: correction.extracted_text || '',
|
|
answers: (correction.evaluations || []).map((e, idx) => ({
|
|
task: `Aufgabe ${e.question_number}`,
|
|
points: e.points_awarded,
|
|
max: e.points_possible,
|
|
text: e.extracted_text,
|
|
feedback: e.feedback
|
|
})),
|
|
image_url: correction.file_path ? `/static/corrections/${correction.id}` : null
|
|
});
|
|
|
|
// Update progress
|
|
document.getElementById('stat-corrected').textContent = correctedExams.length;
|
|
}
|
|
|
|
// All done
|
|
updatePipelineStep('review', 'active');
|
|
updatePipelineStep('grade', 'completed');
|
|
|
|
showCorrectionResults(correctedExams);
|
|
|
|
updatePipelineStep('review', 'completed');
|
|
|
|
} catch (e) {
|
|
console.error('Korrektur-Job fehlgeschlagen:', e);
|
|
alert('Korrektur fehlgeschlagen: ' + e.message);
|
|
// Mark current step as error
|
|
document.querySelectorAll('.pipeline-step.active').forEach(step => {
|
|
step.classList.remove('active');
|
|
step.classList.add('error');
|
|
});
|
|
}
|
|
}
|
|
|
|
// Poll for OCR completion
|
|
async function pollForOCRCompletion(correctionId, maxAttempts = 30) {
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
|
|
const resp = await fetch(`/api/corrections/${correctionId}`);
|
|
if (!resp.ok) continue;
|
|
|
|
const result = await resp.json();
|
|
if (!result.success) continue;
|
|
|
|
const correction = result.correction;
|
|
|
|
// Check status
|
|
if (correction.status === 'ocr_complete' || correction.status === 'analyzed' || correction.status === 'completed') {
|
|
return correction;
|
|
}
|
|
|
|
if (correction.status === 'error') {
|
|
throw new Error('OCR-Verarbeitung fehlgeschlagen');
|
|
}
|
|
|
|
// Still processing, continue polling
|
|
}
|
|
|
|
throw new Error('OCR-Verarbeitung Timeout');
|
|
}
|
|
|
|
// Calculate grade locally (fallback)
|
|
function _calculateLocalGrade(percentage) {
|
|
if (percentage >= 92) return '1';
|
|
if (percentage >= 81) return '2';
|
|
if (percentage >= 67) return '3';
|
|
if (percentage >= 50) return '4';
|
|
if (percentage >= 30) return '5';
|
|
return '6';
|
|
}
|
|
|
|
// Reset pipeline visualization
|
|
function resetPipeline() {
|
|
const steps = ['upload', 'preprocess', 'ocr', 'segment', 'grade', 'review'];
|
|
steps.forEach(step => updatePipelineStep(step, null));
|
|
}
|
|
|
|
// Update pipeline step visualization
|
|
function updatePipelineStep(stepId, status) {
|
|
const step = document.querySelector(`[data-step="${stepId}"]`);
|
|
if (!step) return;
|
|
|
|
step.classList.remove('active', 'completed', 'error');
|
|
if (status) {
|
|
step.classList.add(status);
|
|
}
|
|
}
|
|
|
|
// Show correction results
|
|
function showCorrectionResults(results) {
|
|
correctedExams = results || [];
|
|
|
|
// Update stats
|
|
document.getElementById('stat-corrected').textContent = correctedExams.length;
|
|
const avg = correctedExams.length
|
|
? (correctedExams.reduce((sum, e) => sum + e.points, 0) / correctedExams.length).toFixed(1)
|
|
: '-';
|
|
document.getElementById('stat-average').textContent = avg;
|
|
|
|
// Render exam list
|
|
const container = document.getElementById('correction-results');
|
|
if (!container) return;
|
|
|
|
if (!correctedExams.length) {
|
|
container.innerHTML = `
|
|
<div class="correction-empty">
|
|
<div class="icon">📝</div>
|
|
<h2>Keine Ergebnisse</h2>
|
|
<p>Die Korrektur wurde abgeschlossen, aber es wurden keine Ergebnisse gefunden.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="exam-list">
|
|
${correctedExams.map((exam, idx) => `
|
|
<div class="exam-item${idx === currentExamIndex ? ' active' : ''}" onclick="selectExam(${idx})">
|
|
<div class="exam-thumb">
|
|
<span style="font-size: 24px;">📄</span>
|
|
</div>
|
|
<div class="exam-info">
|
|
<div class="exam-name">${exam.student_name || exam.filename}</div>
|
|
<div class="exam-meta">${exam.points} / ${exam.max_points} Punkte</div>
|
|
</div>
|
|
<div class="exam-grade ${getGradeClass(exam.grade)}">${exam.grade}</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Get grade class for styling
|
|
function getGradeClass(grade) {
|
|
const g = parseInt(grade);
|
|
if (g === 1) return 'excellent';
|
|
if (g === 2) return 'good';
|
|
if (g === 3 || g === 4) return 'average';
|
|
return 'poor';
|
|
}
|
|
|
|
// Select exam for review
|
|
function selectExam(index) {
|
|
currentExamIndex = index;
|
|
const exam = correctedExams[index];
|
|
if (!exam) return;
|
|
|
|
// Update active state in list
|
|
document.querySelectorAll('.exam-item').forEach((item, idx) => {
|
|
item.classList.toggle('active', idx === index);
|
|
});
|
|
|
|
showReviewInterface(exam);
|
|
}
|
|
|
|
// Show review interface
|
|
function showReviewInterface(exam) {
|
|
const resultsContainer = document.getElementById('correction-results');
|
|
const reviewInterface = document.getElementById('review-interface');
|
|
|
|
if (resultsContainer) resultsContainer.style.display = 'none';
|
|
if (reviewInterface) {
|
|
reviewInterface.classList.add('active');
|
|
reviewInterface.style.display = 'flex';
|
|
}
|
|
|
|
// Populate review data
|
|
const reviewImage = document.getElementById('review-image');
|
|
if (reviewImage && exam.image_url) {
|
|
reviewImage.src = exam.image_url;
|
|
}
|
|
|
|
const ocrText = document.getElementById('ocr-text');
|
|
if (ocrText) {
|
|
ocrText.textContent = exam.ocr_text || 'OCR-Text wird geladen...';
|
|
}
|
|
|
|
// Render answers
|
|
const answersContainer = document.getElementById('answers-container');
|
|
if (answersContainer && exam.answers) {
|
|
answersContainer.innerHTML = exam.answers.map((ans, idx) => `
|
|
<div class="answer-section">
|
|
<div class="answer-header">
|
|
<span class="answer-task">${ans.task}</span>
|
|
<div class="answer-points">
|
|
<input type="number" id="answer-points-${idx}" value="${ans.points}" min="0" max="${ans.max}">
|
|
<span>/ ${ans.max}</span>
|
|
</div>
|
|
</div>
|
|
<div class="answer-text">${ans.text || 'Keine Antwort erkannt'}</div>
|
|
<div class="ai-feedback">${ans.feedback || 'AI-Feedback wird generiert...'}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Update total
|
|
updateTotalPoints(exam);
|
|
}
|
|
|
|
// Update total points display
|
|
function updateTotalPoints(exam) {
|
|
const totalEl = document.getElementById('total-points');
|
|
if (totalEl) {
|
|
totalEl.textContent = `${exam.points} / ${exam.max_points}`;
|
|
}
|
|
|
|
const gradeSelect = document.getElementById('grade-select');
|
|
if (gradeSelect) {
|
|
gradeSelect.value = exam.grade;
|
|
}
|
|
}
|
|
|
|
// Navigate exams
|
|
function previousExam() {
|
|
if (currentExamIndex > 0) {
|
|
selectExam(currentExamIndex - 1);
|
|
}
|
|
}
|
|
|
|
async function saveAndNextExam() {
|
|
// Save current exam data
|
|
const exam = correctedExams[currentExamIndex];
|
|
if (exam && exam.answers) {
|
|
exam.answers.forEach((ans, idx) => {
|
|
const input = document.getElementById(`answer-points-${idx}`);
|
|
if (input) {
|
|
ans.points = parseInt(input.value) || 0;
|
|
}
|
|
});
|
|
exam.points = exam.answers.reduce((sum, a) => sum + a.points, 0);
|
|
exam.percentage = (exam.points / exam.max_points * 100) || 0;
|
|
exam.grade = document.getElementById('grade-select')?.value || exam.grade;
|
|
|
|
// Save to API if we have a correction ID
|
|
if (exam.id) {
|
|
try {
|
|
const evaluations = exam.answers.map((ans, idx) => ({
|
|
question_number: idx + 1,
|
|
extracted_text: ans.text || '',
|
|
points_possible: ans.max,
|
|
points_awarded: ans.points,
|
|
feedback: ans.feedback || '',
|
|
is_correct: ans.points >= ans.max * 0.5,
|
|
confidence: 0.8
|
|
}));
|
|
|
|
await fetch(`/api/corrections/${exam.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
evaluations: evaluations,
|
|
total_points: exam.points,
|
|
grade: exam.grade,
|
|
status: 'reviewing'
|
|
})
|
|
});
|
|
|
|
console.log(`Korrektur ${exam.id} gespeichert`);
|
|
} catch (e) {
|
|
console.error('Speichern fehlgeschlagen:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move to next
|
|
if (currentExamIndex < correctedExams.length - 1) {
|
|
selectExam(currentExamIndex + 1);
|
|
} else {
|
|
// All done - mark as completed if we have IDs
|
|
for (const exam of correctedExams) {
|
|
if (exam.id) {
|
|
try {
|
|
await fetch(`/api/corrections/${exam.id}/complete`, { method: 'POST' });
|
|
} catch (e) {
|
|
console.error(`Complete failed for ${exam.id}:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show summary
|
|
hideReviewInterface();
|
|
showCorrectionResults(correctedExams);
|
|
|
|
// Update stats
|
|
const avg = correctedExams.length
|
|
? (correctedExams.reduce((sum, e) => sum + (e.percentage || 0), 0) / correctedExams.length).toFixed(1)
|
|
: '-';
|
|
document.getElementById('stat-average').textContent = avg + '%';
|
|
}
|
|
}
|
|
|
|
function hideReviewInterface() {
|
|
const resultsContainer = document.getElementById('correction-results');
|
|
const reviewInterface = document.getElementById('review-interface');
|
|
|
|
if (reviewInterface) {
|
|
reviewInterface.classList.remove('active');
|
|
reviewInterface.style.display = 'none';
|
|
}
|
|
if (resultsContainer) {
|
|
resultsContainer.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Export correction results - uses /api/corrections API
|
|
async function exportCorrectionResults(format) {
|
|
console.log(`Exportiere Ergebnisse als ${format}...`);
|
|
|
|
if (!correctedExams.length) {
|
|
alert('Keine Ergebnisse zum Exportieren vorhanden.');
|
|
return;
|
|
}
|
|
|
|
if (format === 'csv') {
|
|
// CSV export - client-side
|
|
const csv = [
|
|
'Name,Punkte,MaxPunkte,Prozent,Note',
|
|
...correctedExams.map(e =>
|
|
`"${e.student_name || e.filename}",${e.points},${e.max_points},${e.percentage?.toFixed(1) || 0},${e.grade}`
|
|
)
|
|
].join('\\n');
|
|
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'klausur-ergebnisse.csv';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
} else if (format === 'pdf') {
|
|
// PDF export - via API for each correction
|
|
try {
|
|
for (const exam of correctedExams) {
|
|
if (!exam.id) continue;
|
|
|
|
const resp = await fetch(`/api/corrections/${exam.id}/export-pdf`);
|
|
if (!resp.ok) {
|
|
console.error(`PDF export failed for ${exam.student_name}`);
|
|
continue;
|
|
}
|
|
|
|
const blob = await resp.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `korrektur_${exam.student_name || 'unbekannt'}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
// Small delay between downloads
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
}
|
|
alert(`${correctedExams.length} PDF(s) wurden heruntergeladen.`);
|
|
} catch (e) {
|
|
console.error('PDF export error:', e);
|
|
alert('PDF-Export fehlgeschlagen: ' + e.message);
|
|
}
|
|
|
|
} else if (format === 'excel') {
|
|
// Excel export - generate XLSX-like structure
|
|
const headers = ['Name', 'Punkte', 'MaxPunkte', 'Prozent', 'Note'];
|
|
const rows = correctedExams.map(e => [
|
|
e.student_name || e.filename,
|
|
e.points,
|
|
e.max_points,
|
|
e.percentage?.toFixed(1) || 0,
|
|
e.grade
|
|
]);
|
|
|
|
// Create TSV (Tab-separated) which Excel can open
|
|
const tsv = [
|
|
headers.join('\\t'),
|
|
...rows.map(row => row.join('\\t'))
|
|
].join('\\n');
|
|
|
|
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'klausur-ergebnisse.xls';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
// Show Correction Panel
|
|
function showCorrectionPanel() {
|
|
console.log('showCorrectionPanel called');
|
|
hideAllPanels();
|
|
hideStudioSubMenu();
|
|
const panel = document.getElementById('panel-correction');
|
|
if (panel) {
|
|
panel.style.display = 'flex';
|
|
loadCorrectionModule();
|
|
console.log('Correction panel shown');
|
|
} else {
|
|
console.error('panel-correction not found');
|
|
}
|
|
}
|
|
"""
|