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:
743
backend/frontend/components/local_llm.py
Normal file
743
backend/frontend/components/local_llm.py
Normal file
@@ -0,0 +1,743 @@
|
||||
"""
|
||||
Local LLM Component - Transformers.js + ONNX Integration.
|
||||
|
||||
Ermoeglicht Header-Extraktion aus Klausuren direkt im Browser:
|
||||
- Laeuft vollstaendig lokal (Privacy-by-Design)
|
||||
- ONNX Modell wird beim PWA-Install gecacht
|
||||
- Extrahiert: Namen, Klasse, Fach, Datum
|
||||
|
||||
Architektur:
|
||||
1. Service Worker cacht ONNX Modell (~100MB)
|
||||
2. Transformers.js laedt Modell aus Cache
|
||||
3. Header-Region wird per Canvas extrahiert
|
||||
4. Vision-Modell extrahiert strukturierte Daten
|
||||
"""
|
||||
|
||||
|
||||
class LocalLLMComponent:
|
||||
"""PWA Local LLM Component fuer Header-Extraktion."""
|
||||
|
||||
# Modell-Konfiguration
|
||||
MODEL_ID = "breakpilot/exam-header-extractor"
|
||||
MODEL_CACHE_NAME = "breakpilot-llm-v1"
|
||||
MODEL_SIZE_MB = 100
|
||||
|
||||
@staticmethod
|
||||
def get_css() -> str:
|
||||
"""CSS fuer Local LLM UI-Elemente."""
|
||||
return """
|
||||
/* Local LLM Status Indicator */
|
||||
.local-llm-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bp-surface-elevated);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.local-llm-status.loading {
|
||||
background: var(--bp-warning-bg);
|
||||
}
|
||||
|
||||
.local-llm-status.ready {
|
||||
background: var(--bp-success-bg);
|
||||
}
|
||||
|
||||
.local-llm-status.error {
|
||||
background: var(--bp-error-bg);
|
||||
}
|
||||
|
||||
.llm-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--bp-text-muted);
|
||||
}
|
||||
|
||||
.local-llm-status.loading .llm-status-dot {
|
||||
background: var(--bp-warning);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.local-llm-status.ready .llm-status-dot {
|
||||
background: var(--bp-success);
|
||||
}
|
||||
|
||||
.local-llm-status.error .llm-status-dot {
|
||||
background: var(--bp-error);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Extraction Progress */
|
||||
.extraction-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bp-surface);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--bp-border);
|
||||
}
|
||||
|
||||
.extraction-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.extraction-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--bp-primary), var(--bp-accent));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.extraction-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--bp-text-muted);
|
||||
}
|
||||
|
||||
/* Instant Feedback Card */
|
||||
.instant-feedback-card {
|
||||
background: linear-gradient(135deg, var(--bp-primary-bg), var(--bp-accent-bg));
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
animation: fadeInUp 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.instant-feedback-card h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 20px;
|
||||
color: var(--bp-text);
|
||||
}
|
||||
|
||||
.detected-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detected-info-item {
|
||||
background: var(--bp-surface);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detected-info-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--bp-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detected-info-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--bp-text);
|
||||
}
|
||||
|
||||
.detected-info-item .confidence {
|
||||
font-size: 11px;
|
||||
color: var(--bp-success);
|
||||
margin-top: 4px;
|
||||
}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_html() -> str:
|
||||
"""HTML fuer Local LLM UI-Elemente (wird in Wizard eingebettet)."""
|
||||
return """
|
||||
<!-- Local LLM Status (oben im Wizard) -->
|
||||
<div id="local-llm-status" class="local-llm-status" style="display: none;">
|
||||
<span class="llm-status-dot"></span>
|
||||
<span class="llm-status-text">KI-Modell wird geladen...</span>
|
||||
</div>
|
||||
|
||||
<!-- Instant Feedback (nach Upload) -->
|
||||
<div id="instant-feedback" class="instant-feedback-card" style="display: none;">
|
||||
<h3>Automatisch erkannt</h3>
|
||||
<div class="detected-info-grid">
|
||||
<div class="detected-info-item">
|
||||
<div class="label">Klasse</div>
|
||||
<div class="value" id="detected-class">-</div>
|
||||
<div class="confidence" id="detected-class-conf"></div>
|
||||
</div>
|
||||
<div class="detected-info-item">
|
||||
<div class="label">Fach</div>
|
||||
<div class="value" id="detected-subject">-</div>
|
||||
<div class="confidence" id="detected-subject-conf"></div>
|
||||
</div>
|
||||
<div class="detected-info-item">
|
||||
<div class="label">Schueler</div>
|
||||
<div class="value" id="detected-count">-</div>
|
||||
</div>
|
||||
<div class="detected-info-item">
|
||||
<div class="label">Datum</div>
|
||||
<div class="value" id="detected-date">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extraction Progress -->
|
||||
<div id="extraction-progress" class="extraction-progress" style="display: none;">
|
||||
<div class="extraction-progress-bar">
|
||||
<div class="extraction-progress-fill" id="extraction-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="extraction-stats">
|
||||
<span id="extraction-current">0 / 0 analysiert</span>
|
||||
<span id="extraction-time">~0 Sek verbleibend</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_js() -> str:
|
||||
"""JavaScript fuer Transformers.js + ONNX Integration."""
|
||||
return """
|
||||
// ============================================================
|
||||
// LOCAL LLM - Transformers.js + ONNX Header Extraction
|
||||
// ============================================================
|
||||
|
||||
// Konfiguration
|
||||
const LOCAL_LLM_CONFIG = {
|
||||
// Transformers.js CDN
|
||||
transformersUrl: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.1',
|
||||
|
||||
// Modell fuer Header-Extraktion (Vision + Text)
|
||||
// Option 1: Florence-2 (Microsoft) - gut fuer OCR + Strukturerkennung
|
||||
// Option 2: PaddleOCR via ONNX - spezialisiert auf Handschrift
|
||||
// Option 3: Custom fine-tuned Modell
|
||||
modelId: 'Xenova/florence-2-base', // Fallback: Xenova/vit-gpt2-image-captioning
|
||||
|
||||
// Alternative: Lokaler OCR-basierter Ansatz
|
||||
useOcrFallback: true,
|
||||
|
||||
// Cache
|
||||
cacheName: 'breakpilot-llm-v1',
|
||||
|
||||
// Performance
|
||||
maxParallelExtractions: 4,
|
||||
headerRegionPercent: 0.20 // Top 20% der Seite
|
||||
};
|
||||
|
||||
// State
|
||||
let localLLMState = {
|
||||
isLoading: false,
|
||||
isReady: false,
|
||||
error: null,
|
||||
pipeline: null,
|
||||
ocrWorker: null
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initialisiert das lokale LLM (Transformers.js).
|
||||
* Wird beim ersten Magic-Onboarding-Start aufgerufen.
|
||||
*/
|
||||
async function initLocalLLM() {
|
||||
if (localLLMState.isReady) return true;
|
||||
if (localLLMState.isLoading) {
|
||||
// Warten bis geladen
|
||||
return new Promise((resolve) => {
|
||||
const checkReady = setInterval(() => {
|
||||
if (localLLMState.isReady) {
|
||||
clearInterval(checkReady);
|
||||
resolve(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
localLLMState.isLoading = true;
|
||||
updateLLMStatus('loading', 'KI-Modell wird geladen...');
|
||||
|
||||
try {
|
||||
// Transformers.js dynamisch laden
|
||||
if (!window.Transformers) {
|
||||
await loadScript(LOCAL_LLM_CONFIG.transformersUrl);
|
||||
}
|
||||
|
||||
// OCR Worker initialisieren (Tesseract.js als Fallback)
|
||||
if (LOCAL_LLM_CONFIG.useOcrFallback) {
|
||||
await initOCRWorker();
|
||||
}
|
||||
|
||||
localLLMState.isReady = true;
|
||||
localLLMState.isLoading = false;
|
||||
updateLLMStatus('ready', 'KI bereit');
|
||||
|
||||
console.log('[LocalLLM] Initialisierung abgeschlossen');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[LocalLLM] Fehler bei Initialisierung:', error);
|
||||
localLLMState.error = error;
|
||||
localLLMState.isLoading = false;
|
||||
updateLLMStatus('error', 'KI-Fehler: ' + error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisiert Tesseract.js als OCR-Fallback.
|
||||
*/
|
||||
async function initOCRWorker() {
|
||||
// Tesseract.js fuer deutsche Handschrifterkennung
|
||||
if (!window.Tesseract) {
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js');
|
||||
}
|
||||
|
||||
localLLMState.ocrWorker = await Tesseract.createWorker('deu', 1, {
|
||||
logger: m => {
|
||||
if (m.status === 'recognizing text') {
|
||||
updateExtractionProgress(m.progress * 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[LocalLLM] OCR Worker initialisiert (Deutsch)');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HEADER EXTRACTION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Extrahiert Header-Daten aus mehreren Bildern.
|
||||
* @param {File[]} files - Array von Bild-Dateien
|
||||
* @returns {Promise<ExtractionResult>}
|
||||
*/
|
||||
async function extractExamHeaders(files) {
|
||||
if (!localLLMState.isReady) {
|
||||
await initLocalLLM();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
showExtractionProgress(true);
|
||||
|
||||
const results = {
|
||||
students: [],
|
||||
detectedClass: null,
|
||||
detectedSubject: null,
|
||||
detectedDate: null,
|
||||
classConfidence: 0,
|
||||
subjectConfidence: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Parallele Verarbeitung mit Limit
|
||||
const batchSize = LOCAL_LLM_CONFIG.maxParallelExtractions;
|
||||
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, Math.min(i + batchSize, files.length));
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(file => extractSingleHeader(file))
|
||||
);
|
||||
|
||||
// Ergebnisse aggregieren
|
||||
for (const result of batchResults) {
|
||||
if (result.error) {
|
||||
results.errors.push(result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.studentName) {
|
||||
results.students.push({
|
||||
firstName: result.firstName || result.studentName,
|
||||
lastNameHint: result.lastNameHint,
|
||||
fullName: result.studentName,
|
||||
confidence: result.nameConfidence
|
||||
});
|
||||
}
|
||||
|
||||
// Klasse/Fach/Datum aggregieren (hoechste Konfidenz gewinnt)
|
||||
if (result.className && result.classConfidence > results.classConfidence) {
|
||||
results.detectedClass = result.className;
|
||||
results.classConfidence = result.classConfidence;
|
||||
}
|
||||
if (result.subject && result.subjectConfidence > results.subjectConfidence) {
|
||||
results.detectedSubject = result.subject;
|
||||
results.subjectConfidence = result.subjectConfidence;
|
||||
}
|
||||
if (result.date && !results.detectedDate) {
|
||||
results.detectedDate = result.date;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress Update
|
||||
const progress = Math.min(100, ((i + batch.length) / files.length) * 100);
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const remaining = (elapsed / (i + batch.length)) * (files.length - i - batch.length);
|
||||
|
||||
updateExtractionProgress(progress, i + batch.length, files.length, remaining);
|
||||
}
|
||||
|
||||
showExtractionProgress(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Header-Daten aus einem einzelnen Bild.
|
||||
*/
|
||||
async function extractSingleHeader(file) {
|
||||
try {
|
||||
// 1. Bild laden
|
||||
const img = await loadImageFromFile(file);
|
||||
|
||||
// 2. Header-Region extrahieren (Top 20%)
|
||||
const headerCanvas = extractHeaderRegion(img);
|
||||
|
||||
// 3. OCR auf Header-Region
|
||||
const ocrResult = await performOCR(headerCanvas);
|
||||
|
||||
// 4. Strukturierte Daten extrahieren
|
||||
const parsed = parseHeaderText(ocrResult.text);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
nameConfidence: ocrResult.confidence,
|
||||
rawText: ocrResult.text
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[LocalLLM] Fehler bei Extraktion:', error);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt ein Bild aus einer File.
|
||||
*/
|
||||
function loadImageFromFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert die Header-Region (Top X%) aus einem Bild.
|
||||
*/
|
||||
function extractHeaderRegion(img) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const headerHeight = Math.floor(img.height * LOCAL_LLM_CONFIG.headerRegionPercent);
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = headerHeight;
|
||||
|
||||
ctx.drawImage(img, 0, 0, img.width, headerHeight, 0, 0, img.width, headerHeight);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt OCR auf einem Canvas aus.
|
||||
*/
|
||||
async function performOCR(canvas) {
|
||||
if (!localLLMState.ocrWorker) {
|
||||
throw new Error('OCR Worker nicht initialisiert');
|
||||
}
|
||||
|
||||
const { data } = await localLLMState.ocrWorker.recognize(canvas);
|
||||
|
||||
return {
|
||||
text: data.text,
|
||||
confidence: data.confidence / 100,
|
||||
words: data.words
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst OCR-Text und extrahiert strukturierte Daten.
|
||||
*/
|
||||
function parseHeaderText(text) {
|
||||
const result = {
|
||||
studentName: null,
|
||||
firstName: null,
|
||||
lastNameHint: null,
|
||||
className: null,
|
||||
classConfidence: 0,
|
||||
subject: null,
|
||||
subjectConfidence: 0,
|
||||
date: null
|
||||
};
|
||||
|
||||
const lines = text.split('\\n').map(l => l.trim()).filter(l => l);
|
||||
|
||||
for (const line of lines) {
|
||||
// Klassenname erkennen (z.B. "3a", "Klasse 10b", "Q1")
|
||||
const classMatch = line.match(/\\b(Klasse\\s*)?(\\d{1,2}[a-zA-Z]?|Q[12]|E[1-2]|EF|[KG]\\d)\\b/i);
|
||||
if (classMatch) {
|
||||
result.className = classMatch[2] || classMatch[0];
|
||||
result.classConfidence = 0.9;
|
||||
}
|
||||
|
||||
// Fach erkennen
|
||||
const subjects = {
|
||||
'mathe': 'Mathematik',
|
||||
'mathematik': 'Mathematik',
|
||||
'deutsch': 'Deutsch',
|
||||
'englisch': 'Englisch',
|
||||
'english': 'Englisch',
|
||||
'physik': 'Physik',
|
||||
'chemie': 'Chemie',
|
||||
'biologie': 'Biologie',
|
||||
'bio': 'Biologie',
|
||||
'geschichte': 'Geschichte',
|
||||
'erdkunde': 'Erdkunde',
|
||||
'geographie': 'Geographie',
|
||||
'politik': 'Politik',
|
||||
'kunst': 'Kunst',
|
||||
'musik': 'Musik',
|
||||
'sport': 'Sport',
|
||||
'religion': 'Religion',
|
||||
'ethik': 'Ethik',
|
||||
'informatik': 'Informatik',
|
||||
'latein': 'Latein',
|
||||
'franzoesisch': 'Franzoesisch',
|
||||
'französisch': 'Franzoesisch',
|
||||
'spanisch': 'Spanisch'
|
||||
};
|
||||
|
||||
const lowerLine = line.toLowerCase();
|
||||
for (const [key, value] of Object.entries(subjects)) {
|
||||
if (lowerLine.includes(key)) {
|
||||
result.subject = value;
|
||||
result.subjectConfidence = 0.95;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Datum erkennen (verschiedene Formate)
|
||||
const datePatterns = [
|
||||
/\\b(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})\\b/, // 12.01.2026
|
||||
/\\b(\\d{1,2})\\s+(Januar|Februar|Maerz|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\\s+(\\d{4})\\b/i
|
||||
];
|
||||
|
||||
for (const pattern of datePatterns) {
|
||||
const dateMatch = line.match(pattern);
|
||||
if (dateMatch) {
|
||||
result.date = dateMatch[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Name erkennen (erste Zeile mit "Name:" oder alleinstehender Name)
|
||||
if (line.toLowerCase().includes('name')) {
|
||||
const nameMatch = line.match(/name[:\\s]+(.+)/i);
|
||||
if (nameMatch) {
|
||||
result.studentName = nameMatch[1].trim();
|
||||
parseStudentName(result);
|
||||
}
|
||||
} else if (!result.studentName && lines.indexOf(line) < 3) {
|
||||
// Erste Zeilen koennen Namen sein
|
||||
const potentialName = line.replace(/[^a-zA-ZaeoeueAeOeUess\\s.-]/g, '').trim();
|
||||
if (potentialName.length >= 2 && potentialName.length <= 40) {
|
||||
// Pruefen ob es wie ein Name aussieht (Gross-/Kleinschreibung)
|
||||
if (/^[A-ZAEOEUE][a-zaeoeue]+/.test(potentialName)) {
|
||||
result.studentName = potentialName;
|
||||
parseStudentName(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst einen Schuelernamen in Vor- und Nachname.
|
||||
*/
|
||||
function parseStudentName(result) {
|
||||
if (!result.studentName) return;
|
||||
|
||||
const name = result.studentName.trim();
|
||||
const parts = name.split(/\\s+/);
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Nur Vorname
|
||||
result.firstName = parts[0];
|
||||
} else if (parts.length === 2) {
|
||||
// Vorname Nachname oder Nachname, Vorname
|
||||
if (parts[0].endsWith(',')) {
|
||||
result.firstName = parts[1];
|
||||
result.lastNameHint = parts[0].replace(',', '');
|
||||
} else {
|
||||
result.firstName = parts[0];
|
||||
result.lastNameHint = parts[1];
|
||||
}
|
||||
} else {
|
||||
// Mehrere Teile - erster ist Vorname
|
||||
result.firstName = parts[0];
|
||||
result.lastNameHint = parts.slice(1).join(' ');
|
||||
}
|
||||
|
||||
// Abkuerzungen erkennen (z.B. "M." fuer Nachname)
|
||||
if (result.lastNameHint && result.lastNameHint.match(/^[A-Z]\\.?$/)) {
|
||||
result.lastNameHint = result.lastNameHint.replace('.', '') + '.';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UI HELPERS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Aktualisiert den LLM-Status-Indikator.
|
||||
*/
|
||||
function updateLLMStatus(state, text) {
|
||||
const statusEl = document.getElementById('local-llm-status');
|
||||
if (!statusEl) return;
|
||||
|
||||
statusEl.style.display = 'flex';
|
||||
statusEl.className = 'local-llm-status ' + state;
|
||||
statusEl.querySelector('.llm-status-text').textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt das Instant-Feedback-Panel.
|
||||
*/
|
||||
function showInstantFeedback(data) {
|
||||
const feedbackEl = document.getElementById('instant-feedback');
|
||||
if (!feedbackEl) return;
|
||||
|
||||
feedbackEl.style.display = 'block';
|
||||
|
||||
document.getElementById('detected-class').textContent = data.detectedClass || '-';
|
||||
document.getElementById('detected-subject').textContent = data.detectedSubject || '-';
|
||||
document.getElementById('detected-count').textContent = data.studentCount || '0';
|
||||
document.getElementById('detected-date').textContent = data.detectedDate || '-';
|
||||
|
||||
// Konfidenz anzeigen
|
||||
if (data.classConfidence) {
|
||||
document.getElementById('detected-class-conf').textContent =
|
||||
Math.round(data.classConfidence * 100) + '% sicher';
|
||||
}
|
||||
if (data.subjectConfidence) {
|
||||
document.getElementById('detected-subject-conf').textContent =
|
||||
Math.round(data.subjectConfidence * 100) + '% sicher';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt/versteckt den Extraktions-Fortschritt.
|
||||
*/
|
||||
function showExtractionProgress(show) {
|
||||
const progressEl = document.getElementById('extraction-progress');
|
||||
if (progressEl) {
|
||||
progressEl.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Extraktions-Fortschritt.
|
||||
*/
|
||||
function updateExtractionProgress(percent, current, total, remainingSec) {
|
||||
const fillEl = document.getElementById('extraction-fill');
|
||||
const currentEl = document.getElementById('extraction-current');
|
||||
const timeEl = document.getElementById('extraction-time');
|
||||
|
||||
if (fillEl) fillEl.style.width = percent + '%';
|
||||
if (currentEl && current !== undefined) {
|
||||
currentEl.textContent = current + ' / ' + total + ' analysiert';
|
||||
}
|
||||
if (timeEl && remainingSec !== undefined) {
|
||||
timeEl.textContent = '~' + Math.ceil(remainingSec) + ' Sek verbleibend';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt ein Script dynamisch.
|
||||
*/
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAGIC ONBOARDING INTEGRATION
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Hauptfunktion fuer Magic-Analyse beim Upload.
|
||||
* Wird von klausur_korrektur.py aufgerufen.
|
||||
*/
|
||||
async function magicAnalyzeExams(files) {
|
||||
console.log('[MagicOnboarding] Starte Analyse von', files.length, 'Dateien');
|
||||
|
||||
// 1. LLM initialisieren
|
||||
await initLocalLLM();
|
||||
|
||||
// 2. Quick Preview (erste 5 Dateien)
|
||||
const quickFiles = files.slice(0, 5);
|
||||
const quickResults = await extractExamHeaders(quickFiles);
|
||||
|
||||
// 3. Sofort Feedback zeigen (WOW-Effekt!)
|
||||
showInstantFeedback({
|
||||
detectedClass: quickResults.detectedClass,
|
||||
detectedSubject: quickResults.detectedSubject,
|
||||
studentCount: files.length,
|
||||
detectedDate: quickResults.detectedDate,
|
||||
classConfidence: quickResults.classConfidence,
|
||||
subjectConfidence: quickResults.subjectConfidence
|
||||
});
|
||||
|
||||
// 4. Rest im Hintergrund verarbeiten
|
||||
if (files.length > 5) {
|
||||
const remainingFiles = files.slice(5);
|
||||
const remainingResults = await extractExamHeaders(remainingFiles);
|
||||
|
||||
// Ergebnisse mergen
|
||||
quickResults.students = [
|
||||
...quickResults.students,
|
||||
...remainingResults.students
|
||||
];
|
||||
}
|
||||
|
||||
console.log('[MagicOnboarding] Analyse abgeschlossen:', quickResults);
|
||||
return quickResults;
|
||||
}
|
||||
|
||||
// Export fuer globalen Zugriff
|
||||
window.LocalLLM = {
|
||||
init: initLocalLLM,
|
||||
extractHeaders: extractExamHeaders,
|
||||
magicAnalyze: magicAnalyzeExams,
|
||||
getStatus: () => localLLMState
|
||||
};
|
||||
"""
|
||||
Reference in New Issue
Block a user