ki-kennzahlen-coach/www/cockpit/cockpit.js
Qognio Bot Extract 1e3703ac4b init: extract ki-kennzahlen-coach from qognio-bot-widget-template@d2c816f
Source files (src/) and rendered bundle (www/) extracted on 2026-04-29T01:35:47+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-25
2026-04-29 01:35:47 +02:00

1249 lines
56 KiB
JavaScript
Raw Permalink 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.

/* Kai Cockpit — vanilla JS, no framework, no build, no CDN.
* Shares XP/Level/Streak state with the Kai widget via localStorage key `kai.state.v1`.
* Cockpit-specific keys: kai.cockpit.reifegrad, kai.cockpit.aiact, kai.cockpit.dashboard, kai.cockpit.chat
*/
(() => {
'use strict';
// ========= CONFIG =========
const API = 'https://llm.qognio.com/api/bots/ki-kennzahlen-coach/chat';
const RAW_KEY = window.__KAI_KEY__ || '';
const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : '';
// Shared with widget
const LS_STATE = 'kai.state.v1';
// Cockpit-specific
const LS_REIFE = 'kai.cockpit.reifegrad';
const LS_AIACT = 'kai.cockpit.aiact';
const LS_DASH = 'kai.cockpit.dashboard';
const LS_CHAT = 'kai.cockpit.chat';
// ========= UTIL =========
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const today = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ========= SHARED STATE (with widget) =========
function loadState() {
try {
const s = JSON.parse(localStorage.getItem(LS_STATE) || '{}');
return Object.assign({
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: []
}, s);
} catch (e) {
return { xp: 0, badges: {} };
}
}
function saveState() { localStorage.setItem(LS_STATE, JSON.stringify(state)); }
let state = loadState();
function addXP(n, reason = '') {
state.xp = (state.xp || 0) + n;
saveState();
toast(`+${n} XP${reason ? ' · ' + reason : ''}`, 'info', 2200);
renderXP();
}
const LEVELS = [
{ min: 0, title: 'AI-Trainee' },
{ min: 50, title: 'AI-Analyst:in' },
{ min: 200, title: 'ML-Engineer' },
{ min: 500, title: 'AI-Lead' },
{ min: 1250, title: 'AI-Officer' },
{ min: 2500, title: 'AI-Governance-Lead' },
{ min: 5000, title: 'Chief AI Officer' }
];
function levelInfo() {
let cur = LEVELS[0];
for (const l of LEVELS) if ((state.xp || 0) >= 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 };
}
function renderXP() {
const li = levelInfo();
const lvl = $('#xp-level'); const xp = $('#xp-score'); const bar = $('#xp-bar-fill');
if (lvl) lvl.textContent = `Lvl ${li.levelNum} · ${li.title}`;
if (xp) xp.textContent = `${state.xp || 0} XP`;
if (bar) bar.style.width = li.pct + '%';
}
// ========= BADGES =========
const COCKPIT_BADGES = {
cockpit_reifegrad_done: 'Reifegrad-Profi',
cockpit_ai_act_done: 'AI-Act-Navigator (3 Systeme)',
cockpit_dashboard_explored: 'Dashboard-Flaneur:in',
cockpit_power_user: 'Cockpit-Power-User'
};
function unlockBadge(id) {
if (state.badges[id]) return false;
state.badges[id] = today();
saveState();
const title = COCKPIT_BADGES[id] || id;
toast('🏆 Neues Abzeichen: ' + title, 'success', 4200);
// Power-user check
if (id !== 'cockpit_power_user') checkPowerUser();
return true;
}
function checkPowerUser() {
if (state.badges.cockpit_reifegrad_done
&& state.badges.cockpit_ai_act_done
&& state.badges.cockpit_dashboard_explored) {
unlockBadge('cockpit_power_user');
}
}
// ========= TOAST =========
function toast(msg, kind = '', ms = 3000) {
const stack = $('#toast-stack'); if (!stack) return;
const el = document.createElement('div');
el.className = 'toast ' + kind;
el.textContent = msg;
stack.appendChild(el);
setTimeout(() => { el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, ms);
}
// ========= MARKDOWN (light + GFM tables) =========
function renderMD(src) {
let t = esc(src);
// code fences
t = t.replace(/```([\s\S]*?)```/g, (_, c) => `<pre>${c}</pre>`);
// inline code
t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
// GFM tables — consume before line-based processing
t = t.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => {
const ls = content.trim().split('\n');
if (ls.length < 2) return block;
const sep = ls[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(ls[0]);
const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? 'center' : /-+:$/.test(s) ? 'right' : 'left');
const rows = ls.slice(2).map(parseRow);
let h = '\n<table class="md-table"><thead><tr>';
header.forEach((hd, i) => { h += `<th style="text-align:${aligns[i]||'left'}">${hd}</th>`; });
h += '</tr></thead><tbody>';
rows.forEach(r => {
h += '<tr>';
for (let i = 0; i < Math.max(r.length, header.length); i++) {
h += `<td style="text-align:${aligns[i]||'left'}">${r[i] || ''}</td>`;
}
h += '</tr>';
});
h += '</tbody></table>\n';
return h;
});
// bold + italic
t = t.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
// headings (very lightweight)
t = t.replace(/^###\s+(.*)$/gm, '<strong>$1</strong>');
t = t.replace(/^##\s+(.*)$/gm, '<strong>$1</strong>');
t = t.replace(/^#\s+(.*)$/gm, '<strong>$1</strong>');
// bullets + paragraphs (skip inside tables/pre)
const lines = t.split('\n'); const out = []; let inUL = false; let inSkip = false;
for (const l of lines) {
// Skip line-processing while inside <table> or <pre> blocks
if (/<table\b/.test(l)) inSkip = true;
if (/<pre\b/.test(l)) inSkip = true;
if (inSkip) {
if (inUL) { out.push('</ul>'); inUL = false; }
out.push(l);
if (/<\/table>/.test(l) || /<\/pre>/.test(l)) inSkip = false;
continue;
}
if (/^[-*]\s+/.test(l)) {
if (!inUL) { out.push('<ul>'); inUL = true; }
out.push('<li>' + l.replace(/^[-*]\s+/, '') + '</li>');
} else {
if (inUL) { out.push('</ul>'); inUL = false; }
if (l.trim()) out.push('<p>' + l + '</p>');
}
}
if (inUL) out.push('</ul>');
return out.join('');
}
// =====================================================================
// MODULE 1 — REIFEGRAD ASSESSMENT
// =====================================================================
const DIMENSIONS = [
{
id: 'strategy',
title: 'Strategie',
short: 'Wo steht KI in eurer Unternehmens-Strategie?',
questions: [
'Hat euer Unternehmen eine dokumentierte KI-Strategie mit Executive-Sponsorship?',
'Sind AI-Use-Cases nach Business-Value priorisiert und in einem Portfolio gelistet?',
'Gibt es ein klares Budget-Committment für AI über 12 Monate hinaus?',
'Ist AI Teil der Digital- oder Geschäftsstrategie (nicht nur IT-Plan)?',
'Wird der ROI von AI-Initiativen auf Portfolio-Ebene an den Vorstand reported?'
]
},
{
id: 'data',
title: 'Daten',
short: 'Wie solide ist euer Daten-Fundament?',
questions: [
'Existiert ein zentraler Data-Catalog mit Lineage für relevante Datensätze?',
'Wird Data-Quality (Completeness, Accuracy, Freshness) systematisch gemessen?',
'Habt ihr dokumentierte Data-Governance-Rollen (Data-Owner, Steward)?',
'Ist Master-Data für Kunden/Produkte/Mitarbeitende harmonisiert?',
'Sind Trainingsdaten versioniert und auf Bias/Verzerrungen gescreent (Art. 10 AI Act)?'
]
},
{
id: 'technology',
title: 'Technologie',
short: 'MLOps, Registry, Monitoring — wie tief ist euer Stack?',
questions: [
'Habt ihr eine MLOps-Plattform (CI/CD für ML, nicht nur Ad-hoc-Notebooks)?',
'Gibt es eine zentrale Model-Registry mit Versionierung und Approval-Flow?',
'Sind Inferenz-Pipelines auf Latenz, Throughput und Error-Rate beobachtet?',
'Läuft automatisches Drift-Monitoring (Daten- oder Modell-Drift) in Produktion?',
'Ist eure Inferenz-Infrastruktur skalierbar (Cloud/On-Prem/Hybrid) dokumentiert?'
]
},
{
id: 'people',
title: 'Menschen',
short: 'Skills, Rollen, AI-Literacy.',
questions: [
'Habt ihr einen AI-/ML-Engineer(in) oder ein Data-Science-Team (intern)?',
'Gibt es AI-Literacy-Training für Non-Tech-Mitarbeitende (Art. 4 AI Act)?',
'Existieren klare Rollen wie CAIO / AI-Risk-Officer / Data-Owner?',
'Wird externe Expertise (Consulting, Fachanwalt IT-Recht) strukturiert eingebunden?',
'Wird AI-Kompetenz in Job-Profile und Hiring-Prozesse integriert?'
]
},
{
id: 'process',
title: 'Prozesse',
short: 'Deployment, Monitoring, Incident-Response.',
questions: [
'Habt ihr standardisierte Deployment-Prozesse (Design → Train → Deploy → Retire)?',
'Existiert ein AI-Incident-Response-Plan inkl. Art. 73 AI-Act-Meldewege?',
'Gibt es ein jährliches AI-Program-Review mit Vorstand oder Geschäftsführung?',
'Wurden FRIA (Art. 27) / DSFA (Art. 35 DSGVO) für relevante Systeme durchgeführt?',
'Ist EU AI Act-Klassifikation für ALLE produktiven Systeme abgeschlossen?'
]
}
];
// Scale: 0 nicht vorhanden / 1 ad-hoc / 2 geplant / 3 etabliert / 4 optimiert
const LIKERT = [
{ v: 0, t: 'nicht vorhanden' },
{ v: 1, t: 'ad-hoc' },
{ v: 2, t: 'geplant' },
{ v: 3, t: 'etabliert' },
{ v: 4, t: 'optimiert' }
];
const BENCHMARK = { strategy: 2.1, data: 1.8, technology: 2.3, people: 1.6, process: 1.9 };
function loadReifegrad() {
try { return JSON.parse(localStorage.getItem(LS_REIFE) || '{}'); } catch (e) { return {}; }
}
function saveReifegrad(data) { localStorage.setItem(LS_REIFE, JSON.stringify(data)); }
const reifeState = {
view: 'overview', // 'overview' | 'assess' | 'result'
currentDim: null,
currentQ: 0,
answers: loadReifegrad() // { dimId: [v,v,v,v,v] }
};
function reifeScore(dimId) {
const arr = reifeState.answers[dimId];
if (!arr || arr.length !== 5 || arr.some(v => v == null)) return null;
return arr.reduce((s, v) => s + v, 0) / arr.length;
}
function reifeDimDone(dimId) {
const s = reifeScore(dimId);
return s != null;
}
function reifeCountDone() { return DIMENSIONS.filter(d => reifeDimDone(d.id)).length; }
function reifeOverallScore() {
const scores = DIMENSIONS.map(d => reifeScore(d.id)).filter(v => v != null);
if (!scores.length) return 0;
return scores.reduce((s, v) => s + v, 0) / scores.length;
}
function renderReifegradModule() {
const host = $('#reifegrad-host');
if (reifeState.view === 'overview') return renderReifeOverview(host);
if (reifeState.view === 'assess') return renderReifeAssessment(host);
if (reifeState.view === 'result') return renderReifeResult(host);
}
function renderReifeOverview(host) {
const done = reifeCountDone();
const overall = reifeOverallScore();
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Modul 01</span>
<h1>Reifegrad-Assessment</h1>
<p>5-Dimensionen-Self-Assessment auf Basis von Gartner + MIT CISR + Microsoft RAI-MM. Pro Dimension 5 Fragen, 04 Likert. Ergibt einen Radar gegen den DACH-Mittelstand-Durchschnitt (BITKOM/Fraunhofer 2024).</p>
</div>
<div class="reifegrad-intro">
<div class="intro-stats">
<span><strong>${done}/5</strong> Dimensionen beantwortet</span>
<span><strong>${overall.toFixed(2)}</strong> / 4.00 Overall-Score</span>
<span><strong>${done === 5 ? 'komplett' : 'in Arbeit'}</strong></span>
</div>
</div>
<div class="dim-grid">
${DIMENSIONS.map(d => {
const s = reifeScore(d.id);
const pct = s != null ? (s / 4) * 100 : 0;
const done = s != null;
return `
<button class="dim-card ${done ? 'done' : ''}" data-dim="${d.id}" type="button">
<h3>${esc(d.title)}</h3>
<p>${esc(d.short)}</p>
<div class="dim-score">
${done ? `<span>${s.toFixed(1)} / 4.0</span>` : '<span>noch offen</span>'}
<div class="mini-bar"><div class="mini-bar-fill" style="width:${pct}%"></div></div>
</div>
</button>
`;
}).join('')}
</div>
<div style="display:flex; gap:12px; flex-wrap:wrap;">
<button class="btn btn-primary" id="reife-start" ${done === 0 ? '' : ''}>${done === 0 ? 'Assessment starten' : (done < 5 ? 'Weitermachen' : 'Radar anzeigen')}</button>
${done === 5 ? `<button class="btn btn-ghost" id="reife-result">Ergebnis & Radar</button>` : ''}
${done > 0 ? `<button class="btn btn-ghost" id="reife-reset">Zurücksetzen</button>` : ''}
</div>
${done === 5 ? renderBadgeRow() : ''}
`;
// Events
$$('.dim-card', host).forEach(el => el.addEventListener('click', () => {
reifeState.currentDim = el.dataset.dim;
reifeState.currentQ = 0;
reifeState.view = 'assess';
renderReifegradModule();
}));
const startBtn = $('#reife-start', host);
if (startBtn) startBtn.addEventListener('click', () => {
if (done === 5) { reifeState.view = 'result'; renderReifegradModule(); return; }
// find first un-done dim
const next = DIMENSIONS.find(d => !reifeDimDone(d.id)) || DIMENSIONS[0];
reifeState.currentDim = next.id;
reifeState.currentQ = 0;
reifeState.view = 'assess';
renderReifegradModule();
});
const resBtn = $('#reife-result', host);
if (resBtn) resBtn.addEventListener('click', () => { reifeState.view = 'result'; renderReifegradModule(); });
const resetBtn = $('#reife-reset', host);
if (resetBtn) resetBtn.addEventListener('click', () => {
if (confirm('Alle Antworten löschen?')) {
reifeState.answers = {};
saveReifegrad({});
renderReifegradModule();
}
});
}
function renderBadgeRow() {
const ids = Object.keys(COCKPIT_BADGES);
return `<div class="badges-row">
${ids.map(id => {
const earned = !!state.badges[id];
return `<span class="badge-chip ${earned ? 'earned' : ''}"><span class="dot"></span>${esc(COCKPIT_BADGES[id])}</span>`;
}).join('')}
</div>`;
}
function renderReifeAssessment(host) {
const dim = DIMENSIONS.find(d => d.id === reifeState.currentDim);
if (!dim) { reifeState.view = 'overview'; return renderReifegradModule(); }
const qi = reifeState.currentQ;
const q = dim.questions[qi];
const answers = reifeState.answers[dim.id] || Array(5).fill(null);
const selected = answers[qi];
const total = dim.questions.length;
const pct = ((qi) / total) * 100;
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Dimension · ${esc(dim.title)}</span>
<h1>${esc(dim.title)}</h1>
<p>${esc(dim.short)}</p>
</div>
<div class="assessment">
<div class="assessment-nav">
<button class="linklike" id="reife-back">&larr; Übersicht</button>
<span>Frage ${qi + 1} / ${total}</span>
</div>
<div class="assessment-progress"><div class="assessment-progress-fill" style="width:${pct}%"></div></div>
<div class="question-block">
<h2>${esc(q)}</h2>
<p class="question-hint">Skala: 0 = nicht vorhanden, 4 = optimiert &amp; firmenweit verankert</p>
<div class="likert" role="radiogroup" aria-label="Bewertung">
${LIKERT.map(l => `
<button class="likert-btn" role="radio" aria-pressed="${selected === l.v}" data-v="${l.v}" type="button">
<span class="likert-n">${l.v}</span>
<span class="likert-t">${esc(l.t)}</span>
</button>
`).join('')}
</div>
<div class="q-nav">
<button class="btn" id="q-prev" ${qi === 0 ? 'disabled' : ''}>&larr; Zurück</button>
<button class="btn btn-primary" id="q-next" ${selected == null ? 'disabled' : ''}>${qi === total - 1 ? 'Dimension abschließen' : 'Weiter &rarr;'}</button>
</div>
</div>
</div>
<div style="text-align:center; margin-top: 16px;">
<button class="btn-ask-kai" id="ask-kai-dim">Frag Kai zu "${esc(dim.title)}"</button>
</div>
`;
$('#reife-back', host).addEventListener('click', () => { reifeState.view = 'overview'; renderReifegradModule(); });
$$('.likert-btn', host).forEach(b => b.addEventListener('click', () => {
const v = parseInt(b.dataset.v, 10);
if (!reifeState.answers[dim.id]) reifeState.answers[dim.id] = Array(5).fill(null);
const wasEmpty = reifeState.answers[dim.id][qi] == null;
reifeState.answers[dim.id][qi] = v;
saveReifegrad(reifeState.answers);
if (wasEmpty) addXP(2, 'Antwort');
renderReifeAssessment(host);
}));
const prevBtn = $('#q-prev', host);
if (prevBtn) prevBtn.addEventListener('click', () => { reifeState.currentQ = Math.max(0, qi - 1); renderReifeAssessment(host); });
const nextBtn = $('#q-next', host);
if (nextBtn) nextBtn.addEventListener('click', () => {
if (qi < total - 1) {
reifeState.currentQ = qi + 1;
renderReifeAssessment(host);
} else {
// dim completed
const justCompleted = reifeDimDone(dim.id);
const prevDoneCount = DIMENSIONS.filter(d => d.id !== dim.id && reifeDimDone(d.id)).length;
if (justCompleted) {
// +20 per dim (first completion only) handled by checking badges flag per dim
const key = `reife_dim_${dim.id}`;
if (!state.badges[key]) {
state.badges[key] = today();
saveState();
addXP(20, `Dimension ${dim.title} komplett`);
}
}
const allDone = reifeCountDone() === 5;
if (allDone && !state.badges.cockpit_reifegrad_done) {
addXP(50, 'Reifegrad-Assessment abgeschlossen');
unlockBadge('cockpit_reifegrad_done');
}
reifeState.view = allDone ? 'result' : 'overview';
renderReifegradModule();
}
});
$('#ask-kai-dim', host).addEventListener('click', () => {
openDock();
const prompt = `Was sind die wichtigsten Hebel, um in der Dimension "${dim.title}" vom DACH-Mittelstand (Durchschnitt ${BENCHMARK[dim.id].toFixed(1)}) auf Stufe 3 (etabliert) zu kommen?`;
fillDockInput(prompt);
});
}
function renderReifeResult(host) {
const scores = DIMENSIONS.map(d => ({ dim: d, score: reifeScore(d.id) || 0 }));
const overall = reifeOverallScore();
// Find 3 weakest for recommendations
const weakest = [...scores].sort((a, b) => a.score - b.score).slice(0, 3);
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Ergebnis</span>
<h1>Dein Reifegrad-Profil</h1>
<p>Radar gegen DACH-Mittelstand (BITKOM 2024: ~Gartner-Stufe 2 Mittelwert). Die 3 schwächsten Achsen sind deine Roadmap.</p>
</div>
<div class="radar-wrap">
<div class="radar-diagram">${renderRadarSVG(scores)}</div>
<div>
<div class="radar-score-big">${overall.toFixed(2)} <small style="font-size:16px; color: var(--text-mute);">/ 4.00</small></div>
<div class="radar-score-hint">${overallLabel(overall)}</div>
<div class="radar-legend">
<div class="legend-row"><span class="legend-swatch" style="background: var(--teal-c);"></span>Deine Werte</div>
<div class="legend-row"><span class="legend-swatch" style="background: rgba(167,139,250,.5); border: 1px dashed var(--violet);"></span>DACH-Mittelstand Ø</div>
</div>
<div style="margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap;">
<button class="btn" id="reife-overview-btn">Übersicht</button>
<button class="btn-ask-kai" id="ask-kai-overall">Roadmap für mich erstellen</button>
</div>
</div>
</div>
<h2 style="font-size: 18px; font-weight: 600; margin: 28px 0 12px;">Top-Empfehlungen</h2>
<div class="recos">
${weakest.map((w, i) => {
const reco = weakestReco(w.dim.id, w.score);
return `
<div class="reco-item">
<div class="reco-num">${i + 1}</div>
<div>
<h4>${esc(w.dim.title)} · ${w.score.toFixed(1)} / 4.0</h4>
<p>${esc(reco)}</p>
<button class="btn-ask-kai" data-dim="${w.dim.id}">Frag Kai zu "${esc(w.dim.title)}"</button>
</div>
</div>
`;
}).join('')}
</div>
${renderBadgeRow()}
`;
$('#reife-overview-btn', host).addEventListener('click', () => { reifeState.view = 'overview'; renderReifegradModule(); });
$('#ask-kai-overall', host).addEventListener('click', () => {
openDock();
const txt = `Mein Reifegrad-Assessment ergab: ${scores.map(s => `${s.dim.title} ${s.score.toFixed(1)}`).join(', ')}. Gesamt ${overall.toFixed(2)}/4.0. Erstelle mir eine priorisierte 12-Monats-Roadmap für die schwächsten 3 Dimensionen.`;
fillDockInput(txt);
});
$$('[data-dim]', host).forEach(b => {
if (b.classList.contains('btn-ask-kai')) {
b.addEventListener('click', () => {
const d = DIMENSIONS.find(dd => dd.id === b.dataset.dim);
if (!d) return;
openDock();
fillDockInput(`Meine Dimension "${d.title}" liegt bei ${(reifeScore(d.id) || 0).toFixed(1)} / 4.0 (DACH-Ø ${BENCHMARK[d.id]}). Was sind die 3 wichtigsten Maßnahmen in den nächsten 90 Tagen?`);
});
}
});
}
function overallLabel(s) {
if (s < 1) return 'Foundational / Experimenting — Start beim AI-Strategie-Dokument + Sponsorship.';
if (s < 2) return 'Emerging / Piloting — 3-5 Pilots mit klaren Business-Cases als Fokus.';
if (s < 2.8) return 'Operational / Scaling — der kritische Sprung. MLOps + Monitoring konsolidieren.';
if (s < 3.5) return 'Scaled — Platform + AI-Engineering-Team in Produktion.';
return 'Transformational / Optimizing — AI ist Teil des Geschäftsmodells.';
}
function weakestReco(dimId, score) {
const low = {
strategy: 'AI-Charter in 2-3 Seiten schreiben, vom CEO unterschreiben lassen. Quartals-Review einführen. Use-Case-Intake-Template verpflichtend ab Tag 1.',
data: 'Data-Catalog aktivieren (Open-Source-Stack reicht initial). Data-Quality-Scorecard für Top-5-Datensätze. Data-Owner benennen.',
technology: 'MLOps auf 1 Pattern einkochen (z.B. Training + Registry + Serving). Modell-Drift-Monitoring als Muss vor jedem Go-Live.',
people: 'AI-Literacy-Training für alle Mitarbeitenden (Art. 4 AI-Act-Pflicht). AI-Risk-Officer benennen — reicht initial als 20%-Rolle.',
process: 'Model-Lifecycle-Standard auf 1 Seite. EU-AI-Act-Klassifikation für alle Systeme durchführen. Incident-Response-Plan einmal durchspielen.'
};
const mid = {
strategy: 'Portfolio-Review quartalsweise: Welcher Use-Case wirft ROI? Welcher wird retiret? Business-Case-Templates standardisieren.',
data: 'Data-Lineage end-to-end (nicht nur Katalog-Eintrag). Trainingsdaten auf Bias screenen (Art. 10 AI-Act-Pflicht für High-Risk).',
technology: 'Von Ad-hoc-Monitoring zu Self-Service-Observability. Model-Registry mit Approval-Workflow. Automated Retrain-Pipelines.',
people: 'CAIO oder AI-Risk-Officer in Vollzeit. Hiring-Profil überarbeiten. Externe Fachanwalt-IT-Recht als Retainer.',
process: 'FRIA + DSFA kombiniert durchführen. Jährlicher Program-Review mit Vorstand. ISO 42001 als Rahmen adaptieren.'
};
return score < 2 ? low[dimId] : mid[dimId];
}
// ========= RADAR SVG =========
function renderRadarSVG(scoresArr) {
const size = 420; const cx = size / 2; const cy = size / 2;
const radius = 150; const rings = 4; // 0-4 scale, 4 rings
const n = scoresArr.length;
const angleFor = (i) => (-Math.PI / 2) + (i * 2 * Math.PI / n);
const pointAt = (i, val) => {
const a = angleFor(i);
const r = (val / 4) * radius;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
// Grid
let grid = '';
for (let r = 1; r <= rings; r++) {
const pts = [];
for (let i = 0; i < n; i++) {
const a = angleFor(i);
const rr = (r / rings) * radius;
pts.push([cx + rr * Math.cos(a), cy + rr * Math.sin(a)]);
}
grid += `<polygon points="${pts.map(p => p.join(',')).join(' ')}" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>`;
}
// Axes + labels
let axes = ''; let labels = '';
for (let i = 0; i < n; i++) {
const [x, y] = pointAt(i, 4);
axes += `<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>`;
const a = angleFor(i);
const lx = cx + (radius + 26) * Math.cos(a);
const ly = cy + (radius + 26) * Math.sin(a);
labels += `<text x="${lx}" y="${ly}" text-anchor="middle" fill="#a0a0b5" font-size="13" font-weight="500" dy=".3em">${esc(scoresArr[i].dim.title)}</text>`;
}
// Benchmark polygon
const benchPts = scoresArr.map((s, i) => pointAt(i, BENCHMARK[s.dim.id] || 2));
const bench = `<polygon points="${benchPts.map(p => p.join(',')).join(' ')}"
fill="rgba(167,139,250,0.12)" stroke="rgba(167,139,250,0.7)" stroke-width="1.5" stroke-dasharray="4 3"/>`;
// User polygon
const userPts = scoresArr.map((s, i) => pointAt(i, s.score));
const user = `<polygon points="${userPts.map(p => p.join(',')).join(' ')}"
fill="rgba(34,211,238,0.2)" stroke="#22d3ee" stroke-width="2"/>`;
const dots = scoresArr.map((s, i) => {
const [x, y] = pointAt(i, s.score);
return `<circle cx="${x}" cy="${y}" r="4" fill="#22d3ee" stroke="#0a0a0f" stroke-width="2"/>`;
}).join('');
return `<svg class="radar-svg" viewBox="0 0 ${size} ${size}" role="img" aria-label="Reifegrad-Radar">
${grid}
${axes}
${bench}
${user}
${dots}
${labels}
</svg>`;
}
// =====================================================================
// MODULE 2 — EU AI ACT CLASSIFIER
// =====================================================================
// Simple decision tree — 7 yes/no questions, returns verdict object
const AIACT_TREE = [
{
id: 'q1', key: 'prohibited',
q: 'Wird das System für Social-Scoring, Massenüberwachung, unterschwellige Manipulation oder Emotion-Recognition am Arbeitsplatz / in der Schule eingesetzt?',
hint: 'Art. 5 AI Act — „Prohibited Practices" gelten seit Februar 2025.',
yes: { verdict: 'prohibited' },
no: 'q2'
},
{
id: 'q2', key: 'safety_component',
q: 'Ist das System Sicherheitskomponente eines regulierten Produkts (Medizin-Device, Maschine, Fahrzeug, kritische Infrastruktur, Spielzeug)?',
hint: 'Art. 6 Abs. 1 — Weg A zu High-Risk via Anhang I.',
yes: { verdict: 'high', reason: 'safety_component' },
no: 'q3'
},
{
id: 'q3', key: 'annex_iii',
q: 'Wird das System für HR-Entscheidungen, Recruiting, Kredit-/Versicherungs-Scoring, Strafverfolgung, Bildungsbewertung, Migration oder Biometrie eingesetzt?',
hint: 'Anhang III Kategorien 1, 3, 4, 5, 6, 7. Deutsche Mittelständler unterschätzen HR-AI oft.',
yes: { verdict: 'high', reason: 'annex_iii' },
no: 'q4'
},
{
id: 'q4', key: 'narrow_task',
q: 'Erfüllt das System nur eine schmale, vorbereitende Aufgabe (z. B. reine Duplikat-Erkennung, Mustererkennung ohne Profiling)?',
hint: 'Art. 6 Abs. 3 — Opt-out aus High-Risk, muss dokumentiert werden.',
yes: { verdict: 'limited', reason: 'narrow_task_opt_out' },
no: 'q5'
},
{
id: 'q5', key: 'chatbot',
q: 'Ist es ein Chatbot, ein Deepfake-/Synthetic-Media-Generator oder interagiert direkt mit Endnutzer:innen als AI?',
hint: 'Art. 50 — Transparenzpflicht: „Du sprichst mit einer KI."',
yes: { verdict: 'limited', reason: 'chatbot' },
no: 'q6'
},
{
id: 'q6', key: 'pii',
q: 'Werden personenbezogene Daten verarbeitet oder zum Training verwendet?',
hint: 'DSGVO greift zusätzlich — Art. 10 AI Act + Art. 5 DSGVO synchron behandeln.',
yes: { verdict: 'minimal', reason: 'dsgvo_pflicht' },
no: 'q7'
},
{
id: 'q7', key: 'gpai',
q: 'Ist es ein General-Purpose-AI-Modell oder wird ein solches direkt in eurer Kontrolle trainiert (Foundation-Model mit > 10^25 FLOPs)?',
hint: 'Art. 51-56 GPAI-Regeln — für die meisten Deployer nicht relevant.',
yes: { verdict: 'minimal', reason: 'gpai_deployer' },
no: { verdict: 'minimal', reason: 'default' }
}
];
const aiactState = {
step: 0,
answers: {},
verdict: null,
runs: (function() { try { return JSON.parse(localStorage.getItem(LS_AIACT) || '{}').runs || []; } catch (e) { return []; } })()
};
function saveAIActRuns() {
localStorage.setItem(LS_AIACT, JSON.stringify({ runs: aiactState.runs }));
}
const VERDICTS = {
prohibited: {
title: 'Verboten (Prohibited Practice)',
label: 'Art. 5 AI Act',
cls: 'prohibited',
summary: 'Dein System fällt unter Art. 5 und ist seit dem 2. Februar 2025 in der EU verboten. Weiterbetrieb ist keine Option.',
obligations: [
'Art. 5: Unverzüglicher Stopp und Abbau',
'Art. 99: Strafen bis zu 35 Mio. EUR oder 7 % weltweiter Jahresumsatz (höher)',
'Legal-Review + Information der Aufsichtsbehörde prüfen',
'Alternative Implementierung ohne Art-5-Praktik entwerfen'
]
},
high: {
title: 'High-Risk',
label: 'Art. 6 + Anhang III',
cls: 'high',
summary: 'Umfangreiche Pflichten. Für Deployer + Provider unterschiedlich. Vollständige Anwendung ab 2. August 2026.',
obligations: [
'Art. 9: Risk-Management-System etablieren',
'Art. 10: Data-Governance — Training/Validation/Test dokumentieren, Bias-Screening',
'Art. 11 + Anhang IV: Technical Documentation',
'Art. 13: Transparency — User-Instructions für Deployer',
'Art. 14: Human Oversight — dokumentierte Eingriffsmöglichkeit',
'Art. 15: Accuracy, Robustness, Cybersecurity',
'Art. 26: Deployer-Pflichten (Monitoring, Logs, Information)',
'Art. 27: Fundamental Rights Impact Assessment (FRIA) — kombinierbar mit DSFA',
'Art. 43: Conformity Assessment + CE-Kennzeichnung',
'Art. 49: Registrierung in EU-Datenbank'
]
},
limited: {
title: 'Limited Risk',
label: 'Art. 50',
cls: 'limited',
summary: 'Transparenzpflichten. Kein CE-Zwang, aber Hinweispflicht gegenüber Nutzer:innen.',
obligations: [
'Art. 50 Abs. 1: Kennzeichnung — "Du interagierst mit einer KI"',
'Art. 50 Abs. 2: Synthetic-Content maschinenlesbar markieren',
'Art. 50 Abs. 4: Deepfakes offenlegen',
'DSGVO Art. 13/14: Informationspflichten, wenn personenbezogene Daten verarbeitet',
'Art. 4 (AI-Literacy) — gilt für ALLE AI-Nutzungen, unabhängig von Klasse'
]
},
minimal: {
title: 'Minimal Risk',
label: 'Freiwillig + Art. 4',
cls: 'minimal',
summary: 'Keine spezifischen AI-Act-Pflichten außer AI-Literacy (Art. 4). DSGVO gilt weiterhin bei personenbezogenen Daten.',
obligations: [
'Art. 4: AI-Literacy-Training für Mitarbeitende (seit Feb 2025)',
'Freiwillige Codes of Conduct empfohlen (Art. 95)',
'DSGVO Art. 5/6: Rechtmäßigkeitsgrundlage + Grundsätze prüfen',
'Art. 35 DSGVO: DSFA bei hohem Risiko für Betroffene'
]
}
};
function renderAIActModule() {
const host = $('#aiact-host');
if (aiactState.verdict) return renderAIActVerdict(host);
renderAIActQuestion(host);
}
function renderAIActQuestion(host) {
const step = aiactState.step;
const node = AIACT_TREE[step];
const totalSteps = AIACT_TREE.length;
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Modul 02</span>
<h1>EU AI Act — Risk-Classifier</h1>
<p>Entscheidungsbaum in einfacher Sprache. Am Ende: Risikoklasse + konkrete Pflichten mit Artikel-Bezug. <strong>Keine Rechtsberatung</strong> — für die konkrete Umsetzung mit IT-Recht-Fachanwalt sprechen.</p>
</div>
<div class="wizard">
<div class="progress-dots" role="progressbar" aria-valuenow="${step}" aria-valuemin="0" aria-valuemax="${totalSteps}">
${AIACT_TREE.map((_, i) => `<span class="${i === step ? 'active' : (i < step ? 'done' : '')}"></span>`).join('')}
</div>
<div class="wizard-q">
<h2>${esc(node.q)}</h2>
<p>${esc(node.hint)}</p>
<div class="yesno-row">
<button class="yesno-btn yes" data-ans="yes" type="button">Ja</button>
<button class="yesno-btn no" data-ans="no" type="button">Nein</button>
</div>
<div style="margin-top: 20px; text-align: center;">
<button class="btn btn-sm btn-ghost" id="aiact-restart">Neu starten</button>
</div>
</div>
</div>
${aiactState.runs.length > 0 ? `
<div style="margin-top: 24px; text-align: center; color: var(--text-mute); font-size: 13px;">
Bereits ${aiactState.runs.length} Systeme klassifiziert.
${aiactState.runs.length >= 3 && !state.badges.cockpit_ai_act_done ? '' : ''}
</div>
` : ''}
`;
$$('[data-ans]', host).forEach(btn => btn.addEventListener('click', () => {
const ans = btn.dataset.ans;
aiactState.answers[node.key] = ans;
const branch = node[ans];
if (branch && typeof branch === 'object' && branch.verdict) {
aiactState.verdict = { kind: branch.verdict, reason: branch.reason || null };
// record
aiactState.runs.push({ ts: Date.now(), verdict: branch.verdict, answers: { ...aiactState.answers } });
// cap stored runs at 10
aiactState.runs = aiactState.runs.slice(-10);
saveAIActRuns();
addXP(30, 'AI-Act-Classifier Run');
if (aiactState.runs.length >= 3 && !state.badges.cockpit_ai_act_done) {
unlockBadge('cockpit_ai_act_done');
}
renderAIActModule();
} else if (typeof branch === 'string') {
// find next step
const nextIdx = AIACT_TREE.findIndex(n => n.id === branch);
aiactState.step = nextIdx !== -1 ? nextIdx : step + 1;
renderAIActModule();
} else {
// default — move on
aiactState.step = step + 1;
renderAIActModule();
}
}));
$('#aiact-restart', host).addEventListener('click', () => {
aiactState.step = 0;
aiactState.answers = {};
aiactState.verdict = null;
renderAIActModule();
});
}
function renderAIActVerdict(host) {
const v = VERDICTS[aiactState.verdict.kind];
const reason = aiactState.verdict.reason;
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Ergebnis</span>
<h1>Klassifikation</h1>
<p>Basierend auf deinen Antworten. Speichere das Ergebnis, druck's aus, zeig's dem Fachanwalt.</p>
</div>
<div class="verdict ${v.cls}">
<span class="verdict-label">${esc(v.label)}</span>
<h2>${esc(v.title)}</h2>
<p class="verdict-summary">${esc(v.summary)}</p>
<h3 style="font-size: 14px; font-weight: 600; margin: 20px 0 10px; color: var(--text);">Konkrete Pflichten &amp; Artikel</h3>
<ul class="obligation-list">
${v.obligations.map(o => {
const parts = o.split(':');
if (parts.length > 1) return `<li><strong>${esc(parts[0])}:</strong>${esc(parts.slice(1).join(':'))}</li>`;
return `<li>${esc(o)}</li>`;
}).join('')}
</ul>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px;">
<button class="btn btn-primary" id="aiact-another">Nächstes System klassifizieren</button>
<button class="btn-ask-kai" id="aiact-ask-kai">Frag Kai zu diesem Verdict</button>
</div>
<div class="verdict-run-counter">Runs insgesamt: ${aiactState.runs.length} / für „AI-Act-Navigator"-Badge 3 verschiedene Systeme</div>
</div>
`;
$('#aiact-another', host).addEventListener('click', () => {
aiactState.step = 0;
aiactState.answers = {};
aiactState.verdict = null;
renderAIActModule();
});
$('#aiact-ask-kai', host).addEventListener('click', () => {
openDock();
const p = `Mein System wurde als "${v.title}" (${v.label}) klassifiziert. Was sind die 5 häufigsten Umsetzungsfehler in dieser Klasse, die ich vermeiden soll?`;
fillDockInput(p);
});
}
// =====================================================================
// MODULE 3 — KPI DASHBOARD (demo)
// =====================================================================
const TILES = [
// Executive
{ id: 'adoption', group: 'exec', label: 'Adoption Rate', value: '73%', trend: '+4% vs. Vormonat', trendKind: 'up',
explain: 'Anteil der anspruchsberechtigten User:innen, die das AI-System im letzten Monat aktiv genutzt haben. Proxy für Change-Success.',
series: [54, 58, 62, 64, 67, 70, 73] },
{ id: 'savings', group: 'exec', label: 'Monthly Cost Savings', value: '€ 38.400', trend: '+€3.2k vs. Vormonat', trendKind: 'up',
explain: 'Eingesparte operative Kosten durch AI-Automatisierung (Service-Deflection, Process-Shortening). Teil des Business-KPI-Sets (Modul 02).',
series: [22000, 26500, 29000, 30500, 33000, 35200, 38400] },
{ id: 'trust', group: 'exec', label: 'Trust Score', value: '4.1 / 5', trend: '+0.2 vs. Q-1', trendKind: 'up',
explain: 'User-Survey NPS-ähnlich: „Vertrauen Sie den AI-Antworten?". Zielwert > 4.0 für Skalierung.',
series: [3.5, 3.6, 3.7, 3.8, 3.9, 4.0, 4.1] },
// Operational
{ id: 'latency', group: 'ops', label: 'P95 Latency', value: '182 ms', trend: '-18ms vs. Vorwoche', trendKind: 'up',
explain: 'P95-Response-Time Ende-zu-Ende. Ziel < 300ms für konversationelle UX. Hinweis: nicht der Mittelwert — der P95 zählt.',
series: [210, 205, 200, 195, 190, 185, 182] },
{ id: 'throughput', group: 'ops', label: 'Throughput', value: '840 req/s', trend: '+12 req/s', trendKind: 'up',
explain: 'Peak-Requests pro Sekunde. Kapazitätsplanung + Kostenschätzung pro Inferenz.',
series: [720, 750, 760, 785, 800, 820, 840] },
{ id: 'errors', group: 'ops', label: 'Error Rate', value: '0.8%', trend: '-0.1% vs. Vorwoche', trendKind: 'up',
explain: 'Fehlgeschlagene Requests (5xx + Fachlich-Invalid). Ziel < 1%. Alarm bei > 2%.',
series: [1.2, 1.1, 1.0, 0.95, 0.9, 0.85, 0.8] },
{ id: 'availability', group: 'ops', label: 'Availability', value: '99.92%', trend: 'SLO ≥ 99.9%', trendKind: 'neutral',
explain: 'Uptime des AI-Service. Typisches SLO 99.9% (= ~43min Downtime/Monat). Tracking pro Region + Gesamt.',
series: [99.85, 99.87, 99.9, 99.92, 99.91, 99.92, 99.92] },
// Technical
{ id: 'accuracy', group: 'tech', label: 'Model Accuracy', value: '94.3%', trend: '+0.4% vs. Baseline', trendKind: 'up',
explain: 'Classification-Accuracy auf Holdout-Set. Achtung: Accuracy alleine unzuverlässig bei imbalancierten Klassen — Prüfe zusätzlich Precision/Recall.',
series: [92.5, 93.0, 93.4, 93.7, 94.0, 94.2, 94.3] },
{ id: 'f1', group: 'tech', label: 'F1 Score', value: '0.89', trend: '+0.02 vs. Vormonat', trendKind: 'up',
explain: 'Harmonisches Mittel aus Precision und Recall. Robust gegen Klassenungleichheit.',
series: [0.83, 0.85, 0.86, 0.87, 0.88, 0.885, 0.89] },
{ id: 'drift', group: 'tech', label: 'Drift (PSI)', value: '0.07', trend: 'ok (< 0.1)', trendKind: 'up',
explain: 'Population Stability Index. 0 = keine Drift, > 0.1 = moderate Drift, > 0.25 = signifikant → Retrain.',
series: [0.03, 0.04, 0.05, 0.055, 0.06, 0.065, 0.07] },
{ id: 'bias', group: 'tech', label: 'Bias (EO Gap)', value: '0.03', trend: 'EO-OK (< 0.05)', trendKind: 'up',
explain: 'Equal-Opportunity-Differenz zwischen protected und unprotected group. Hinweis: Bias-Metriken immer auf Fairness-Definition (DP vs. EO vs. EOd) abstimmen.',
series: [0.07, 0.06, 0.055, 0.05, 0.045, 0.04, 0.03] }
];
function loadDash() {
try { return JSON.parse(localStorage.getItem(LS_DASH) || '{"seen": []}'); } catch (e) { return { seen: [] }; }
}
function saveDash(d) { localStorage.setItem(LS_DASH, JSON.stringify(d)); }
const dashState = loadDash();
if (!Array.isArray(dashState.seen)) dashState.seen = [];
function renderDashboardModule() {
const host = $('#dashboard-host');
const groups = [
{ key: 'exec', title: 'Executive Layer — Board-Kennzahlen', cls: 'exec' },
{ key: 'ops', title: 'Operational Layer — Service-Health', cls: 'ops' },
{ key: 'tech', title: 'Technical Layer — Model-Qualität', cls: 'tech' }
];
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Modul 03</span>
<h1>KPI-Dashboard für KI-Systeme</h1>
<p>So könnte dein erstes Dashboard aussehen — drei Layer, Executive/Operational/Technical. Klick auf jedes Tile für Sparkline + „Was misst das?". Daten sind Demo-Werte, die Struktur ist das, was zählt.</p>
</div>
${groups.map(g => `
<div class="dash-group">
<div class="dash-group-title">${esc(g.title)}</div>
<div class="dash-grid ${g.cls}">
${TILES.filter(t => t.group === g.key).map(t => {
const seen = dashState.seen.includes(t.id);
return `
<button class="tile ${g.cls} ${seen ? 'seen' : ''}" data-tile="${t.id}" type="button">
<span class="tile-label">${esc(t.label)}</span>
<span class="tile-value">${esc(t.value)}</span>
<span class="tile-trend ${t.trendKind}">${esc(t.trend)}</span>
</button>
`;
}).join('')}
</div>
</div>
`).join('')}
<p style="color: var(--text-mute); font-size: 13px; margin-top: 8px;">
${dashState.seen.length} / ${TILES.length} Tiles geöffnet — ${dashState.seen.length === TILES.length ? 'komplett!' : `öffne alle für das Dashboard-Flaneur:in-Abzeichen`}
</p>
`;
$$('[data-tile]', host).forEach(btn => btn.addEventListener('click', () => openTileModal(btn.dataset.tile)));
}
function openTileModal(id) {
const tile = TILES.find(t => t.id === id); if (!tile) return;
// mark seen
if (!dashState.seen.includes(id)) {
dashState.seen.push(id);
saveDash(dashState);
// partial XP per new tile
addXP(2, 'Tile geöffnet');
if (dashState.seen.length === TILES.length && !state.badges.cockpit_dashboard_explored) {
addXP(25, 'Alle Tiles erkundet');
unlockBadge('cockpit_dashboard_explored');
}
}
// re-render to reflect seen state
renderDashboardModule();
// open modal
openModal(`
<div class="modal-head">
<h3>${esc(tile.label)}</h3>
<button class="modal-close" aria-label="Schließen" data-close>✕</button>
</div>
<div class="modal-value">${esc(tile.value)}</div>
<div class="modal-value-sub">${esc(tile.trend)}</div>
<div>${renderSparkline(tile.series)}</div>
<p>${esc(tile.explain)}</p>
<div class="modal-actions">
<button class="btn" data-close>Schließen</button>
<button class="btn btn-primary" id="modal-ask-kai">Frag Kai zu "${esc(tile.label)}"</button>
</div>
`);
$('#modal-ask-kai').addEventListener('click', () => {
closeModal();
openDock();
fillDockInput(`Erklär mir die Kennzahl "${tile.label}" im Kontext eines AI-Systems in Produktion. Welche Alert-Schwellen sind üblich?`);
});
}
function renderSparkline(series) {
if (!series || series.length < 2) return '';
const w = 420; const h = 80; const pad = 8;
const min = Math.min(...series); const max = Math.max(...series);
const range = (max - min) || 1;
const xStep = (w - pad * 2) / (series.length - 1);
const pts = series.map((v, i) => {
const x = pad + i * xStep;
const y = h - pad - ((v - min) / range) * (h - pad * 2);
return [x, y];
});
const path = pts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
// area fill
const area = path + ` L ${pts[pts.length-1][0].toFixed(1)} ${h - pad} L ${pts[0][0].toFixed(1)} ${h - pad} Z`;
const last = pts[pts.length - 1];
return `<svg class="sparkline-svg" viewBox="0 0 ${w} ${h}" role="img" aria-label="7-Tage-Trend">
<defs>
<linearGradient id="spark-grad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#22d3ee" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/>
</linearGradient>
</defs>
<path d="${area}" fill="url(#spark-grad)" stroke="none"/>
<path d="${path}" fill="none" stroke="#22d3ee" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="3.5" fill="#22d3ee" stroke="#0a0a0f" stroke-width="2"/>
</svg>`;
}
// ========= MODAL =========
function openModal(innerHTML) {
const root = $('#modal-root');
root.innerHTML = `<div class="modal" role="dialog" aria-modal="true">${innerHTML}</div>`;
root.classList.add('open');
root.setAttribute('aria-hidden', 'false');
$$('[data-close]', root).forEach(b => b.addEventListener('click', closeModal));
root.addEventListener('click', backdropClose);
document.addEventListener('keydown', modalEscClose);
// focus first focusable
const first = root.querySelector('button,[href],textarea,input');
if (first) setTimeout(() => first.focus(), 50);
}
function backdropClose(e) { if (e.target.id === 'modal-root') closeModal(); }
function modalEscClose(e) { if (e.key === 'Escape') closeModal(); }
function closeModal() {
const root = $('#modal-root');
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
root.innerHTML = '';
root.removeEventListener('click', backdropClose);
document.removeEventListener('keydown', modalEscClose);
}
// =====================================================================
// MODULE 4 — CHAT DOCK (+ FULL CHAT in module-4)
// =====================================================================
function loadDockChat() {
try { return JSON.parse(localStorage.getItem(LS_CHAT) || '[]'); } catch (e) { return []; }
}
function saveDockChat() {
const trimmed = dockHistory.slice(-30);
localStorage.setItem(LS_CHAT, JSON.stringify(trimmed));
}
let dockHistory = loadDockChat();
let dockBusy = false;
function renderDockMessages() {
const box = $('#dock-box'); if (!box) return;
if (dockHistory.length === 0) {
box.innerHTML = `<div class="dock-msg sys">Hi, ich bin Kai. Frag mich zu Metriken, AI Act, Reifegrad — oder klick "Frag Kai" in den Modulen.</div>`;
return;
}
box.innerHTML = '';
for (const m of dockHistory) {
const el = document.createElement('div');
el.className = 'dock-msg ' + (m.role === 'assistant' ? 'bot' : 'user');
if (m.role === 'assistant') el.innerHTML = renderMD(m.content);
else el.textContent = m.content;
box.appendChild(el);
}
box.scrollTop = box.scrollHeight;
}
function openDock() {
const dock = $('#chat-dock');
dock.classList.remove('collapsed');
dock.classList.add('open');
// mobile: focus input
setTimeout(() => { const ta = $('#dock-input'); if (ta) ta.focus(); }, 60);
}
function closeDock() {
const dock = $('#chat-dock');
dock.classList.remove('open');
if (window.innerWidth > 960) dock.classList.add('collapsed');
}
function fillDockInput(text) {
const ta = $('#dock-input'); if (!ta) return;
ta.value = text; ta.focus();
// autoresize
ta.style.height = 'auto'; ta.style.height = Math.min(140, ta.scrollHeight) + 'px';
}
async function sendDockChat(text) {
if (!text.trim() || dockBusy) return;
if (!KEY) {
appendDockMsg('err', 'Kein API-Schlüssel konfiguriert. window.__KAI_KEY__ fehlt.');
return;
}
dockBusy = true;
const sendBtn = $('#dock-send'); if (sendBtn) sendBtn.disabled = true;
dockHistory.push({ role: 'user', content: text });
renderDockMessages();
saveDockChat();
const box = $('#dock-box');
const pending = document.createElement('div');
pending.className = 'dock-msg bot';
pending.innerHTML = '<span class="dots"><span></span><span></span><span></span></span>';
box.appendChild(pending);
box.scrollTop = box.scrollHeight;
try {
const r = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + KEY },
body: JSON.stringify({ message: text, history: dockHistory.slice(0, -1).slice(-20) })
});
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));
pending.remove();
dockHistory.push({ role: 'assistant', content: data.reply || '(leere Antwort)' });
saveDockChat();
renderDockMessages();
// award small XP for first-ever question of the session (tracked via timestamp)
if (!state._lastDockQ || (Date.now() - state._lastDockQ) > 60000) {
state._lastDockQ = Date.now();
saveState();
addXP(5, 'Frage an Kai');
}
} catch (err) {
pending.remove();
appendDockMsg('err', 'Fehler: ' + (err.message || String(err)));
} finally {
dockBusy = false;
if (sendBtn) sendBtn.disabled = false;
const ta = $('#dock-input'); if (ta) { ta.value = ''; ta.style.height = 'auto'; ta.focus(); }
}
}
function appendDockMsg(kind, content) {
const box = $('#dock-box'); if (!box) return;
const el = document.createElement('div');
el.className = 'dock-msg ' + kind;
el.textContent = content;
box.appendChild(el);
box.scrollTop = box.scrollHeight;
}
function resetDock() {
if (!confirm('Chat-Verlauf im Dock löschen?')) return;
dockHistory = [];
saveDockChat();
renderDockMessages();
toast('Dock-Chat zurückgesetzt', 'info', 1800);
}
// Full chat module (module 4) — shows a larger empty-state + shares state with dock
function renderChatFullModule() {
const host = $('#chat-full-host');
host.innerHTML = `
<div class="mod-head">
<span class="mod-badge">Modul 04</span>
<h1>Chat mit Kai</h1>
<p>Dein ruhiger, systematischer Sparring-Coach. Isolierter Chat-Verlauf vom Widget — hier diskutierst du Cockpit-Themen. Shortcut: <code>Ctrl+K</code> fokussiert das Dock-Input rechts.</p>
</div>
<div class="chat-full-wrap">
<p><strong>Quick-Starter:</strong></p>
<div class="chat-full-empty">
<button class="chip" data-chip="Erklär mir den Unterschied zwischen Precision, Recall und F1-Score an einem HR-Recruiting-Beispiel.">Precision vs. Recall</button>
<button class="chip" data-chip="Ich habe ein KI-System für Recruiting. Welche EU-AI-Act-Pflichten treffen uns als Deployer?">HR-AI Deployer-Pflichten</button>
<button class="chip" data-chip="Wie sieht eine pragmatische AI-Governance-Struktur für 50-Mitarbeitende-Unternehmen aus?">Governance für KMU</button>
<button class="chip" data-chip="Welche 5 KPIs gehören zwingend ins Board-Reporting für AI?">Board-Reporting KPIs</button>
<button class="chip" data-chip="Wir starten unser erstes RAG-System. Welche 3 Metriken brauchen wir ab Tag 1?">RAG-Metriken Tag 1</button>
<button class="chip" data-chip="Was ist der Unterschied zwischen ISO 42001 und NIST AI RMF — welches zuerst?">ISO 42001 vs NIST</button>
</div>
<p style="font-size: 12px;">Öffne das Dock (rechts oder unten), um zu chatten — oder klicke einen Starter, um eine Frage vorzubereiten.</p>
</div>
`;
$$('.chip', host).forEach(c => c.addEventListener('click', () => {
openDock();
fillDockInput(c.dataset.chip);
}));
}
// =====================================================================
// NAV + ROUTING
// =====================================================================
function switchModule(modId) {
$$('.module').forEach(m => m.dataset.active = (m.id === 'mod-' + modId) ? 'true' : 'false');
$$('.nav-item').forEach(n => n.setAttribute('aria-selected', (n.dataset.module === modId) ? 'true' : 'false'));
// Re-render current module fresh
if (modId === 'reifegrad') renderReifegradModule();
if (modId === 'aiact') renderAIActModule();
if (modId === 'dashboard') renderDashboardModule();
if (modId === 'chat') renderChatFullModule();
}
function installNav() {
$$('.nav-item').forEach(n => n.addEventListener('click', () => switchModule(n.dataset.module)));
}
function installKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl+1..4 -> switch module
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && ['1','2','3','4'].includes(e.key)) {
const map = { '1': 'reifegrad', '2': 'aiact', '3': 'dashboard', '4': 'chat' };
e.preventDefault();
switchModule(map[e.key]);
return;
}
// Ctrl+K -> focus dock
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
openDock();
const ta = $('#dock-input'); if (ta) ta.focus();
}
});
}
function installDock() {
renderDockMessages();
const form = $('#dock-form');
const input = $('#dock-input');
// autoresize
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(140, input.scrollHeight) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendDockChat(input.value);
}
});
form.addEventListener('submit', (e) => { e.preventDefault(); sendDockChat(input.value); });
$('#dock-reset').addEventListener('click', resetDock);
$('#dock-collapse').addEventListener('click', () => {
const dock = $('#chat-dock');
if (window.innerWidth <= 960) { dock.classList.remove('open'); }
else { dock.classList.add('collapsed'); }
});
$('#dock-open').addEventListener('click', openDock);
}
// =====================================================================
// BOOT
// =====================================================================
function boot() {
// Ensure badges object exists
if (!state.badges) { state.badges = {}; saveState(); }
renderXP();
installNav();
installKeyboardShortcuts();
installDock();
// Initial module
switchModule('reifegrad');
// Touch activity for streak (shared widget logic would also do this, harmless redundancy)
state.lastActive = today(); saveState();
// Welcome toast if first visit
if (!state.seenCockpitWelcome) {
state.seenCockpitWelcome = true; saveState();
setTimeout(() => toast('Willkommen im Kai-Cockpit. Start mit dem Reifegrad-Assessment.', 'info', 3800), 600);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();