awareness-coach/www/app.js
Qognio Bot Extract 25bb61feaf init: extract awareness-coach from qognio-bot-widget-template@d2c816f
Source files (src/) and rendered bundle (www/) extracted on 2026-04-29T01:35:15+02:00.
Adds nginx:alpine Dockerfile + docker-compose.yml (Caddy-labels) so the bot
runs stand-alone or as a per-customer template clone.

Parent monorepo commit: d2c816f3edbc9760802a11b29ff4151c7aad4b46
Bot version: 2026-04-21
2026-04-29 01:35:15 +02:00

1821 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Ava — Awareness & Club-Manager-Coach Widget
* Vanilla JS, no build, no framework, keine externen Fonts/Analytics.
* Chat | Quiz | Flashcards | Fortschritt | Policy-Bibliothek — localStorage only.
*/
(() => {
'use strict';
// ==== Config ====
const API = 'https://llm.qognio.com/api/bots/awareness-coach/chat';
const RAW_KEY = window.__AVA_KEY__ || '';
const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : '';
const LS_KEY = 'ava.state.v1';
const LS_CHAT = 'ava.chat.v1';
const LS_FLASH = 'ava.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: 'Einsteiger:in' }, { min: 50, title: 'Schicht-Leiter:in' },
{ min: 200, title: 'Bar-/Club-Manager:in' }, { min: 500, title: 'Betriebsleiter:in' },
{ min: 1250, title: 'Awareness-Beauftragte:r' }, { min: 2500, title: 'Geschäftsführung Gastro/Events' },
{ min: 5000, title: 'Branchen-Expert: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() {
const done = state.completedCurricula || [];
// Policy-Architekt:in — erste Quiz-Frage zu Policy-Struktur richtig
if ((state.completedQuizzes || 0) >= 1) unlockBadge('erste_policy');
// AGG-Schutzmauer — Diskriminierungsschutz-Modul komplett
if (done.includes('agg_diskriminierung')) unlockBadge('agg_shield');
// Incident-Profi — Vorfall-Dokumentation komplett
if (done.includes('dokumentation') || done.includes('save_protokoll')) unlockBadge('incident_keeper');
// Krisenkommunikations-Profi — beide Krise-Module
if (done.includes('statement') && done.includes('medien')) unlockBadge('crisis_comm_pro');
// Harm-Reduction-Advocate — beide Harm-Module
if (done.includes('notfaelle') && done.includes('drogenhilfe')) unlockBadge('harm_reducer');
// Safer-Space-Architekt:in — Fundament + Policy + Team komplett
const foundations = ['awareness_grundlagen', 'parteilichkeit'];
const policy = ['policy_struktur', 'eskalationskette'];
const team = ['team_aufbau', 'konflikte_fuehrung'];
if (foundations.every(m => done.includes(m)) && policy.every(m => done.includes(m)) && team.every(m => done.includes(m))) {
unlockBadge('safer_space_builder');
}
// Awareness-Master — alle 16 Module
if (done.length >= 16) unlockBadge('policy_master');
// Monats-Disziplin — 30-Tage-Streak
if (state.maxStreak >= 30) unlockBadge('streak_30');
// Night Owl (late-night Lerner) — Nightlife-Flavour
const h = new Date().getHours();
if (h >= 22) unlockBadge('night_owl');
}
// ==== 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 — Ava generiert realistische Management-Szenario-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">Ava erstellt ${count} Szenario-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">Ava 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">Ava 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"> Policy-Bibliothek</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">Ava 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('Ava v2026-04-21 ready. XP:', state.xp, 'Streak:', state.currentStreak);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();