Serverseitige KI-Auswertung des Marken-Klarheits-Checks (Dual-Provider:
Anthropic/OpenAI, umschaltbar via KLARHEITS_CHECK_KI), mit kuratiertem
Prompt, belegtem Grad aus dem Wortlaut, deterministischem Fallback und
Datenschutz-Abschnitt zur KI-Verarbeitung im Privacy-Modus.
Neu in diesem Stand:
- Test-Protokoll (KlarheitsCheckProtokoll): schreibt je KI-Lauf Eingabe +
Ausgabe mit Datum und Session-ID als JSONL. Token-geschützte Ansicht unter
/klarheits-check/protokoll/{token} plus Komfort-Übersicht (nur außerhalb
der Produktion). Per KLARHEITS_CHECK_LOG abschaltbar.
- Missbrauchsschutz der KI-Anfragen: pro IP/Minute, pro IP/Tag und globaler
Tagesdeckel (konfigurierbar). Bei Überschreitung statisches Ergebnis mit
Hinweis statt KI; greift nur bei Freitext.
- Deutlicher Lade-Hinweis ("Bitte warten … bis zu 30 Sekunden") mit größerem
Spinner während der KI-Anfrage.
Abgedeckt durch Feature-Tests (Auswerten, Protokoll, Probe-Command).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
13 KiB
JavaScript
309 lines
13 KiB
JavaScript
/* Marken-Klarheits-Check — Quiz-State, geführte Navigation, deterministischer Reifegrad,
|
||
* Ergebnis-Rendering. Die KI-Einordnung kommt (nur bei Freitext) vom serverseitigen Proxy
|
||
* POST /klarheits-check/auswerten. Schlägt der Call fehl oder fehlt Freitext, greift das
|
||
* deterministische Ergebnis — das Tool funktioniert immer.
|
||
* Funktioniert auf /klarheits-check wie im Overlay (mehrere [data-kc]-Roots).
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Reifegrad je Skala-Index — Spiegel zu config/klarheits_check.php.
|
||
const GRAD = ['scharf', 'unscharf', 'nicht entschieden', 'nicht entschieden'];
|
||
const GRADCLASS = ['scharf', 'unscharf', 'offen', 'offen'];
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
}
|
||
|
||
// Umami-Event (self-hosted). No-op, wenn der Tracker nicht geladen ist.
|
||
// Wir senden ausschließlich Aggregat-Zahlen, niemals Freitext.
|
||
let started = false;
|
||
function track(event, data) {
|
||
try {
|
||
if (window.umami && typeof window.umami.track === 'function') window.umami.track(event, data);
|
||
} catch (_) { /* Tracking darf das Tool nie blockieren */ }
|
||
}
|
||
|
||
function scrollToEl(el) {
|
||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
// Vier kanonische Stufen-Texte — Spiegel zu config/klarheits_check.php ('stufen')
|
||
// und zum Block <gesamturteil> im KI-Prompt. So zeigt der No-Text-Pfad exakt
|
||
// denselben Auftakt/Schluss wie KI-Lauf und serverseitiger Fallback.
|
||
const STUFEN = {
|
||
scharf: {
|
||
auftakt: 'Das Markenfundament ist in weiten Teilen bereits scharf.',
|
||
schluss: 'Ihr Fundament trägt in weiten Teilen schon. Das Marken-Klarheits-Audit hält fest, was hier bereits klar ist, und schließt die wenigen offenen Stellen zu einer dokumentierten Marken-DNA.',
|
||
},
|
||
gemischt: {
|
||
auftakt: 'Das Markenfundament steht in Teilen, ist aber noch nicht durchgängig scharf.',
|
||
schluss: 'Einige Glieder Ihres Fundaments tragen bereits, andere warten noch auf eine klare Entscheidung. Das Marken-Klarheits-Audit ist der Ort, an dem aus dieser Diagnose eine dokumentierte Marken-DNA wird.',
|
||
},
|
||
offen: {
|
||
auftakt: 'Das Markenfundament ist überwiegend noch nicht scharf gestellt.',
|
||
schluss: 'Die offenen Punkte liegen nicht in der Umsetzung, sondern in noch nicht getroffenen Entscheidungen. Genau diese Entscheidungen hält das Marken-Klarheits-Audit als dokumentierte Marken-DNA fest.',
|
||
},
|
||
};
|
||
|
||
// Gesamtstufe aus der Anzahl „scharf"-Grade (>=4 scharf, 2–3 gemischt, sonst offen).
|
||
function stufeFor(state) {
|
||
let scharf = 0;
|
||
state.forEach((s) => { if (s.sel != null && GRADCLASS[s.sel] === 'scharf') scharf++; });
|
||
if (scharf >= 4) return STUFEN.scharf;
|
||
if (scharf >= 2) return STUFEN.gemischt;
|
||
return STUFEN.offen;
|
||
}
|
||
|
||
function initCheck(root) {
|
||
if (root.dataset.kcInit) return; // Doppel-Init vermeiden (Seite + Overlay teilen das Markup)
|
||
root.dataset.kcInit = '1';
|
||
|
||
const questions = Array.from(root.querySelectorAll('.kc-q'));
|
||
const state = questions.map(() => ({ sel: null, text: '' }));
|
||
|
||
// Antwort wählen → markieren, Freitextfeld dezent einladen, Weiter-Button zeigen.
|
||
root.querySelectorAll('[data-kc-opt]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (!started) { started = true; track('klarheits-check-gestartet'); }
|
||
|
||
const q = +btn.dataset.q;
|
||
const o = +btn.dataset.o;
|
||
state[q].sel = o;
|
||
root.querySelectorAll('[data-kc-opt][data-q="' + q + '"]').forEach((x) => x.setAttribute('aria-pressed', 'false'));
|
||
btn.setAttribute('aria-pressed', 'true');
|
||
|
||
const qEl = questions[q];
|
||
const field = qEl.querySelector('[data-kc-field]');
|
||
if (field && !state[q].text) field.classList.add('is-invited'); // Hervorhebung, KEIN Fokus
|
||
const next = qEl.querySelector('[data-kc-next]');
|
||
if (next) next.hidden = false;
|
||
|
||
hideError(root);
|
||
});
|
||
});
|
||
|
||
// Freitext → speichern; sobald etwas drinsteht, Einladungs-Hervorhebung lösen.
|
||
root.querySelectorAll('[data-kc-text]').forEach((t) => {
|
||
t.addEventListener('input', () => {
|
||
const q = +t.dataset.q;
|
||
state[q].text = t.value.trim();
|
||
const field = questions[q].querySelector('[data-kc-field]');
|
||
if (field) field.classList.toggle('is-invited', !state[q].text && state[q].sel != null);
|
||
});
|
||
});
|
||
|
||
// Weiter → nächste Frage, bei der letzten zur Auswertung.
|
||
root.querySelectorAll('[data-kc-next-btn]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const qEl = btn.closest('.kc-q');
|
||
const idx = questions.indexOf(qEl);
|
||
const target = questions[idx + 1] || root.querySelector('[data-kc-submit]');
|
||
scrollToEl(target);
|
||
});
|
||
});
|
||
|
||
const submitBtn = root.querySelector('[data-kc-submit]');
|
||
if (submitBtn) submitBtn.addEventListener('click', () => auswerten(root, state, submitBtn, questions));
|
||
|
||
const jumpBtn = root.querySelector('[data-kc-error-jump]');
|
||
if (jumpBtn) {
|
||
jumpBtn.addEventListener('click', () => {
|
||
const firstOpen = state.findIndex((s) => s.sel == null);
|
||
if (firstOpen !== -1) scrollToEl(questions[firstOpen]);
|
||
});
|
||
}
|
||
|
||
const resetBtn = root.querySelector('[data-kc-reset]');
|
||
if (resetBtn) resetBtn.addEventListener('click', () => window.location.reload());
|
||
}
|
||
|
||
function hideError(root) {
|
||
const err = root.querySelector('[data-kc-error]');
|
||
if (err) err.hidden = true;
|
||
}
|
||
|
||
async function auswerten(root, state, submitBtn, questions) {
|
||
if (state.some((s) => s.sel == null)) {
|
||
const err = root.querySelector('[data-kc-error]');
|
||
if (err) err.hidden = false;
|
||
return;
|
||
}
|
||
hideError(root);
|
||
|
||
submitBtn.disabled = true;
|
||
const loading = root.querySelector('[data-kc-loading]');
|
||
const hatText = state.some((s) => s.text);
|
||
if (loading && hatText) {
|
||
submitBtn.hidden = true;
|
||
loading.hidden = false;
|
||
loading.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
let ki = null;
|
||
let limitHinweis = null;
|
||
if (hatText) {
|
||
try {
|
||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||
const res = await fetch('/klarheits-check/auswerten', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json',
|
||
'X-CSRF-TOKEN': meta ? meta.getAttribute('content') : '',
|
||
},
|
||
body: JSON.stringify({ antworten: state.map((s) => ({ skala: s.sel, text: s.text })) }),
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
// Limit erreicht: statisches Ergebnis + Hinweis, nicht als KI behandeln.
|
||
if (data && data.hinweis) { limitHinweis = data.hinweis; } else { ki = data; }
|
||
}
|
||
} catch (_) { ki = null; }
|
||
}
|
||
|
||
render(root, state, ki, questions, limitHinweis);
|
||
|
||
// Abschluss: nur Reifegrad-Verteilung + Flags, kein Freitext.
|
||
const verteilung = { scharf: 0, unscharf: 0, offen: 0 };
|
||
state.forEach((s) => { if (s.sel != null) verteilung[GRADCLASS[s.sel]]++; });
|
||
track('klarheits-check-abgeschlossen', { ...verteilung, mit_text: hatText, ki: !!ki, limit: !!limitHinweis });
|
||
|
||
if (loading) loading.hidden = true;
|
||
|
||
// Ergebnis-Zustand: Fragen + kompletter Aktionsbereich weg, Hero-Start weg.
|
||
const form = root.querySelector('[data-kc-form]');
|
||
if (form) form.hidden = true;
|
||
const actions = root.querySelector('.kc-actions');
|
||
if (actions) actions.hidden = true;
|
||
document.querySelectorAll('[data-kc-start-link]').forEach((el) => { el.hidden = true; });
|
||
|
||
// „Neu starten“ hinter den Audit-CTA ans Ende des Ergebnisses verschieben.
|
||
const result = root.querySelector('[data-kc-result]');
|
||
const resetBtn = root.querySelector('[data-kc-reset]');
|
||
const lastBlock = result ? result.querySelector('.kc-r-block:last-child') : null;
|
||
if (resetBtn && lastBlock) {
|
||
resetBtn.hidden = false;
|
||
lastBlock.appendChild(resetBtn);
|
||
}
|
||
|
||
scrollToEl(result);
|
||
}
|
||
|
||
// Belegter Grad (aus dem Wortlaut) → CSS-Klasse der ersten Spalte.
|
||
function classForGrad(grad) {
|
||
if (grad === 'scharf') return 'scharf';
|
||
if (grad === 'unscharf') return 'unscharf';
|
||
return 'offen'; // 'nicht entschieden'
|
||
}
|
||
|
||
function render(root, state, ki, questions, limitHinweis) {
|
||
const kiSpiegelFor = (n) => {
|
||
if (!ki || !ki.spiegel) return null;
|
||
return ki.spiegel.find((s) => s.nr === n) || null;
|
||
};
|
||
|
||
const hatText = state.some((s) => s.text);
|
||
const stufe = stufeFor(state);
|
||
|
||
let html = '<div class="kc-r-eyebrow meta">' + (ki ? 'Ihre Auswertung' : 'Statische Auswertung') + '</div>'
|
||
+ '<p class="kc-r-subline dim">Erster Eindruck, keine fertige Diagnose. Ein Spiegel Ihrer Antworten, kein Urteil über Ihr Unternehmen.</p>'
|
||
+ '<div class="kc-r-gesamt">' + (ki && ki.gesamt ? escapeHtml(ki.gesamt) : escapeHtml(stufe.auftakt)) + '</div>';
|
||
|
||
// KI-Limit erreicht: deutlich machen, dass dies die statische Auswertung ist.
|
||
if (limitHinweis) {
|
||
html += '<div class="kc-r-hint kc-r-limit">' + escapeHtml(limitHinweis) + '</div>';
|
||
}
|
||
|
||
// Ohne Freitext greift nur der deterministische Reifegrad. Deutlich machen,
|
||
// dass ausgefüllte Felder eine echte, am Wortlaut belegte Analyse ergeben.
|
||
if (!hatText) {
|
||
html += '<div class="kc-r-hint">'
|
||
+ '<strong>Möchten Sie mehr Klarheit und eine präzise Auswertung?</strong> '
|
||
+ 'Das hier ist eine statische Auswertung allein aus Ihrer Skala. '
|
||
+ 'Füllen Sie die Felder unter den Fragen aus – dann liest die Analyse Ihren Wortlaut, '
|
||
+ 'belegt jede Einordnung am Beispiel und spiegelt zurück, wo Selbstbild und Formulierung auseinandergehen.'
|
||
+ '</div>';
|
||
}
|
||
|
||
questions.forEach((q, i) => {
|
||
const sel = state[i].sel;
|
||
const bw = (q.querySelector('.kc-q-bw') || {}).textContent || '';
|
||
const selBtn = root.querySelector('[data-kc-opt][data-q="' + i + '"][aria-pressed="true"]');
|
||
const optText = selBtn ? selBtn.textContent.trim() : '';
|
||
const m = kiSpiegelFor(i + 1);
|
||
|
||
// Standard: Skala-Stufe als Rückfall.
|
||
let gradClass = GRADCLASS[sel];
|
||
let gradLabel = GRAD[sel];
|
||
let spiegelText = 'Einschätzung: ' + escapeHtml(optText) + '.';
|
||
let note = '';
|
||
|
||
if (m && m.grad) {
|
||
// Belegter Grad aus dem Wortlaut steuert Label + Farbe.
|
||
gradClass = classForGrad(m.grad);
|
||
gradLabel = m.grad;
|
||
if (m.text) spiegelText = escapeHtml(m.text);
|
||
} else if (state[i].text && !limitHinweis) {
|
||
// Freitext lag vor, aber kein belegter Grad (KI nicht verfügbar):
|
||
// Skala zeigen, aber als ungeprüft kennzeichnen — kein geprüftes Urteil.
|
||
// Bei erreichtem Limit erklärt das der Banner oben, nicht jede Zeile.
|
||
note = '<div class="kc-r-unverif">Wortlaut-Prüfung nicht verfügbar</div>';
|
||
}
|
||
|
||
html += '<div class="kc-r-row">'
|
||
+ '<div><div class="kc-r-grad ' + gradClass + '">' + gradLabel + '</div>' + note + '</div>'
|
||
+ '<div><div class="kc-r-label meta">' + escapeHtml(bw) + '</div>'
|
||
+ '<div class="kc-r-spiegel">' + spiegelText + '</div></div>'
|
||
+ '</div>';
|
||
});
|
||
|
||
if (ki && ki.luecken && ki.luecken.length) {
|
||
html += '<div class="kc-r-block"><h3 class="meta">Lücke zwischen Selbstbild und Wortlaut</h3><ul>'
|
||
+ ki.luecken.map((l) => '<li>' + escapeHtml(l) + '</li>').join('') + '</ul></div>';
|
||
}
|
||
if (ki && ki.stimmigkeit && ki.stimmigkeit.length) {
|
||
html += '<div class="kc-r-block"><h3 class="meta">Innere Stimmigkeit</h3><ul>'
|
||
+ ki.stimmigkeit.map((l) => '<li>' + escapeHtml(l) + '</li>').join('') + '</ul></div>';
|
||
}
|
||
|
||
const schluss = ki && ki.schluss ? ki.schluss : stufe.schluss;
|
||
html += '<div class="kc-r-block"><div class="kc-r-schluss">' + escapeHtml(schluss) + '</div>'
|
||
+ '<a class="btn btn-nav-cta" href="/audit" data-kc-audit>Zum Marken-Klarheits-Audit</a></div>';
|
||
|
||
const result = root.querySelector('[data-kc-result]');
|
||
if (result) {
|
||
result.innerHTML = html;
|
||
result.hidden = false;
|
||
const audit = result.querySelector('[data-kc-audit]');
|
||
if (audit) audit.addEventListener('click', () => track('klarheits-check-audit-klick'));
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
document.querySelectorAll('[data-kc]').forEach(initCheck);
|
||
|
||
// „Check starten“ im Hero: sanft zum Anker scrollen statt hart zu springen.
|
||
document.querySelectorAll('[data-kc-start-link]').forEach((link) => {
|
||
if (link.dataset.kcBound) return;
|
||
link.dataset.kcBound = '1';
|
||
link.addEventListener('click', (e) => {
|
||
const href = link.getAttribute('href') || '';
|
||
const target = href.charAt(0) === '#' ? document.querySelector(href) : null;
|
||
if (target) {
|
||
e.preventDefault();
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (document.readyState !== 'loading') {
|
||
init();
|
||
} else {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
}
|
||
|
||
// Für dynamisch eingefügte Overlays (CP 2.x) erneut aufrufbar.
|
||
window.MarkemachtKlarheitsCheck = { init: init };
|
||
})();
|