ki-kennzahlen-coach/www/cockpit/cockpit.js

1250 lines
56 KiB
JavaScript
Raw Normal View History

/* 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();
}
})();