1250 lines
56 KiB
JavaScript
1250 lines
56 KiB
JavaScript
|
|
/* 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, 0–4 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">← Ü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 & 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' : ''}>← Zurück</button>
|
|||
|
|
<button class="btn btn-primary" id="q-next" ${selected == null ? 'disabled' : ''}>${qi === total - 1 ? 'Dimension abschließen' : 'Weiter →'}</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 & 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();
|
|||
|
|
}
|
|||
|
|
})();
|