physio-tutor/www/app.js

1804 lines
87 KiB
JavaScript
Raw Permalink Normal View History

/* Luna PhysioTutor Widget
* Vanilla JS, no build, no framework, keine externen Fonts/Analytics.
* Chat | Quiz | Flashcards | Fortschritt | Lehrplan localStorage only.
*/
(() => {
'use strict';
// ==== Config ====
const API = 'https://llm.qognio.com/api/bots/physio-tutor/chat';
const RAW_KEY = window.__LUNA_KEY__ || '';
const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : '';
const LS_KEY = 'luna.state.v1';
const LS_CHAT = 'luna.chat.v1';
const LS_FLASH = 'luna.flash.v1';
// ==== State ====
let CURRICULA = null;
let state = loadState();
let chatHistory = loadChatHistory();
let flashCards = loadFlashCards(); // { [topicId]: [{front, back, hint, ef, interval, due, reps}] }
// ==== Utils ====
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const now = () => Date.now();
const today = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
function daysBetween(dateA, dateB) {
const a = new Date(dateA); const b = new Date(dateB);
return Math.round((b - a) / 86400000);
}
// ==== Persistence ====
function loadState() {
try {
const s = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
return {
xp: s.xp || 0,
totalAnswers: s.totalAnswers || 0,
correctAnswers: s.correctAnswers || 0,
currentStreak: s.currentStreak || 0,
maxStreak: s.maxStreak || 0,
lastActive: s.lastActive || null,
quizStreak: s.quizStreak || 0,
maxQuizStreak: s.maxQuizStreak || 0,
mastery: s.mastery || {}, // { [curriculumId]: { correct, total } }
moduleCorrect: s.moduleCorrect || {}, // { [moduleId]: correctCount }
moduleTotal: s.moduleTotal || {}, // { [moduleId]: totalAnswered }
modulePassedFlash: s.modulePassedFlash || {}, // { [moduleId]: true when all cards rated >= "gut"}
completedQuizzes: s.completedQuizzes || 0,
flashCardsRated: s.flashCardsRated || 0,
badges: s.badges || {},
seenWelcome: s.seenWelcome || false,
completedCurricula: s.completedCurricula || []
};
} catch (e) {
return {
xp: 0, totalAnswers: 0, correctAnswers: 0,
currentStreak: 0, maxStreak: 0, lastActive: null,
quizStreak: 0, maxQuizStreak: 0, mastery: {},
moduleCorrect: {}, moduleTotal: {}, modulePassedFlash: {},
completedQuizzes: 0, flashCardsRated: 0,
badges: {}, seenWelcome: false, completedCurricula: []
};
}
}
function saveState() { localStorage.setItem(LS_KEY, JSON.stringify(state)); }
function loadChatHistory() {
try { return JSON.parse(localStorage.getItem(LS_CHAT) || '[]'); } catch (e) { return []; }
}
function saveChatHistory() {
// Cap at last 40 exchanges to keep API payload small
const trimmed = chatHistory.slice(-40);
localStorage.setItem(LS_CHAT, JSON.stringify(trimmed));
}
function loadFlashCards() {
try { return JSON.parse(localStorage.getItem(LS_FLASH) || '{}'); } catch (e) { return {}; }
}
function saveFlashCards() { localStorage.setItem(LS_FLASH, JSON.stringify(flashCards)); }
// ==== Streak / activity tracking ====
function touchActivity() {
const t = today();
if (state.lastActive === t) return;
if (state.lastActive) {
const diff = daysBetween(state.lastActive, t);
if (diff === 1) state.currentStreak += 1;
else if (diff > 1) state.currentStreak = 1;
else state.currentStreak = Math.max(1, state.currentStreak);
} else {
state.currentStreak = 1;
}
state.lastActive = t;
if (state.currentStreak > state.maxStreak) state.maxStreak = state.currentStreak;
checkBadges();
saveState();
}
// ==== XP / Level ====
function addXP(n, reason = '') {
state.xp += n;
saveState();
showXPGain(`+${n} XP${reason ? ' · ' + reason : ''}`);
}
function levelInfo() {
const levels = (CURRICULA && CURRICULA.levels) || [
{ min: 0, title: 'Anfänger:in' }, { min: 50, title: 'Einsteiger:in' },
{ min: 200, title: 'Fortgeschrittene:r' }, { min: 500, title: 'Profi' },
{ min: 1250, title: 'Expert:in' }, { min: 2500, title: 'Meister:in' }
];
let cur = levels[0];
for (const l of levels) if (state.xp >= l.min) cur = l;
const idx = levels.indexOf(cur);
const next = levels[idx + 1] || null;
const pct = next ? Math.min(100, ((state.xp - cur.min) / (next.min - cur.min)) * 100) : 100;
return { levelNum: idx + 1, title: cur.title, pct, next };
}
// ==== Badges ====
function unlockBadge(id) {
if (state.badges[id]) return false;
state.badges[id] = today();
saveState();
const badge = (CURRICULA && CURRICULA.badges || []).find(b => b.id === id);
if (badge) toast('🏆 Neues Abzeichen: ' + badge.title, 'success', 4500);
return true;
}
function checkBadges() {
if (state.completedQuizzes >= 1) unlockBadge('first_quiz');
if (state.maxQuizStreak >= 10) unlockBadge('10_quiz_streak');
if (state.totalAnswers >= 100) unlockBadge('100_answers');
if (state.maxStreak >= 7) unlockBadge('7_day_streak');
if (state.completedCurricula.length >= 1) unlockBadge('curriculum_complete');
const h = new Date().getHours();
if (h >= 22) unlockBadge('night_owl');
if (h < 7) unlockBadge('early_bird');
}
// ==== Toast ====
function toast(msg, kind = '', ms = 3200) {
const stack = $('#toast-stack');
const t = document.createElement('div');
t.className = 'toast ' + kind;
t.textContent = msg;
stack.appendChild(t);
setTimeout(() => {
t.style.opacity = '0';
t.style.transition = 'opacity .25s';
setTimeout(() => t.remove(), 260);
}, ms);
}
function showXPGain(txt) {
const el = document.createElement('div');
el.className = 'xp-gain';
el.textContent = txt;
document.body.appendChild(el);
setTimeout(() => el.remove(), 1600);
}
// ==== Simple markdown renderer ====
function renderMD(md) {
if (!md) return '';
let s = md;
// Escape HTML first
s = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Code fences
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) =>
`<pre><code>${code}</code></pre>`);
// Inline code
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
// GFM tables: header / separator / body
s = s.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => {
const lines = content.trim().split('\n');
if (lines.length < 2) return block;
const sep = lines[1];
if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(sep)) return block;
const parseRow = (ln) => ln.replace(/^\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim());
const header = parseRow(lines[0]);
const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? 'center' : /-+:$/.test(s) ? 'right' : 'left');
const rows = lines.slice(2).map(parseRow);
let html = '\n<table class="md-table"><thead><tr>';
header.forEach((h, i) => { html += `<th style="text-align:${aligns[i]||'left'}">${h}</th>`; });
html += '</tr></thead><tbody>';
rows.forEach(r => {
html += '<tr>';
for (let i = 0; i < Math.max(r.length, header.length); i++) {
html += `<td style="text-align:${aligns[i]||'left'}">${r[i] || ''}</td>`;
}
html += '</tr>';
});
html += '</tbody></table>\n';
return html;
});
// Bold
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
// Italic
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
// Headings
s = s.replace(/^### (.+)$/gm, '<h3>$1</h3>');
s = s.replace(/^## (.+)$/gm, '<h2>$1</h2>');
s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Links
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Unordered lists
s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => {
const items = m.trim().split('\n').map(ln => ln.replace(/^[*\-] /, '')).map(li => `<li>${li}</li>`).join('');
return '\n<ul>' + items + '</ul>';
});
// Ordered lists
s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => {
const items = m.trim().split('\n').map(ln => ln.replace(/^\d+\. /, '')).map(li => `<li>${li}</li>`).join('');
return '\n<ol>' + items + '</ol>';
});
// Paragraphs: split on blank lines
s = s.split(/\n{2,}/).map(block => {
if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block;
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('\n');
return s;
}
// ==== Chat API ====
async function chatAPI(message, history, attachments) {
const body = { message, history };
if (Array.isArray(attachments) && attachments.length > 0) body.attachments = attachments;
const r = await fetch(API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + KEY
},
body: JSON.stringify(body)
});
let data;
try { data = await r.json(); } catch (e) { throw new Error('Server-Antwort nicht lesbar'); }
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
return data;
}
// ==== Attachments (file upload) ====
const ATTACH_MAX_COUNT = 5;
const ATTACH_MAX_BYTES = 8 * 1024 * 1024;
const ATTACH_ACCEPTED_RE = /\.(pdf|txt|md|csv|json|xml|yaml|yml|log|png|jpg|jpeg|webp|gif)$/i;
let pendingAttachments = []; // [{ name, mimeType, dataUrl, size }]
function fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'));
fr.readAsDataURL(file);
});
}
function renderAttachStrip() {
const strip = $('#attach-strip');
if (!strip) return;
strip.innerHTML = '';
pendingAttachments.forEach((a, idx) => {
const chip = document.createElement('span');
chip.className = 'attach-chip';
const ico = a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝';
chip.innerHTML = `<span>${ico}</span><span class="attach-chip-name" title="${a.name.replace(/[<>"']/g, '')}">${a.name.replace(/[<>"']/g, '')}</span><span class="attach-chip-size">${fmtSize(a.size)}</span>`;
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'attach-chip-remove';
rm.setAttribute('aria-label', 'Anhang entfernen');
rm.textContent = '×';
rm.addEventListener('click', () => {
pendingAttachments.splice(idx, 1);
renderAttachStrip();
});
chip.appendChild(rm);
strip.appendChild(chip);
});
}
async function addFiles(fileList) {
const files = Array.from(fileList || []);
for (const f of files) {
if (pendingAttachments.length >= ATTACH_MAX_COUNT) {
toast(`Max ${ATTACH_MAX_COUNT} Anhänge — weitere ignoriert`, 'warn');
break;
}
if (!ATTACH_ACCEPTED_RE.test(f.name) && !f.type.startsWith('image/') && !/text|json|xml|pdf/i.test(f.type)) {
toast(`${f.name}: Format nicht unterstützt`, 'error');
continue;
}
if (f.size > ATTACH_MAX_BYTES) {
toast(`${f.name}: ${fmtSize(f.size)} > Limit ${fmtSize(ATTACH_MAX_BYTES)}`, 'error');
continue;
}
try {
const dataUrl = await fileToDataUrl(f);
pendingAttachments.push({
name: f.name,
mimeType: f.type || 'application/octet-stream',
dataUrl,
size: f.size,
});
} catch (e) {
toast(`${f.name}: ${e.message}`, 'error');
}
}
renderAttachStrip();
}
// ==== Chat UI ====
function addMsg(role, content, { markdown = false, pending = false, attachments = null } = {}) {
const box = $('#chat-box');
const el = document.createElement('div');
el.className = 'msg ' + role;
if (pending) {
el.innerHTML = '<span class="dots"><span></span><span></span><span></span></span>';
} else if (markdown) {
el.innerHTML = renderMD(content);
} else {
el.textContent = content;
}
if (Array.isArray(attachments) && attachments.length > 0) {
const wrap = document.createElement('div');
wrap.className = 'msg-attachments';
attachments.forEach(a => {
const ico = a.mimeType && a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝';
const span = document.createElement('span');
span.className = 'att-name';
span.textContent = `${ico} ${a.name} (${fmtSize(a.size)})`;
wrap.appendChild(span);
});
el.appendChild(wrap);
}
box.appendChild(el);
box.scrollTop = box.scrollHeight;
setTimeout(() => { $('.main').scrollTop = $('.main').scrollHeight; }, 20);
return el;
}
function clearChatUI() {
$('#chat-box').innerHTML = '';
}
function renderWelcome() {
if (state.xp === 0 && chatHistory.length === 0 && !state.seenWelcome) {
$('#welcome-screen').classList.remove('hidden');
$('#welcome-screen').setAttribute('aria-hidden', 'false');
} else {
$('#welcome-screen').classList.add('hidden');
$('#welcome-screen').setAttribute('aria-hidden', 'true');
}
}
function restoreChat() {
clearChatUI();
for (const m of chatHistory) {
addMsg(m.role === 'assistant' ? 'bot' : 'user', m.content, { markdown: m.role === 'assistant' });
}
renderWelcome();
}
async function sendChat(text) {
const attaches = pendingAttachments.slice();
if (!text.trim() && attaches.length === 0) return;
$('#welcome-screen').classList.add('hidden');
state.seenWelcome = true;
addMsg('user', text || '(nur Anhang)', { attachments: attaches });
chatHistory.push({
role: 'user',
content: text + (attaches.length ? '\n[Anhänge: ' + attaches.map(a => a.name).join(', ') + ']' : ''),
});
pendingAttachments = [];
renderAttachStrip();
const pend = addMsg('bot', '', { pending: true });
$('#composer-send').disabled = true;
try {
const hist = chatHistory.slice(-20, -1);
const data = await chatAPI(text, hist, attaches.length ? attaches.map(a => ({ name: a.name, mimeType: a.mimeType, dataUrl: a.dataUrl })) : null);
pend.classList.remove('pending');
const structured = _tryParseStructuredReply(data.reply || '');
if (structured) {
pend.innerHTML = _renderStructuredInChat(structured);
} else {
pend.innerHTML = renderMD(data.reply || '');
}
if (Array.isArray(data.attachment_notes) && data.attachment_notes.length) {
const notice = document.createElement('div');
notice.className = 'attachment-notice';
notice.textContent = '📎 ' + data.attachment_notes.join(' · ');
pend.appendChild(notice);
}
chatHistory.push({ role: 'assistant', content: data.reply });
saveChatHistory();
touchActivity();
// Soft-Gate: postMessage to parent window after N user→assistant
// exchanges (configurable via ?showcase=1 query param). The outer
// qognio.com/showcase page listens for this event and triggers the
// lead-form modal. Sent only when running in an iframe.
try {
const isShowcase = new URLSearchParams(window.location.search).get('showcase') === '1';
if (isShowcase && window.parent && window.parent !== window) {
const userMessages = chatHistory.filter((m) => m.role === 'user').length;
const SOFT_GATE_AT = 3;
if (userMessages === SOFT_GATE_AT) {
window.parent.postMessage(
{ type: 'qognio:soft-gate', afterMessages: userMessages, botSlug: window.location.hostname.split('.')[0] },
'*'
);
}
}
} catch (sgErr) {
// non-fatal — swallow
}
} catch (e) {
pend.className = 'msg sys';
pend.textContent = '⚠ Fehler: ' + (e.message || 'unbekannt');
toast('Verbindung fehlgeschlagen', 'error');
} finally {
$('#composer-send').disabled = false;
$('#composer').focus();
}
}
// ==== Structured request helper ====
function _extractJSON(reply) {
let s = (reply || '').trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (fence) s = fence[1].trim();
const b = s.indexOf('{'); const e = s.lastIndexOf('}');
if (b >= 0 && e > b) s = s.slice(b, e + 1);
return s;
}
function _repairJSON(s) {
s = s.replace(/,(\s*[}\]])/g, '$1');
s = s.replace(/,\s*,/g, ',');
s = s.replace(/(\})\s*\n\s*(\{)/g, '$1,\n$2');
s = s.replace(/(")(\s*\n\s*)(")/g, '$1,$2$3');
return s;
}
// --- Structured-Reply-Fallback fuer Chat (2026-04-25) ---
// Wenn der Bot versehentlich QUIZ/FLASHCARD/CASE/EXAM-JSON liefert statt Markdown,
// parse + render hier lesbar statt Raw-JSON im Chat-Bubble anzuzeigen.
function _tryParseStructuredReply(reply) {
let s = (reply || '').trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (fence) s = fence[1].trim();
if (!s.startsWith('{')) return null;
const b = s.indexOf('{'), e = s.lastIndexOf('}');
if (b < 0 || e <= b) return null;
const raw = s.slice(b, e + 1);
let obj;
try { obj = JSON.parse(raw); } catch {
try { obj = JSON.parse(_repairJSON(raw)); } catch { return null; }
}
if (!obj || typeof obj !== 'object') return null;
const KNOWN = [
'case','quiz','flashcards','exam','lesson','presentation',
// Bot-spezifische Strukturen (KURT/VESTIGIA/PAUL/Pia/Otto/Eli/LIMEN/Zita/LIBRA/IDA)
'audit','privacy_check','mail_check','plan','validate','interview','decode','write','calc','unterweisung',
'compliance_report'
];
if (!KNOWN.includes(obj.type)) return null;
return obj;
}
function _renderStructuredInChat(obj) {
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const label = ['A','B','C','D','E','F','G','H'];
const TYPE_BADGE = {
case:'📖 Fallbeispiel', quiz:'🧠 Quiz-Fragen', flashcards:'📇 Karteikarten',
exam:'📝 Prüfung', lesson:'🎓 Lektion', presentation:'🖼 Präsentation',
audit:'🛡 AI-Act Audit-Trail', privacy_check:'🩺 Pflege-Datenschutz-Check',
mail_check:'📧 Mail-Check', plan:'🗓 Plan', validate:'✓ Validierung',
interview:'🎙 Wissens-Interview', decode:'🔍 Zeugnis-Decoder',
write:'✍ Zeugnis-Entwurf', calc:'🧮 Kalkulation',
unterweisung:'🛠 AdA-Unterweisung',
compliance_report:'🔍 Website-Compliance-Check',
};
let html = `<div class="structured-chat"><div class="struct-badge">${TYPE_BADGE[obj.type] || '📄 Strukturierte Antwort'}</div>`;
if (obj.topic) html += `<div class="struct-topic">${esc(obj.topic)}</div>`;
if (obj.type === 'case') {
if (obj.scenario) html += `<div class="struct-scenario"><strong>Szenario:</strong><br>${esc(obj.scenario)}</div>`;
const fragen = obj.fragen || obj.questions || [];
fragen.forEach((f, i) => {
html += `<div class="struct-question"><strong>Frage ${i+1}:</strong> ${esc(f.frage || f.q || '')}`;
const opts = f.options || [];
if (opts.length) {
html += '<ul class="struct-options">';
opts.forEach((o, j) => {
const correct = (f.correct === j);
html += `<li${correct ? ' class="correct"' : ''}><strong>${label[j]||(j+1)})</strong> ${esc(o)}${correct ? ' ✓' : ''}</li>`;
});
html += '</ul>';
}
const ex = f.explain || f.explanation;
if (ex) html += `<div class="struct-explain"><em>Erklärung:</em> ${esc(ex)}</div>`;
html += '</div>';
});
const lessons = obj.lessons || [];
if (lessons.length) {
html += '<div class="struct-lessons"><strong>Lessons:</strong><ul>';
lessons.forEach(l => { html += `<li>${esc(l)}</li>`; });
html += '</ul></div>';
}
const norm = obj.paragraphen || obj.normen || obj.artikel || [];
if (norm.length) {
html += '<div class="struct-norms"><strong>Rechtsnormen:</strong> ' + norm.map(n => `<code>${esc(n)}</code>`).join(' · ') + '</div>';
}
} else if (obj.type === 'quiz' || obj.type === 'exam') {
// Exam-spezifisch: Dauer-Anzeige + HF-Tag pro Frage (IDA AdA-Schein)
if (obj.type === 'exam' && obj.duration_min) {
html += `<div class="struct-row"><strong>Dauer:</strong> ${esc(obj.duration_min)} Min · <strong>Fragen:</strong> ${(obj.questions||[]).length}</div>`;
}
const qs = obj.questions || [];
qs.forEach((q, i) => {
const hfTag = (obj.type === 'exam' && q.hf != null) ? ` <span style="font-size:.8em;color:var(--accent,#7c3aed);font-weight:600">[HF ${esc(q.hf)}]</span>` : '';
html += `<div class="struct-question"><strong>Frage ${i+1}:</strong>${hfTag} ${esc(q.q || q.frage || '')}`;
const opts = q.options || [];
if (opts.length) {
html += '<ul class="struct-options">';
opts.forEach((o, j) => {
const correct = (q.correct === j);
html += `<li${correct ? ' class="correct"' : ''}><strong>${label[j]||(j+1)})</strong> ${esc(o)}${correct ? ' ✓' : ''}</li>`;
});
html += '</ul>';
}
const ex = q.explain || q.explanation;
if (ex) html += `<div class="struct-explain"><em>Erklärung:</em> ${esc(ex)}</div>`;
html += '</div>';
});
} else if (obj.type === 'flashcards') {
(obj.cards || []).forEach((c, i) => {
html += `<div class="struct-flashcard"><div class="fc-front"><strong>Karte ${i+1}:</strong> ${esc(c.front || '')}</div><div class="fc-back">${esc(c.back || '')}</div>`;
if (c.hint) html += `<div class="fc-hint"><em>Hinweis:</em> ${esc(c.hint)}</div>`;
html += '</div>';
});
} else if (obj.type === 'lesson' || obj.type === 'presentation') {
if (obj.objectives || obj.learning_objectives) {
const objs = obj.objectives || obj.learning_objectives;
html += '<div class="struct-objectives"><strong>Lernziele:</strong><ul>';
objs.forEach(o => { html += `<li>${esc(o)}</li>`; });
html += '</ul></div>';
}
(obj.slides || []).forEach((s, i) => {
html += `<div class="struct-slide"><strong>${i+1}. ${esc(s.title || '')}</strong>`;
if (s.content_md || s.content) html += `<div class="slide-content">${renderMD(s.content_md || s.content || '')}</div>`;
if (s.key_point) html += `<div class="slide-key">💡 ${esc(s.key_point)}</div>`;
html += '</div>';
});
} else if (obj.type === 'audit') {
// KURT / VESTIGIA — AI-Act Audit-Trail
if (obj.system) html += `<div class="struct-row"><strong>System:</strong> ${esc(obj.system)}</div>`;
const cls = obj.ai_act_risk_class || obj.risk_class;
if (cls) {
const clsColor = { prohibited:'#dc2626', high:'#dc2626', limited:'#eab308', minimal:'#22c55e' }[cls] || '#8b8a99';
html += `<div class="struct-row"><strong>Risiko-Klasse:</strong> <span style="color:${clsColor};font-weight:600;text-transform:uppercase">${esc(cls)}</span></div>`;
}
if (obj.role) html += `<div class="struct-row"><strong>Rolle:</strong> ${esc(obj.role)}</div>`;
if (obj.dsgvo_relevant != null) html += `<div class="struct-row"><strong>DSGVO-relevant:</strong> ${obj.dsgvo_relevant ? 'ja' : 'nein'}</div>`;
if (obj.art22_check) html += `<div class="struct-row"><strong>Art. 22 DSGVO:</strong> ${esc(obj.art22_check)}</div>`;
const arts = obj.required_artifacts || [];
if (arts.length) {
html += '<div class="struct-section"><strong>Erforderliche Artefakte:</strong><table class="struct-table"><thead><tr><th>Artefakt</th><th>Status</th><th>Basis</th></tr></thead><tbody>';
arts.forEach(a => {
const sColor = { required:'#dc2626', optional:'#eab308', 'not-required':'#22c55e' }[a.status] || '#8b8a99';
html += `<tr><td>${esc(a.name||'')}</td><td style="color:${sColor};font-weight:600">${esc(a.status||'')}</td><td><code>${esc(a.based_on||'')}</code></td></tr>`;
});
html += '</tbody></table></div>';
}
const cw = obj.crosswalk_savings || [];
if (cw.length) {
html += '<div class="struct-section"><strong>Crosswalk-Einsparung:</strong><ul>';
cw.forEach(c => { html += `<li>${esc(c)}</li>`; });
html += '</ul></div>';
}
const dl = obj.deadlines || [];
if (dl.length) {
html += '<div class="struct-section"><strong>Fristen:</strong><ul>';
dl.forEach(d => { html += `<li><code>${esc(d.date||'')}</code> — ${esc(d.what||'')}</li>`; });
html += '</ul></div>';
}
const ws = obj.warnings || obj.warnung || [];
if (ws.length) {
html += '<div class="struct-section" style="color:#dc2626"><strong>⚠ Warnungen:</strong><ul>';
ws.forEach(w => { html += `<li>${esc(w)}</li>`; });
html += '</ul></div>';
}
} else if (obj.type === 'privacy_check') {
// PAUL — Pflege-Datenschutz-Ampel
const ampel = obj.ampel || 'yellow';
const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99';
const ampelLabel = { green:'🟢 GRÜN — OK', yellow:'🟡 GELB — Vorsicht', red:'🔴 ROT — Akute Gefahr' }[ampel] || ampel;
html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:${ampelColor}">${ampelLabel}</div>`;
if (obj.situation) html += `<div class="struct-row"><strong>Situation:</strong> ${esc(obj.situation)}</div>`;
if (obj.schweigepflicht) html += `<div class="struct-row"><strong>Schweigepflicht (§203 StGB):</strong> ${esc(obj.schweigepflicht)}</div>`;
if (obj.dsgvo_basis) html += `<div class="struct-row"><strong>DSGVO-Basis:</strong> <code>${esc(obj.dsgvo_basis)}</code></div>`;
const acts = obj.handlung || obj.handlungen || [];
if (acts.length) {
html += '<div class="struct-section"><strong>Handlung:</strong><ol>';
acts.forEach(a => { html += `<li>${esc(a)}</li>`; });
html += '</ol></div>';
}
const wns = obj.warnung || obj.warnungen || [];
if (wns.length) {
html += `<div class="struct-section" style="color:${ampelColor}"><strong>⚠ Achtung:</strong><ul>`;
wns.forEach(w => { html += `<li>${esc(w)}</li>`; });
html += '</ul></div>';
}
} else if (obj.type === 'mail_check') {
// Pia — Phishing-Prüferin Mail-Bewertung
const ampel = obj.ampel || 'yellow';
const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99';
const ampelLabel = { green:'🟢 GRÜN — vermutlich legitim', yellow:'🟡 GELB — verdächtig, zweite Meinung holen', red:'🔴 ROT — sehr wahrscheinlich Phishing/BEC' }[ampel] || ampel;
html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:${ampelColor}">${ampelLabel}</div>`;
if (obj.kurz_begruendung) html += `<div class="struct-row"><strong>Kurz:</strong> ${esc(obj.kurz_begruendung)}</div>`;
if (obj.pattern) html += `<div class="struct-row"><strong>Pattern:</strong> <code>${esc(obj.pattern)}</code></div>`;
const flags = obj.red_flags || [];
if (flags.length) {
html += '<div class="struct-section"><strong>Red Flags:</strong><ul>';
flags.forEach(f => { html += `<li>${esc(f)}</li>`; });
html += '</ul></div>';
}
const recs = obj.empfohlene_aktion || obj.aktionen || [];
if (recs.length) {
html += '<div class="struct-section"><strong>Empfohlene Aktion:</strong><ol>';
recs.forEach(a => { html += `<li>${esc(a)}</li>`; });
html += '</ol></div>';
}
if (obj.weiterleiten_an) html += `<div class="struct-row"><strong>Weiterleiten an:</strong> ${esc(obj.weiterleiten_an)}</div>`;
} else if (obj.type === 'plan') {
// Otto — 90-Tage-Onboarding-Plan
if (obj.role) html += `<div class="struct-row"><strong>Rolle:</strong> ${esc(obj.role)}</div>`;
const items = obj.weeks || obj.days || obj.steps || obj.phases || [];
if (items.length) {
items.forEach((it, i) => {
const label = it.day || it.week || it.phase || `Phase ${i+1}`;
const focus = it.focus || it.title || '';
html += `<div class="struct-step"><div class="step-head"><strong>${esc(label)}</strong>${focus?`${esc(focus)}`:''}</div>`;
const tasks = it.tasks || it.aufgaben || [];
if (tasks.length) {
html += '<ul>';
tasks.forEach(t => { html += `<li>${esc(typeof t === 'string' ? t : (t.task || t.text || JSON.stringify(t)))}</li>`; });
html += '</ul>';
}
if (it.success_signal || it.success) html += `<div class="step-success">✓ ${esc(it.success_signal || it.success)}</div>`;
html += '</div>';
});
}
} else if (obj.type === 'validate') {
// Eli — E-Rechnungs-Validator
const status = obj.status || 'ok';
const statusColor = { ok:'#22c55e', warnings:'#eab308', errors:'#dc2626' }[status] || '#8b8a99';
const statusLabel = { ok:'✅ Validierung OK', warnings:'⚠ Warnungen', errors:'❌ Fehler — nicht konform' }[status] || status;
html += `<div class="struct-row" style="font-weight:700;color:${statusColor}">${statusLabel}</div>`;
if (obj.file || obj.dateiname) html += `<div class="struct-row"><strong>Datei:</strong> <code>${esc(obj.file || obj.dateiname)}</code></div>`;
if (obj.format) html += `<div class="struct-row"><strong>Format:</strong> ${esc(obj.format)}</div>`;
const issues = obj.issues || obj.findings || [];
if (issues.length) {
html += '<div class="struct-section"><strong>Befunde:</strong><table class="struct-table"><thead><tr><th>Severity</th><th>Meldung</th><th>Fix</th></tr></thead><tbody>';
issues.forEach(i => {
const sev = i.severity || 'info';
const sevColor = { error:'#dc2626', warning:'#eab308', info:'#06b6d4' }[sev] || '#8b8a99';
html += `<tr><td style="color:${sevColor};font-weight:600">${esc(sev)}</td><td>${esc(i.message||i.msg||'')}</td><td>${esc(i.fix||'')}</td></tr>`;
});
html += '</tbody></table></div>';
}
} else if (obj.type === 'interview') {
// LIMEN — Wissens-Interview entlang Achse
if (obj.achse) html += `<div class="struct-row"><strong>Wissens-Achse:</strong> <code>${esc(obj.achse)}</code></div>`;
const fs = obj.fragen || obj.questions || [];
fs.forEach((f, i) => {
html += `<div class="struct-question"><strong>Frage ${i+1}:</strong> ${esc(f.f || f.frage || f.q || '')}`;
if (f.tipp_aktiv_zuhören || f.tipp_zuhoeren) html += `<div style="font-size:.85em;color:#8b8a99;margin-top:.25rem"><em>👂 Tipp Aktiv-Zuhören:</em> ${esc(f.tipp_aktiv_zuhören || f.tipp_zuhoeren)}</div>`;
if (f.tipp_nachfass) html += `<div style="font-size:.85em;color:#8b8a99;margin-top:.25rem"><em>↳ Tipp Nachfass:</em> ${esc(f.tipp_nachfass)}</div>`;
html += '</div>';
});
} else if (obj.type === 'decode') {
// Zita — Zeugnis-Decoder
if (obj.zeugnis_text) html += `<div class="struct-row"><strong>Zeugnis-Text:</strong><blockquote style="border-left:3px solid var(--accent);padding-left:.75rem;margin:.5rem 0;color:#cfcedb">${esc(obj.zeugnis_text)}</blockquote></div>`;
const gradeNum = (g) => {
const n = typeof g === 'number' ? g : parseFloat(String(g).replace(',','.'));
return isNaN(n) ? null : n;
};
const gradeColor = (g) => {
const n = gradeNum(g);
if (n != null) return n <= 2 ? '#22c55e' : n <= 3 ? '#eab308' : '#dc2626';
return ({ sehr_gut:'#22c55e', gut:'#22c55e', befriedigend:'#eab308', ausreichend:'#eab308', mangelhaft:'#dc2626', ungenuegend:'#dc2626' }[String(g||'')] || '#8b8a99');
};
if (obj.overall_grade != null && obj.overall_grade !== '') {
const ogColor = gradeColor(obj.overall_grade);
html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:${ogColor}">Gesamt-Note: ${esc(obj.overall_grade)}${obj.grade_label ? ' — ' + esc(obj.grade_label) : ''}</div>`;
}
// Sub-Noten (verhalten/schlussformel)
if (obj.verhalten_grade != null || obj.schlussformel_grade != null) {
html += '<div class="struct-row" style="font-size:.9em">';
if (obj.verhalten_grade != null) html += `<strong>Verhalten:</strong> <span style="color:${gradeColor(obj.verhalten_grade)};font-weight:600">${esc(obj.verhalten_grade)}</span> `;
if (obj.schlussformel_grade != null) html += ` · <strong>Schlussformel:</strong> <span style="color:${gradeColor(obj.schlussformel_grade)};font-weight:600">${esc(obj.schlussformel_grade)}</span>`;
html += '</div>';
}
// Akzeptiere mehrere Schema-Varianten: decodierung/codes/findings + passage/quote, klartext/bedeutung/meaning, note/grade, code/section
const dec = obj.decodierung || obj.codes || obj.findings || [];
if (dec.length) {
html += '<div class="struct-section"><strong>Code-Decodierung:</strong>';
dec.forEach(d => {
const passage = d.passage || d.quote || d.text || '';
const klartext = d.klartext || d.bedeutung || d.meaning || '';
const code = d.code || d.section || '';
const note = d.note || d.grade || '';
const risk = d.risk || '';
const noteColor = note !== '' ? gradeColor(note) : '#8b8a99';
html += `<div class="struct-step"><div><strong>"${esc(passage)}"</strong></div>`;
if (code) html += `<div style="font-size:.85em;color:#8b8a99">${esc(code)}</div>`;
if (klartext) html += `<div>↳ ${esc(klartext)}</div>`;
if (note !== '') html += `<div style="color:${noteColor};font-weight:600">Note: ${esc(note)}${risk ? ` · Risiko: ${esc(risk)}` : ''}</div>`;
html += '</div>';
});
html += '</div>';
}
const redFlags = obj.red_flags || [];
if (redFlags.length) {
html += '<div class="struct-section" style="color:#dc2626"><strong>🚩 Red Flags:</strong><ul>';
redFlags.forEach(f => { html += `<li>${esc(f)}</li>`; });
html += '</ul></div>';
}
const missing = obj.missing_elements || [];
if (missing.length) {
html += '<div class="struct-section" style="color:#eab308"><strong>Fehlende Pflicht-Elemente:</strong><ul>';
missing.forEach(m => { html += `<li>${esc(m)}</li>`; });
html += '</ul></div>';
}
const rewrites = obj.rewrite_suggestions || [];
if (rewrites.length) {
html += '<div class="struct-section"><strong>Umschreib-Vorschläge:</strong><table class="struct-table"><thead><tr><th>Original</th><th>Vorschlag</th><th>Warum</th></tr></thead><tbody>';
rewrites.forEach(r => {
html += `<tr><td>${esc(r.original||'')}</td><td>${esc(r.rewrite||'')}</td><td>${esc(r.why||'')}</td></tr>`;
});
html += '</tbody></table></div>';
}
const dsources = obj.sources || obj.quellen || [];
if (dsources.length) {
html += '<div class="struct-row" style="font-size:.85em;color:#8b8a99;margin-top:.4rem"><strong>Quellen:</strong> ' + dsources.map(s => `<code>${esc(s)}</code>`).join(' · ') + '</div>';
}
} else if (obj.type === 'write') {
// Zita — Zeugnis-Schreiber
if (obj.role) html += `<div class="struct-row"><strong>Rolle:</strong> ${esc(obj.role)}</div>`;
if (obj.grade != null) {
const gColor = obj.grade <= 2 ? '#22c55e' : obj.grade <= 3 ? '#eab308' : '#dc2626';
html += `<div class="struct-row"><strong>Note:</strong> <span style="color:${gColor};font-weight:600">${esc(obj.grade)}</span></div>`;
}
const zeugnisText = obj.zeugnis || obj.zeugnis_text || obj.markdown || obj.text;
if (zeugnisText) {
html += '<div class="struct-section"><strong>Zeugnis-Entwurf:</strong>';
html += `<div class="struct-doc" style="background:rgba(255,255,255,.04);padding:.75rem;border-radius:.5rem;border:1px solid rgba(255,255,255,.1);margin-top:.5rem;white-space:pre-wrap;font-family:Georgia,serif">${esc(zeugnisText)}</div>`;
html += '</div>';
}
const notenSignale = obj.noten_signale || [];
if (notenSignale.length) {
html += '<div class="struct-section"><strong>Noten-Signale:</strong><table class="struct-table"><thead><tr><th>Satz</th><th>Codiert</th></tr></thead><tbody>';
notenSignale.forEach(s => {
html += `<tr><td>${esc(s.satz || '')}</td><td>${esc(s.codiert || '')}</td></tr>`;
});
html += '</tbody></table></div>';
}
const paragraphen = obj.verwendete_paragraphen || obj.paragraphen || [];
if (paragraphen.length) {
html += '<div class="struct-row"><strong>Verwendete Paragraphen:</strong> ' + paragraphen.map(p => `<code>${esc(p)}</code>`).join(' · ') + '</div>';
}
const warnings = obj.warnings || obj.warnungen || [];
if (warnings.length) {
html += '<div class="struct-section" style="color:#dc2626"><strong>⚠ Warnungen:</strong><ul>';
warnings.forEach(w => { html += `<li>${esc(w)}</li>`; });
html += '</ul></div>';
}
const notes = obj.notes || obj.hinweise || [];
if (notes.length) {
html += '<div class="struct-section"><strong>Hinweise:</strong><ul>';
notes.forEach(n => { html += `<li>${esc(n)}</li>`; });
html += '</ul></div>';
}
const wsources = obj.sources || obj.quellen || [];
if (wsources.length) {
html += '<div class="struct-row" style="font-size:.85em;color:#8b8a99"><strong>Quellen:</strong> ' + wsources.map(s => `<code>${esc(s)}</code>`).join(' · ') + '</div>';
}
} else if (obj.type === 'calc') {
// LIBRA — Kalkulations-Rechner
if (obj.formel) html += `<div class="struct-row"><strong>Formel:</strong> <code>${esc(obj.formel)}</code></div>`;
if (obj.inputs && typeof obj.inputs === 'object') {
html += '<div class="struct-section"><strong>Eingaben:</strong><table class="struct-table"><tbody>';
Object.entries(obj.inputs).forEach(([k,v]) => { html += `<tr><td><code>${esc(k)}</code></td><td>${esc(v)}</td></tr>`; });
html += '</tbody></table></div>';
}
const steps = obj.schritte || obj.steps || [];
if (steps.length) {
html += '<div class="struct-section"><strong>Rechenweg:</strong><ol>';
steps.forEach(s => { html += `<li>${esc(typeof s === 'string' ? s : (s.text || JSON.stringify(s)))}</li>`; });
html += '</ol></div>';
}
if (obj.ergebnis != null) html += `<div class="struct-row" style="font-size:1.1rem;font-weight:700;color:var(--accent);margin-top:.5rem"><strong>Ergebnis:</strong> ${esc(obj.ergebnis)}</div>`;
} else if (obj.type === 'unterweisung') {
// IDA — AdA-Unterweisung (4-Stufen / Lehrgespraech / Leittext)
if (obj.methode) html += `<div class="struct-row"><strong>Methode:</strong> <code>${esc(obj.methode)}</code></div>`;
const lz = obj.lernzielanalyse || obj.lernziele || null;
if (lz && typeof lz === 'object') {
html += '<div class="struct-section"><strong>Lernzielanalyse:</strong><table class="struct-table"><tbody>';
if (lz.richtlernziel) html += `<tr><td><strong>Richtlernziel</strong></td><td>${esc(lz.richtlernziel)}</td></tr>`;
if (lz.groblernziel) html += `<tr><td><strong>Groblernziel</strong></td><td>${esc(lz.groblernziel)}</td></tr>`;
if (lz.feinlernziel) html += `<tr><td><strong>Feinlernziel</strong></td><td>${esc(lz.feinlernziel)}</td></tr>`;
if (lz.bereich) html += `<tr><td><strong>Bereich</strong></td><td><code>${esc(lz.bereich)}</code></td></tr>`;
html += '</tbody></table></div>';
}
const phasen = obj.phasen || obj.stufen || obj.phases || [];
if (phasen.length) {
let totalMin = 0;
phasen.forEach(p => { if (typeof p.minuten === 'number') totalMin += p.minuten; });
html += `<div class="struct-section"><strong>Phasen${totalMin?` (${totalMin} Min gesamt)`:''}:</strong>`;
phasen.forEach((p, i) => {
html += `<div class="struct-step"><div class="step-head"><strong>${i+1}. ${esc(p.name || p.stufe || '')}</strong>${p.minuten?` <span style="color:#8b8a99">· ${esc(p.minuten)} Min</span>`:''}</div>`;
if (p.ausbilder_tut) html += `<div><strong>Ausbilder:in:</strong> ${esc(p.ausbilder_tut)}</div>`;
if (p.azubi_tut) html += `<div><strong>Azubi:</strong> ${esc(p.azubi_tut)}</div>`;
if (p.feedback_check) html += `<div style="font-size:.9em;color:#8b8a99"><em>Check:</em> ${esc(p.feedback_check)}</div>`;
html += '</div>';
});
html += '</div>';
}
if (obj.erfolgskontrolle) {
html += `<div class="struct-row"><strong>Erfolgskontrolle:</strong> ${esc(obj.erfolgskontrolle)}</div>`;
}
const alt = obj.handlungsalternativen || obj.alternativen || [];
if (alt.length) {
html += '<div class="struct-section"><strong>Handlungs-Alternativen:</strong><ul>';
alt.forEach(a => { html += `<li>${esc(a)}</li>`; });
html += '</ul></div>';
}
} else if (obj.type === 'compliance_report') {
// Vera — Website-Compliance-Check (TMG/DSGVO/TTDSG)
const ampelColor = { green: '#10b981', yellow: '#f59e0b', red: '#ef4444' }[obj.ampel] || '#8b8a99';
const ampelEmoji = { green: '🟢', yellow: '🟡', red: '🔴' }[obj.ampel] || '⚪';
if (obj.url) html += `<div class="struct-row"><strong>Geprüfte URL:</strong> <a href="${esc(obj.url)}" target="_blank" rel="noopener">${esc(obj.url)}</a></div>`;
if (typeof obj.overall_score === 'number') {
html += `<div class="struct-row" style="font-size:1.4rem;font-weight:700;color:${ampelColor};margin:.5rem 0">${ampelEmoji} Score: ${obj.overall_score} %</div>`;
}
if (obj.summary) html += `<div class="struct-row" style="font-style:italic;margin-bottom:.5rem">${esc(obj.summary)}</div>`;
// Pro Section: Impressum, Datenschutz, Cookies
const sections = [
{ key: 'impressum', label: '📄 Impressum (TMG § 5 + § 18 MStV + ODR + VSBG)' },
{ key: 'datenschutz', label: '🛡 Datenschutzerklärung (DSGVO Art. 13)' },
];
for (const s of sections) {
const sec = obj[s.key];
if (!sec) continue;
const sScore = typeof sec.score === 'number' ? `${sec.score} %` : '';
const sUrl = sec.url ? ` <a href="${esc(sec.url)}" target="_blank" rel="noopener" style="font-size:.85em">↗ Quelle</a>` : '';
html += `<div class="struct-section"><strong>${s.label}${sScore}${sUrl}</strong>`;
if (Array.isArray(sec.ok) && sec.ok.length) {
html += '<ul style="list-style:none;padding-left:0">';
sec.ok.forEach(o => { html += `<li style="color:#10b981">✓ ${esc(o)}</li>`; });
html += '</ul>';
}
if (Array.isArray(sec.missing) && sec.missing.length) {
html += '<ul style="list-style:none;padding-left:0">';
sec.missing.forEach(m => { html += `<li style="color:#ef4444">✗ ${esc(m)}</li>`; });
html += '</ul>';
}
if (Array.isArray(sec.warnings) && sec.warnings.length) {
html += '<ul style="list-style:none;padding-left:0">';
sec.warnings.forEach(w => { html += `<li style="color:#f59e0b">⚠ ${esc(w)}</li>`; });
html += '</ul>';
}
html += '</div>';
}
// Cookies
const c = obj.cookies;
if (c && typeof c === 'object') {
const bannerEmoji = c.banner_present ? '✓' : '✗';
const bannerColor = c.banner_present ? '#10b981' : '#ef4444';
html += `<div class="struct-section"><strong>🍪 Cookies (TTDSG § 25)</strong>`;
html += `<div style="color:${bannerColor}">${bannerEmoji} Cookie-Banner: ${c.banner_present ? 'erkannt' : 'NICHT erkannt'}${c.banner_signal ? ` <code style="font-size:.8em">${esc(c.banner_signal)}</code>` : ''}</div>`;
if (Array.isArray(c.third_party_loaders_pre_consent) && c.third_party_loaders_pre_consent.length) {
html += `<div style="color:#f59e0b;margin-top:.3rem">⚠ ${c.third_party_loaders_pre_consent.length} Drittanbieter im HTML — prüfen ob hinter Consent-Gate:</div><ul>`;
c.third_party_loaders_pre_consent.forEach(t => { html += `<li>${esc(t)}</li>`; });
html += '</ul>';
}
if (Array.isArray(c.notes) && c.notes.length) {
html += '<ul style="margin-top:.3rem;font-size:.9em;color:var(--text-mute)">';
c.notes.forEach(n => { html += `<li>${esc(n)}</li>`; });
html += '</ul>';
}
html += '</div>';
}
// Next steps
if (Array.isArray(obj.next_steps) && obj.next_steps.length) {
html += '<div class="struct-section"><strong>Nächste Schritte:</strong><ol>';
obj.next_steps.forEach(n => { html += `<li>${esc(n)}</li>`; });
html += '</ol></div>';
}
if (obj.scanned_at) html += `<div style="font-size:.75em;color:var(--text-mute);margin-top:.5rem">Geprüft am ${esc(obj.scanned_at.replace('T',' ').slice(0,19))} UTC</div>`;
}
const HINT_BY_TYPE = {
audit:'↳ Crosswalk gespeichert. Brauchst du Tech-Doku Anhang IV (VESTIGIA) oder die DPIA-Tiefe (Cora)?',
privacy_check:'↳ Bei Unsicherheit: PDL/EL informieren, Schweigepflicht §203 StGB hat Vorrang vor DSGVO.',
mail_check:'↳ Im Zweifel: nicht klicken, an IT/SOC weiterleiten. Eskalation > Schaden.',
plan:'↳ Tipp: 1. Tag-Erlebnis ist der wichtigste Baustein — ohne Wow-Moment bröckelt die Probezeit.',
validate:'↳ Bei "errors": Rechnung nicht buchen, Lieferanten kontaktieren, Validator-Output anhängen.',
interview:'↳ Story-First: lass die Person erzählen, hör 3 Sekunden nach der Antwort weiter zu.',
decode:'↳ Bei kritischen Codes: Anwält:in für Arbeitsrecht konsultieren, Zeugnis-Anfechtung prüfen.',
write:'↳ Vor Übergabe: 4-Augen-Review durch HR-Lead, Datum prüfen, korrekt unterschreiben.',
calc:'↳ Stichproben mit echten Aufträgen testen — Theorie-Marge ≠ Praxis-Marge.',
unterweisung:'↳ Methode folgt Lernziel: psychomotorisch → 4-Stufen, kognitiv → Lehrgespräch, komplex → Leittext.',
};
const hint = HINT_BY_TYPE[obj.type] || '↳ Nutze den <strong>Quiz</strong>-, <strong>Karteikarten</strong>- oder <strong>Chat</strong>-Tab für die interaktive Version.';
html += `<div class="struct-hint">${hint}</div>`;
html += '</div>';
return html;
}
async function requestStructured(prompt) {
const data = await chatAPI(prompt, []);
let raw = _extractJSON(data.reply || '');
try { return JSON.parse(raw); } catch (e1) {
try { return JSON.parse(_repairJSON(raw)); } catch (e2) {
const heal = await chatAPI(prompt + "\n\nHINWEIS: Dein letzter JSON-Output war invalid (Parse-Fehler). Antworte JETZT NUR mit sauberem JSON. Keine Kommentare, kein Markdown-Fence, keine trailing commas, alle String-Werte in doppelten Anf\u00fchrungszeichen.", []);
const healRaw = _extractJSON(heal.reply || '');
try { return JSON.parse(healRaw); } catch (e3) {
try { return JSON.parse(_repairJSON(healRaw)); } catch (e4) {
const err = new Error('Konnte JSON nicht parsen \u2014 Modell liefert kaputtes Format. Versuch es noch mal.');
err.original = e1.message; err.raw = raw.slice(0, 300);
throw err;
}
}
}
}
}
// ==== Quiz ====
const quizState = { set: null, idx: 0, correct: 0, topic: null };
function renderQuizIntro() {
const host = $('#quiz-host');
const topics = CURRICULA.curricula.flatMap(c =>
c.modules.map(m => ({ id: m.id, curr: c.title, title: m.title, color: c.color }))
);
host.innerHTML = `
<div class="quiz-intro">
<div class="topic-select">
<h3>🎯 Quiz-Thema wählen</h3>
<p style="color:var(--text-dim);font-size:.85rem">Wähle ein Modul Luna generiert 10 Multiple-Choice-Fragen.</p>
<div class="topic-btn-row" id="quiz-topic-pills"></div>
<div style="margin-top:1rem;display:flex;gap:.5rem;align-items:center">
<label for="quiz-count" style="color:var(--text-dim);font-size:.85rem">Anzahl:</label>
<select id="quiz-count" class="btn-sec" style="padding:.4rem .7rem">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="15">15</option>
</select>
<div class="spacer" style="flex:1"></div>
<button class="btn-primary" id="quiz-start">Quiz starten</button>
</div>
</div>
</div>
`;
const pillHost = $('#quiz-topic-pills');
CURRICULA.curricula.forEach(c => {
c.modules.forEach(m => {
const btn = document.createElement('button');
btn.className = 'topic-pill';
btn.dataset.curr = c.id;
btn.dataset.mod = m.id;
btn.textContent = m.title;
btn.title = c.title;
btn.addEventListener('click', () => {
$$('#quiz-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false'));
btn.setAttribute('aria-selected', 'true');
quizState.topic = { curr: c, mod: m };
});
pillHost.appendChild(btn);
});
});
$('#quiz-start').addEventListener('click', () => {
if (!quizState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; }
const count = parseInt($('#quiz-count').value, 10);
startQuiz(quizState.topic, count);
});
}
async function startQuiz(topic, count) {
const host = $('#quiz-host');
host.innerHTML = `<div class="quiz-card"><div class="dots" style="justify-content:center"><span></span><span></span><span></span></div><p style="text-align:center;color:var(--text-dim);margin-top:1rem">Luna erstellt ${count} Fragen zu „${topic.mod.title}" …</p></div>`;
try {
const topicText = `${topic.curr.title}${topic.mod.title}`;
const data = await requestStructured(`QUIZ_REQUEST topic="${topicText}" count=${count}`);
if (!data.questions || !Array.isArray(data.questions) || data.questions.length === 0) {
throw new Error('Keine Fragen erhalten');
}
quizState.set = data.questions;
quizState.idx = 0;
quizState.correct = 0;
renderQuizQuestion();
} catch (e) {
host.innerHTML = `<div class="quiz-card"><p style="color:var(--danger)">⚠ Konnte Quiz nicht erstellen: ${e.message}</p><div style="margin-top:1rem"><button class="btn-sec" onclick="location.reload()">Neu laden</button></div></div>`;
toast('Quiz-Generierung fehlgeschlagen', 'error');
}
}
function renderQuizQuestion() {
const host = $('#quiz-host');
const q = quizState.set[quizState.idx];
if (!q) return renderQuizDone();
const letters = ['A', 'B', 'C', 'D', 'E', 'F'];
host.innerHTML = `
<div class="quiz-card">
<div class="quiz-progress">
<span>Frage ${quizState.idx + 1} / ${quizState.set.length}</span>
<span> ${quizState.correct}</span>
</div>
<div class="quiz-q">${escapeHTML(q.q)}</div>
<div class="quiz-options" id="quiz-opts">
${q.options.map((opt, i) => `
<button class="quiz-option" data-idx="${i}" aria-label="Option ${letters[i]}">
<span class="opt-letter">${letters[i]}</span>
<span>${escapeHTML(opt)}</span>
</button>
`).join('')}
</div>
<div class="quiz-explain hidden" id="quiz-explain"></div>
<div class="quiz-next hidden" id="quiz-next">
<button class="btn-primary" id="quiz-next-btn">Weiter </button>
</div>
</div>
`;
$$('#quiz-opts .quiz-option').forEach(btn => {
btn.addEventListener('click', () => handleQuizAnswer(btn, q));
});
}
function handleQuizAnswer(btn, q) {
const chosen = parseInt(btn.dataset.idx, 10);
const correct = q.correct;
state.totalAnswers += 1;
const curId = quizState.topic.curr.id;
const modId = quizState.topic.mod.id;
if (!state.mastery[curId]) state.mastery[curId] = { correct: 0, total: 0 };
state.mastery[curId].total += 1;
state.moduleTotal[modId] = (state.moduleTotal[modId] || 0) + 1;
$$('#quiz-opts .quiz-option').forEach((b, i) => {
b.disabled = true;
if (i === correct) b.classList.add('correct');
else if (i === chosen) b.classList.add('wrong');
});
const ex = $('#quiz-explain');
ex.classList.remove('hidden');
if (chosen === correct) {
quizState.correct += 1;
state.correctAnswers += 1;
state.quizStreak += 1;
if (state.quizStreak > state.maxQuizStreak) state.maxQuizStreak = state.quizStreak;
state.mastery[curId].correct += 1;
state.moduleCorrect[modId] = (state.moduleCorrect[modId] || 0) + 1;
addXP(10, 'Richtig!');
ex.innerHTML = `<strong style="color:var(--success)">✓ Richtig!</strong><br>${escapeHTML(q.explain || '')}
<div class="deepdive-bar" data-inquiz="1">
<button class="deepdive-btn" data-kind="more">💡 Mehr dazu</button>
<button class="deepdive-btn" data-kind="sources">📚 Quellen</button>
<button class="deepdive-btn" data-kind="why">🤔 Warum?</button>
<button class="deepdive-btn" data-kind="ask"> Frag dazu</button>
</div>
<div id="quiz-deepdive" class="deepdive-panel hidden"></div>`;
$$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct)));
} else {
state.quizStreak = 0;
ex.innerHTML = `<strong style="color:var(--danger)">✗ Falsch.</strong> Richtig wäre <strong>${['A','B','C','D','E','F'][correct]}</strong>. ${escapeHTML(q.explain || '')}
<div class="deepdive-bar" data-inquiz="1">
<button class="deepdive-btn" data-kind="more">💡 Mehr dazu</button>
<button class="deepdive-btn" data-kind="sources">📚 Quellen</button>
<button class="deepdive-btn" data-kind="why">🤔 Warum?</button>
<button class="deepdive-btn" data-kind="ask"> Frag dazu</button>
</div>
<div id="quiz-deepdive" class="deepdive-panel hidden"></div>`;
$$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct)));
}
saveState();
touchActivity();
checkBadges();
$('#quiz-next').classList.remove('hidden');
$('#quiz-next-btn').addEventListener('click', () => {
quizState.idx += 1;
if (quizState.idx >= quizState.set.length) renderQuizDone();
else renderQuizQuestion();
});
}
function renderQuizDone() {
const host = $('#quiz-host');
const pct = Math.round(quizState.correct / quizState.set.length * 100);
state.completedQuizzes += 1;
// Module complete (>=80% correct in this quiz) — bonus XP + track for DSGVO-Master
if (pct >= 80 && quizState.topic) {
const modId = quizState.topic.mod.id;
if (!state.completedCurricula.includes(modId)) {
state.completedCurricula.push(modId);
addXP(20, 'Modul abgeschlossen: ' + quizState.topic.mod.title);
}
// Lernreise: Quiz mit ≥80% beendet → Step kann auto-completed werden.
// Portal-Listener entscheidet, ob er das übernimmt.
sendJourneyBeacon('quiz_passed', { module: modId, score_pct: pct });
}
saveState();
checkBadges();
host.innerHTML = `
<div class="quiz-card quiz-done">
<h3>Quiz beendet!</h3>
<div class="score">${quizState.correct} / ${quizState.set.length}</div>
<p style="color:var(--text-dim)">${pct}% richtig ${pct >= 80 ? 'Ausgezeichnet!' : pct >= 60 ? 'Solide!' : 'Probier es noch mal.'}</p>
<div class="actions">
<button class="btn-sec" id="quiz-again">Gleiches Thema nochmal</button>
<button class="btn-primary" id="quiz-new">Anderes Thema</button>
</div>
</div>
`;
$('#quiz-again').addEventListener('click', () => startQuiz(quizState.topic, quizState.set.length));
$('#quiz-new').addEventListener('click', () => renderQuizIntro());
}
// ==== Flashcards (SM-2 Spaced Repetition) ====
const flashState = { topic: null, deck: null, cur: null, showBack: false };
function renderFlashIntro() {
const host = $('#flash-host');
host.innerHTML = `
<div class="flash-intro">
<div class="topic-select">
<h3>🃏 Flashcards</h3>
<p style="color:var(--text-dim);font-size:.85rem">Luna erstellt Karteikarten zu einem Thema. Bewerte dein Erinnerungsvermögen das System wiederholt schwere Karten öfter (SM-2).</p>
<div class="topic-btn-row" id="flash-topic-pills"></div>
<div style="margin-top:1rem;display:flex;gap:.5rem;align-items:center">
<label for="flash-count" style="color:var(--text-dim);font-size:.85rem">Neue Karten:</label>
<select id="flash-count" class="btn-sec" style="padding:.4rem .7rem">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
</select>
<div class="spacer" style="flex:1"></div>
<button class="btn-sec" id="flash-review">Fällige üben</button>
<button class="btn-primary" id="flash-start">Neu laden</button>
</div>
<div id="flash-stats" style="margin-top:.75rem;font-size:.8rem;color:var(--text-mute)"></div>
</div>
</div>
`;
const pillHost = $('#flash-topic-pills');
CURRICULA.curricula.forEach(c => {
c.modules.forEach(m => {
const btn = document.createElement('button');
btn.className = 'topic-pill';
btn.textContent = m.title;
btn.title = c.title;
btn.addEventListener('click', () => {
$$('#flash-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false'));
btn.setAttribute('aria-selected', 'true');
flashState.topic = { curr: c, mod: m };
updateFlashStats();
});
pillHost.appendChild(btn);
});
});
$('#flash-start').addEventListener('click', () => {
if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; }
loadNewFlashCards(flashState.topic, parseInt($('#flash-count').value, 10));
});
$('#flash-review').addEventListener('click', () => {
if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; }
startReview(flashState.topic);
});
updateFlashStats();
}
function updateFlashStats() {
const el = $('#flash-stats');
if (!el) return;
if (!flashState.topic) { el.textContent = ''; return; }
const tid = flashState.topic.mod.id;
const cards = flashCards[tid] || [];
const due = cards.filter(c => !c.due || c.due <= now()).length;
el.textContent = cards.length === 0
? 'Noch keine Karten in diesem Thema.'
: `${cards.length} Karten gespeichert · ${due} fällig heute`;
}
async function loadNewFlashCards(topic, count) {
const host = $('#flash-host');
host.innerHTML = `<div class="flashcard"><div class="dots" style="justify-content:center"><span></span><span></span><span></span></div><p style="text-align:center;color:var(--text-dim);margin-top:1rem">Luna erstellt Karten zu „${topic.mod.title}" …</p></div>`;
try {
const topicText = `${topic.curr.title}${topic.mod.title}`;
const data = await requestStructured(`FLASHCARD_REQUEST topic="${topicText}" count=${count}`);
if (!data.cards || !data.cards.length) throw new Error('Keine Karten erhalten');
const tid = topic.mod.id;
if (!flashCards[tid]) flashCards[tid] = [];
for (const c of data.cards) {
flashCards[tid].push({
front: c.front, back: c.back, hint: c.hint || '',
ef: 2.5, interval: 0, reps: 0, due: now()
});
}
saveFlashCards();
startReview(topic);
} catch (e) {
host.innerHTML = `<div class="flashcard"><p style="color:var(--danger)">⚠ Fehler: ${e.message}</p><div style="margin-top:1rem"><button class="btn-sec" onclick="document.querySelector('[data-mode=flash]').click()">Zurück</button></div></div>`;
toast('Karten-Generierung fehlgeschlagen', 'error');
}
}
function startReview(topic) {
flashState.topic = topic;
const tid = topic.mod.id;
flashState.deck = (flashCards[tid] || []).filter(c => !c.due || c.due <= now());
if (flashState.deck.length === 0) {
const host = $('#flash-host');
host.innerHTML = `<div class="flashcard"><h3>Keine fälligen Karten in „${escapeHTML(topic.mod.title)}"</h3><p style="color:var(--text-dim);margin-top:.5rem">Lege neue Karten an oder komm später wieder.</p><div style="margin-top:1rem"><button class="btn-primary" id="back-to-flash">Zurück</button></div></div>`;
$('#back-to-flash').addEventListener('click', renderFlashIntro);
return;
}
flashState.cur = 0;
flashState.showBack = false;
renderFlashCard();
}
function renderFlashCard() {
const host = $('#flash-host');
const card = flashState.deck[flashState.cur];
if (!card) {
host.innerHTML = `<div class="flashcard"><h3>Review beendet 🎉</h3><p style="color:var(--text-dim);margin-top:.5rem">Alle fälligen Karten in „${escapeHTML(flashState.topic.mod.title)}" durch.</p><div style="margin-top:1rem"><button class="btn-primary" id="back-to-flash">Weiter</button></div></div>`;
$('#back-to-flash').addEventListener('click', renderFlashIntro);
return;
}
host.innerHTML = `
<div class="flash-meta">
<span>Karte ${flashState.cur + 1} / ${flashState.deck.length}</span>
<span>${escapeHTML(flashState.topic.mod.title)}</span>
</div>
<div class="flashcard" id="flash-card" tabindex="0" role="button" aria-label="Karte umdrehen">
${flashState.showBack
? `<div class="flashcard-back"><strong>${escapeHTML(card.front)}</strong>${escapeHTML(card.back)}</div>`
: `<div class="flashcard-front">${escapeHTML(card.front)}</div>${card.hint ? `<div class="flashcard-hint">Hinweis: ${escapeHTML(card.hint)}</div>` : ''}<div style="margin-top:1rem;font-size:.75rem;color:var(--text-mute)">Klicken oder Leertaste drücken zum Umdrehen</div>`}
</div>
${flashState.showBack ? `
<div class="flash-controls">
<button class="flash-btn" data-rating="0">Wieder<span class="label">&lt; 1 Min</span></button>
<button class="flash-btn" data-rating="1">Schwer<span class="label">~1 Tag</span></button>
<button class="flash-btn" data-rating="2">Gut<span class="label">~3 Tage</span></button>
<button class="flash-btn" data-rating="3">Leicht<span class="label">~7 Tage</span></button>
</div>
<div class="deepdive-bar">
<button class="deepdive-btn" data-kind="more" title="Vertiefende Erklärung">💡 Mehr dazu</button>
<button class="deepdive-btn" data-kind="sources" title="Konkrete Quellen">📚 Quellen</button>
<button class="deepdive-btn" data-kind="example" title="Praxis-Beispiel">🎯 Beispiel</button>
<button class="deepdive-btn" data-kind="ask" title="Eigene Frage stellen"> Frag dazu</button>
</div>
<div id="flash-deepdive" class="deepdive-panel hidden"></div>
` : ''}
`;
$('#flash-card').addEventListener('click', flipCard);
$('#flash-card').addEventListener('keydown', (ev) => {
if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); flipCard(); }
});
$('#flash-card').focus();
$$('.flash-btn').forEach(btn => btn.addEventListener('click', () => rateCard(parseInt(btn.dataset.rating, 10))));
$$('.deepdive-btn').forEach(btn => btn.addEventListener('click', () => cardDeepdive(btn.dataset.kind, card)));
}
async function cardDeepdive(kind, card) {
const prompts = {
more: `Zu dieser Lernkarte — gib mir 2-3 vertiefende Informationen, die über die Karte hinausgehen. Keine Wiederholung.\n\nFrage: ${card.front}\nAntwort: ${card.back}${card.hint ? '\nHinweis: ' + card.hint : ''}\n\nMarkdown erlaubt. Kurz halten (≤180 Wörter).`,
sources: `Für diese Lernkarte — wo kann der Lerner das vertiefen? Nenne 3-5 konkrete öffentliche Quellen (Gesetzestext + Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite, IHK-Leitfaden). Je Quelle EINE Zeile. Keine Allgemeinplätze wie „Fachliteratur".\n\nFrage: ${card.front}\nAntwort: ${card.back}\n\nFormat: Markdown-Liste.`,
example: `Praxis-Beispiel zu dieser Lernkarte — ein konkretes Szenario aus dem Berufsalltag in 3-5 Sätzen, kein Theorie-Kram.\n\nFrage: ${card.front}\nAntwort: ${card.back}`,
ask: null,
};
const host = $('#flash-deepdive');
host.classList.remove('hidden');
if (kind === 'ask') {
const q = window.prompt('Deine Frage zu dieser Karte:', '');
if (!q || !q.trim()) { host.classList.add('hidden'); return; }
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>denkt …</span></div>`;
try {
const data = await chatAPI(`Zu dieser Lernkarte:\nFrage: ${card.front}\nAntwort: ${card.back}\n\nMeine Zusatzfrage: ${q.trim()}\n\nBeantworte präzise, Markdown erlaubt, ≤200 Wörter.`, []);
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
} else {
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>${kind === 'sources' ? 'sucht Quellen' : kind === 'example' ? 'baut Beispiel' : 'vertieft'} …</span></div>`;
try {
const data = await chatAPI(prompts[kind], []);
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
}
const cb = $('#flash-deepdive .close-dd');
if (cb) cb.addEventListener('click', () => host.classList.add('hidden'));
}
async function quizDeepdive(kind, q, chosenIdx, correctIdx) {
const host = $('#quiz-deepdive');
host.classList.remove('hidden');
const chosenTxt = q.options[chosenIdx], correctTxt = q.options[correctIdx];
const prompts = {
more: `Gib mir zu dieser Quiz-Frage 2-3 weiterführende Infos, die über die knappe Erklärung hinausgehen. Kein Wiederholen.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\nErklärung: ${q.explain || '(—)'}\n\nMarkdown erlaubt, ≤180 Wörter.`,
sources: `Für diese Quiz-Frage — wo kann der Lerner das vertiefen? 3-5 konkrete öffentliche Quellen (Gesetzestext+Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite). Je Quelle EINE Zeile, keine Allgemeinplätze.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\n\nMarkdown-Liste.`,
why: chosenIdx === correctIdx
? `Ich hab richtig geantwortet — erkläre mir NOCH tiefer warum das die richtige Wahl ist. Was hätte falsch gelegen bei den anderen Optionen? Je Option 1 Satz.\n\nFrage: ${q.q}\nOptionen: ${q.options.map((o,i)=>(i+1)+'. '+o).join(' | ')}\nRichtig: ${correctTxt}`
: `Ich hab „${chosenTxt}" gewählt, richtig wäre „${correctTxt}". Erkläre mir präzise wo mein Denkfehler lag und wie ich das beim nächsten Mal anders angehe. Sokratisch wenn möglich.\n\nFrage: ${q.q}\nMeine Antwort: ${chosenTxt}\nRichtig: ${correctTxt}`,
ask: null,
};
if (kind === 'ask') {
const userQ = window.prompt('Deine Frage zu dieser Quiz-Frage:', '');
if (!userQ || !userQ.trim()) { host.classList.add('hidden'); return; }
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>denkt …</span></div>`;
try {
const data = await chatAPI(`Zur Quiz-Frage: "${q.q}"\nRichtige Antwort: ${correctTxt}\nMeine Antwort: ${chosenTxt}\n\nZusatzfrage: ${userQ.trim()}\n\nBeantworte präzise, Markdown, ≤200 Wörter.`, []);
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
} else {
host.innerHTML = `<div class="dd-loading"><span class="dots"><span></span><span></span><span></span></span> <span>${kind === 'sources' ? 'sucht Quellen' : kind === 'why' ? 'analysiert' : 'vertieft'} …</span></div>`;
try {
const data = await chatAPI(prompts[kind], []);
host.innerHTML = '<div class="dd-body">' + renderMD(data.reply || '') + '</div><button class="btn-secondary close-dd">Schließen</button>';
} catch (e) { host.innerHTML = `<div class="msg sys">Fehler: ${e.message || 'unbekannt'}</div><button class="btn-secondary close-dd">Schließen</button>`; }
}
const cb = $('#quiz-deepdive .close-dd');
if (cb) cb.addEventListener('click', () => host.classList.add('hidden'));
}
function flipCard() {
if (flashState.showBack) return;
const el = $('#flash-card');
if (el) { el.classList.add('flipping'); setTimeout(() => { flashState.showBack = true; renderFlashCard(); }, 120); }
}
function rateCard(rating) {
const card = flashState.deck[flashState.cur];
// SM-2 algorithm
const q = rating; // 0..3 (we map: 0→ again, 1→hard, 2→good, 3→easy)
const qMap = [0, 3, 4, 5]; // SM-2 uses 0..5, we map our 0..3 to subset
const sm2q = qMap[q];
if (sm2q < 3) {
card.reps = 0;
card.interval = 0;
card.due = now() + 60 * 1000; // 1 min
} else {
card.reps += 1;
if (card.reps === 1) card.interval = 1;
else if (card.reps === 2) card.interval = 3;
else card.interval = Math.round(card.interval * card.ef);
card.due = now() + card.interval * 86400 * 1000;
}
card.ef = Math.max(1.3, card.ef + (0.1 - (5 - sm2q) * (0.08 + (5 - sm2q) * 0.02)));
saveFlashCards();
// XP
state.flashCardsRated += 1;
state.totalAnswers += 1;
if (q === 2) { addXP(5, 'Flashcard Gut'); state.correctAnswers += 1; }
else if (q === 3) { addXP(2, 'Flashcard Leicht'); state.correctAnswers += 1; }
// Track per-run ratings for modulePassedFlash (all cards in deck rated >= Gut)
flashState.runRatings = flashState.runRatings || [];
flashState.runRatings.push(q);
saveState();
touchActivity();
checkBadges();
flashState.showBack = false;
flashState.cur += 1;
// If end of deck and topic set, check if all cards rated Gut/Leicht
if (flashState.cur >= flashState.deck.length && flashState.topic) {
const allGood = flashState.runRatings.length >= 3 && flashState.runRatings.every(r => r >= 2);
if (allGood) {
const modId = flashState.topic.mod.id;
state.modulePassedFlash[modId] = true;
saveState();
checkBadges();
}
flashState.runRatings = [];
}
renderFlashCard();
}
// ==== Progress ====
function renderProgress() {
const host = $('#progress-host');
const li = levelInfo();
const masteryRows = Object.entries(state.mastery)
.map(([cid, m]) => {
const c = CURRICULA.curricula.find(x => x.id === cid);
if (!c || m.total === 0) return null;
const pct = Math.round(m.correct / m.total * 100);
return { title: c.title, color: c.color, pct, correct: m.correct, total: m.total };
})
.filter(Boolean)
.sort((a, b) => b.pct - a.pct);
const badges = (CURRICULA.badges || []);
host.innerHTML = `
<div class="progress-grid">
<div class="stat-row">
<div class="stat-card accent">
<div class="val">${state.xp}</div>
<div class="lbl">XP gesamt</div>
</div>
<div class="stat-card level">
<div class="val">Lvl ${li.levelNum} · ${li.title}</div>
<div class="lbl">${li.next ? li.next.min - state.xp + ' XP bis ' + li.next.title : 'Top-Level erreicht'}</div>
</div>
<div class="stat-card streak">
<div class="val">🔥 ${state.currentStreak}</div>
<div class="lbl">Tage-Streak (max. ${state.maxStreak})</div>
</div>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="val">${state.totalAnswers}</div>
<div class="lbl">Antworten gesamt</div>
</div>
<div class="stat-card">
<div class="val">${state.totalAnswers === 0 ? '0%' : Math.round(state.correctAnswers / state.totalAnswers * 100) + '%'}</div>
<div class="lbl">Trefferquote</div>
</div>
<div class="stat-card">
<div class="val">${state.completedQuizzes}</div>
<div class="lbl">Quizze</div>
</div>
</div>
${li.next ? `
<div class="section-card">
<h3>Fortschritt zu ${li.next.title}"</h3>
<div class="bar-bg"><div class="bar-fg" style="width:${li.pct}%"></div></div>
<div style="margin-top:.4rem;font-size:.78rem;color:var(--text-dim)">${Math.round(li.pct)}% · ${state.xp} / ${li.next.min} XP</div>
</div>` : ''}
<div class="section-card">
<h3>Mastery pro Curriculum</h3>
${masteryRows.length === 0
? '<p style="color:var(--text-dim);font-size:.88rem">Noch keine Daten. Mach ein Quiz, um Mastery aufzubauen.</p>'
: '<div class="mastery-row">' + masteryRows.map(r => `
<div class="mastery-bar">
<div class="mastery-head">
<span>${escapeHTML(r.title)}</span>
<span class="pct">${r.pct}% <span style="color:var(--text-mute);font-weight:400;margin-left:.4rem">(${r.correct}/${r.total})</span></span>
</div>
<div class="bar-bg"><div class="bar-fg" style="width:${r.pct}%"></div></div>
</div>
`).join('') + '</div>'
}
</div>
<div class="section-card">
<h3>Abzeichen (${Object.keys(state.badges).length}/${badges.length})</h3>
<div class="badge-grid">
${badges.map(b => {
const earned = !!state.badges[b.id];
const icons = { award:'🏆', flame:'🔥', star:'⭐', calendar:'🗓', crown:'👑', moon:'🌙', sun:'☀️', shield:'🛡️', detective:'🕵️', clock:'⏱️', handshake:'🤝', medal:'🎖️', diamond:'💎' };
return `<div class="badge ${earned ? 'earned' : 'locked'}">
<span class="icon">${icons[b.icon] || '🎖'}</span>
<div class="title">${escapeHTML(b.title)}</div>
<div class="desc">${escapeHTML(b.description)}</div>
</div>`;
}).join('')}
</div>
</div>
<div class="section-card">
<h3>Daten zurücksetzen</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin-bottom:.75rem">Lokal gespeichert (kein Server-Tracking).</p>
<button class="btn-sec" id="reset-data">Alle Fortschritte löschen</button>
</div>
</div>
`;
$('#reset-data').addEventListener('click', () => {
if (confirm('Alle lokalen Daten (XP, Streaks, Flashcards, Chat) wirklich löschen?')) {
localStorage.removeItem(LS_KEY);
localStorage.removeItem(LS_CHAT);
localStorage.removeItem(LS_FLASH);
state = loadState();
chatHistory = [];
flashCards = {};
restoreChat();
renderProgress();
toast('Daten zurückgesetzt.', 'success');
}
});
}
// ==== Curriculum tree ====
function renderCurriculum() {
const host = $('#curr-host');
host.innerHTML = '<div class="curr-tree" id="curr-tree-root"></div>';
const root = $('#curr-tree-root');
CURRICULA.curricula.forEach(c => {
const rootEl = document.createElement('details');
rootEl.className = 'curr-root';
rootEl.innerHTML = `
<summary class="curr-root-head" style="list-style:none">
<span class="ic" style="background:${c.color}">${c.title.charAt(0)}</span>
<span class="txt">
<strong>${escapeHTML(c.title)}</strong>
<small>${escapeHTML(c.short)} · ${c.modules.length} Module</small>
</span>
<span class="chev"></span>
</summary>
<div class="curr-mods">
${c.modules.map(m => `
<div class="curr-mod" data-curr="${c.id}" data-mod="${m.id}">
<span class="m-title">${escapeHTML(m.title)}</span>
<span class="m-arrow"></span>
</div>
`).join('')}
</div>
`;
root.appendChild(rootEl);
});
$$('.curr-mod').forEach(el => el.addEventListener('click', () => {
const c = CURRICULA.curricula.find(x => x.id === el.dataset.curr);
const m = c.modules.find(x => x.id === el.dataset.mod);
renderModuleDetail(c, m);
}));
}
function renderModuleDetail(c, m) {
const host = $('#curr-host');
host.innerHTML = `
<div class="mod-detail">
<div class="breadcrumb">
<button id="breadcrumb-back"> Lehrplan</button> / ${escapeHTML(c.title)}
</div>
<h3>${escapeHTML(m.title)}</h3>
<h4>Lernziele</h4>
<ul>${m.objectives.map(o => `<li>${escapeHTML(o)}</li>`).join('')}</ul>
<h4>Kernthemen</h4>
<ul>${(m.topics || []).map(t => `<li>${escapeHTML(t)}</li>`).join('')}</ul>
${m.hours ? `<p style="margin-top:.5rem;color:var(--text-dim);font-size:.85rem">Umfang: ~${m.hours} h</p>` : ''}
<div class="mod-actions">
<button class="btn-primary" data-action="quiz">Quiz zu diesem Thema</button>
<button class="btn-sec" data-action="flash">Flashcards</button>
<button class="btn-sec" data-action="ask">Luna fragen</button>
</div>
</div>
`;
$('#breadcrumb-back').addEventListener('click', renderCurriculum);
$$('.mod-actions button').forEach(b => b.addEventListener('click', () => {
const act = b.dataset.action;
if (act === 'quiz') {
quizState.topic = { curr: c, mod: m };
switchMode('quiz');
setTimeout(() => startQuiz({ curr: c, mod: m }, 10), 50);
} else if (act === 'flash') {
flashState.topic = { curr: c, mod: m };
switchMode('flash');
setTimeout(() => {
const tid = m.id;
if (flashCards[tid] && flashCards[tid].length) startReview({ curr: c, mod: m });
else loadNewFlashCards({ curr: c, mod: m }, 10);
}, 50);
} else if (act === 'ask') {
switchMode('chat');
$('#composer').value = `Erklär mir kurz und klar: ${m.title} (aus ${c.title}). Fokus auf ${m.topics.slice(0,3).join(', ')}.`;
$('#composer').focus();
}
}));
}
// ==== Mode switching ====
function switchMode(mode) {
$$('.tab').forEach(t => {
const isActive = t.dataset.mode === mode;
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
$$('.view').forEach(v => {
v.dataset.active = (v.id === 'view-' + mode) ? 'true' : 'false';
});
// The composer is shown only in chat mode
$('#composer-form').classList.toggle('hidden', mode !== 'chat');
if (mode === 'quiz') renderQuizIntro();
if (mode === 'flash') renderFlashIntro();
if (mode === 'progress') renderProgress();
if (mode === 'curriculum') renderCurriculum();
if (mode === 'chat') setTimeout(() => $('#composer').focus(), 50);
}
// ==== Escape HTML ====
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, ch => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[ch]));
}
// ==== Boot ====
// Lernreise-State (URL-Params), einmal beim Boot ausgelesen.
// Wird von renderJourneyBanner() für die UI und von sendJourneyBeacon()
// für die postMessage-Kommunikation zurück zum Portal-Tab benutzt.
const JOURNEY = (() => {
try {
const p = new URLSearchParams(window.location.search);
const slug = p.get('journey');
if (!slug) return null;
const step = parseInt(p.get('step') || '0', 10);
const total = parseInt(p.get('total') || '0', 10);
let returnOrigin = '';
try {
if (p.get('return')) returnOrigin = new URL(p.get('return')).origin;
} catch {}
return { slug, step, total, returnOrigin };
} catch { return null; }
})();
// Beacon zurück an den Portal-Tab. Funktioniert nur wenn das Widget über
// `target="_blank" rel="opener"` aus der Lernreise-Seite geöffnet wurde
// (window.opener erreichbar). Bei direktem Aufruf der Bot-URL no-op.
function sendJourneyBeacon(event, extra = {}) {
if (!JOURNEY || !window.opener) return;
if (!JOURNEY.returnOrigin) return;
try {
window.opener.postMessage({
type: 'qognio:journey-step-event',
journey: JOURNEY.slug,
step: JOURNEY.step,
event, // 'quiz_passed' | 'curriculum_completed' | 'manual'
...extra,
}, JOURNEY.returnOrigin);
} catch (e) {
console.warn('journey beacon failed', e);
}
}
// Lernreise-Banner: gerendert wenn der Bot via Portal-Lernreise geöffnet wurde
// (`?journey=<slug>&step=<n>&total=<m>&return=<url>`). Nicht-invasiv, weglinkbar.
function renderJourneyBanner() {
try {
const params = new URLSearchParams(window.location.search);
const journey = params.get('journey');
const step = parseInt(params.get('step') || '0', 10);
const total = parseInt(params.get('total') || '0', 10);
const ret = params.get('return') || '';
if (!journey || step <= 0) return;
// Dismissed for this journey/step combo? (LS sticky)
const dismissKey = 'qognio.journey-banner.dismiss.v1';
const dismissed = JSON.parse(localStorage.getItem(dismissKey) || '{}');
const dKey = journey + ':' + step;
if (dismissed[dKey]) return;
const bar = document.createElement('div');
bar.id = 'qognio-journey-banner';
bar.setAttribute('role', 'note');
bar.style.cssText = [
'position:sticky', 'top:0', 'z-index:100',
'display:flex', 'align-items:center', 'gap:.75rem',
'padding:.55rem .9rem',
'background:linear-gradient(90deg, rgba(124,58,237,.18), rgba(34,211,238,.10))',
'border-bottom:1px solid rgba(124,58,237,.35)',
'color:var(--text, #e5e7eb)', 'font-size:.875rem',
'box-shadow:0 1px 0 rgba(0,0,0,.05)',
].join(';');
const human = journey.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
bar.innerHTML = [
'<span aria-hidden="true" style="font-size:1.05em;line-height:1">🧭</span>',
'<span style="font-weight:600">Lernreise: ' + human + '</span>',
'<span style="opacity:.7">Schritt ' + step + (total > 0 ? ' von ' + total : '') + '</span>',
'<span style="flex:1"></span>',
ret ? '<a href="' + ret.replace(/"/g, '&quot;') + '" style="text-decoration:none;padding:.3rem .65rem;border-radius:.4rem;background:rgba(124,58,237,.25);color:inherit;font-weight:500">↩ Zurück zur Reise</a>' : '',
'<button type="button" id="qognio-journey-dismiss" aria-label="Banner ausblenden" title="Banner ausblenden" style="background:transparent;border:0;color:inherit;font-size:1.1em;line-height:1;cursor:pointer;opacity:.7;padding:.25rem .5rem">×</button>',
].join('');
document.body.prepend(bar);
const btn = bar.querySelector('#qognio-journey-dismiss');
if (btn) btn.addEventListener('click', () => {
dismissed[dKey] = Date.now();
localStorage.setItem(dismissKey, JSON.stringify(dismissed));
bar.remove();
});
} catch (e) {
console.warn('journey banner failed', e);
}
}
async function boot() {
renderJourneyBanner();
try {
const r = await fetch('curricula.json?v=2026-04-21');
if (!r.ok) throw new Error('curricula.json HTTP ' + r.status);
CURRICULA = await r.json();
} catch (e) {
toast('Curriculum konnte nicht geladen werden.', 'error', 6000);
console.error(e);
return;
}
restoreChat();
touchActivity();
// Tabs
$$('.tab').forEach(tab => tab.addEventListener('click', () => switchMode(tab.dataset.mode)));
// Welcome-card shortcuts
$$('[data-goto]').forEach(b => b.addEventListener('click', () => {
state.seenWelcome = true; saveState();
switchMode(b.dataset.goto);
}));
// Welcome-card prompt-fillers (special *_REQUEST modes)
$$('[data-prompt]').forEach(b => b.addEventListener('click', () => {
state.seenWelcome = true; saveState();
switchMode('chat');
const composer = $('#composer');
if (composer) {
composer.value = b.dataset.prompt;
autogrow(composer);
setTimeout(() => composer.focus(), 50);
}
}));
// Composer
const form = $('#composer-form');
const ta = $('#composer');
form.addEventListener('submit', (ev) => {
ev.preventDefault();
const text = ta.value.trim();
if (!text && pendingAttachments.length === 0) return;
ta.value = '';
autogrow(ta);
sendChat(text);
});
ta.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
form.requestSubmit();
}
});
// Attachments
const fileInput = $('#composer-file');
const attachBtn = $('#composer-attach');
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (ev) => {
await addFiles(ev.target.files);
ev.target.value = '';
});
}
// Drag & Drop on composer
['dragenter', 'dragover'].forEach(evt => form.addEventListener(evt, (ev) => {
if (ev.dataTransfer && Array.from(ev.dataTransfer.types || []).includes('Files')) {
ev.preventDefault();
form.classList.add('dragover');
}
}));
['dragleave', 'drop'].forEach(evt => form.addEventListener(evt, (ev) => {
if (evt === 'dragleave' && ev.target !== form) return;
form.classList.remove('dragover');
}));
form.addEventListener('drop', async (ev) => {
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) {
ev.preventDefault();
await addFiles(ev.dataTransfer.files);
}
});
// Paste image / file
ta.addEventListener('paste', async (ev) => {
const items = ev.clipboardData && ev.clipboardData.files;
if (items && items.length) {
ev.preventDefault();
await addFiles(items);
}
});
ta.addEventListener('input', () => autogrow(ta));
function autogrow(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
// Keyboard shortcuts Ctrl+1..5
document.addEventListener('keydown', (ev) => {
if (!(ev.ctrlKey || ev.metaKey)) return;
const map = { '1': 'chat', '2': 'quiz', '3': 'flash', '4': 'progress', '5': 'curriculum' };
if (map[ev.key]) { ev.preventDefault(); switchMode(map[ev.key]); }
});
// Default focus
ta.focus();
// Update status once per min (visual cue)
setInterval(() => { /* placeholder for future heartbeat */ }, 60000);
console.log('Luna v2026-04-21 ready. XP:', state.xp, 'Streak:', state.currentStreak);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();