markemacht/resources/js/web/klarheits-check.js
Kevin Adametz 5c3cbd6431 feat(klarheits-check): KI-Auswertung mit Proxy, Test-Protokoll und Missbrauchsschutz
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>
2026-06-19 13:13:20 +00:00

309 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };
})();