/**
* Assessment-Engine — Adaptiver Eingangstest.
*
* Wählt Fragen aus dem Katalog aus, passt die Schwierigkeit
* dynamisch an die Antworten des Nutzers an und berechnet
* pro Kategorie einen Score (0.0–1.0).
*
* Algorithmus:
* 1. Startet mit Schwierigkeit 2 (Grundlagen)
* 2. Bei richtiger Antwort: Schwierigkeit +1 (max 4)
* 3. Bei falscher Antwort: Schwierigkeit −1 (min 1)
* 4. Pro Kategorie werden 2–3 Fragen gestellt (adaptiv)
* 5. Reihenfolge der Kategorien wird gemischt
* 6. Antwort-Optionen werden pro Frage gemischt
*
* @fileoverview
* @author Alexander Wolf
* @version 1.0
* @see docs/conventions/DATA_MODEL_CONVENTIONS.md §4
* @see docs/specifications/ONBOARDING_AND_CONTENT_ARCHITECTURE.md
*/
/** Konfiguration */
const CONFIG = {
/** Fragen pro Kategorie */
QUESTIONS_PER_CATEGORY: 2,
/** Bonus-Frage bei gemischten Ergebnissen */
MAX_QUESTIONS_PER_CATEGORY: 3,
/** Start-Schwierigkeit */
START_DIFFICULTY: 2,
/** Minimale Schwierigkeit */
MIN_DIFFICULTY: 1,
/** Maximale Schwierigkeit */
MAX_DIFFICULTY: 4,
/** Gesamte Testdauer in Minuten (geschätzt) */
ESTIMATED_MINUTES: 8
};
/**
* Adaptiver Assessment-Test.
*
* @example
* const engine = await AssessmentEngine.create();
*
* while (engine.hasNext()) {
* const q = engine.getNextQuestion();
* // ... question UI anzeigen ...
* const result = engine.submitAnswer(selectedIndex);
* // result.correct, result.explanation, result.funFact
* }
*
* const scores = engine.getResults();
* // { history: 0.75, math: 0.5, ... }
*/
class AssessmentEngine {
/**
* @param {Object} catalog — Geparste assessment-questions.json
*/
constructor(catalog) {
/** @type {Object} Rohdaten */
this._catalog = catalog;
/** @type {string[]} Gemischte Kategorie-Reihenfolge */
this._categoryOrder = AssessmentEngine._shuffle([...catalog.categories]);
/** @type {number} Aktuelle Kategorie (Index in _categoryOrder) */
this._categoryIndex = 0;
/** @type {number} Fragen-Zähler in aktueller Kategorie */
this._questionInCategory = 0;
/** @type {number} Aktuelle adaptive Schwierigkeit */
this._currentDifficulty = CONFIG.START_DIFFICULTY;
/** @type {Map<string, Object[]>} Kategorie → verfügbare Fragen (nach Difficulty gruppiert) */
this._pool = this._buildPool();
/** @type {Set<string>} Bereits gestellte Frage-IDs */
this._asked = new Set();
/** @type {Object|null} Aktuell angezeigte Frage (mit gemischten Optionen) */
this._currentQuestion = null;
/** @type {number[]} Aktuelle Mapping: shuffledIndex → originalIndex */
this._currentOptionMap = [];
/** @type {Object<string, Object>} Kategorie → Tracking-Daten */
this._scores = {};
for (const cat of catalog.categories) {
this._scores[cat] = {
correct: 0,
total: 0,
maxDifficultyReached: CONFIG.START_DIFFICULTY,
answers: []
};
}
/** @type {boolean} Ob der Test beendet ist */
this._finished = false;
/** @type {number} Gesamtanzahl gestellter Fragen */
this._totalAsked = 0;
}
// ── Factory ─────────────────────────────────────────────────
/**
* Erstellt eine neue Engine und lädt den Fragenkatalog.
*
* @param {string} [basePath=''] — Basis-Pfad für fetch
* @returns {Promise<AssessmentEngine>}
*/
static async create(basePath = '') {
const sep = basePath && !basePath.endsWith('/') ? '/' : '';
const url = `${basePath}${sep}config/assessment-questions.json`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const catalog = await response.json();
return new AssessmentEngine(catalog);
}
/**
* Erstellt eine Engine mit bereits geladenem Katalog.
* @param {Object} catalog — Geparste assessment-questions.json
* @returns {AssessmentEngine}
*/
static fromCatalog(catalog) {
return new AssessmentEngine(catalog);
}
// ── Navigation API ──────────────────────────────────────────
/**
* Gibt zurück, ob noch Fragen übrig sind.
* @returns {boolean}
*/
hasNext() {
if (this._finished) return false;
return this._categoryIndex < this._categoryOrder.length;
}
/**
* Gibt die nächste Frage zurück (mit gemischten Antwortoptionen).
*
* @returns {Object|null} Frage-Objekt mit:
* - id, question, options (gemischt!), category, difficulty
* - categoryLabel, progress { current, total, categoryProgress }
*/
getNextQuestion() {
if (!this.hasNext()) return null;
const category = this._categoryOrder[this._categoryIndex];
const question = this._pickQuestion(category, this._currentDifficulty);
if (!question) {
// Keine passenden Fragen mehr in dieser Kategorie → nächste
this._advanceCategory();
return this.getNextQuestion();
}
this._asked.add(question.id);
// Optionen mischen
const { shuffled, map } = AssessmentEngine._shuffleOptions(question.options);
this._currentOptionMap = map;
this._currentQuestion = {
id: question.id,
question: question.question,
options: shuffled,
category: question.category,
categoryLabel: this._catalog.categoryLabels?.[category] || category,
difficulty: question.difficulty,
image: question.image,
imageCredit: question.imageCredit,
progress: {
current: this._totalAsked + 1,
total: this._estimateTotal(),
categoryIndex: this._categoryIndex + 1,
categoryCount: this._categoryOrder.length,
questionInCategory: this._questionInCategory + 1
}
};
return this._currentQuestion;
}
/**
* Übermittelt die Antwort des Nutzers.
*
* @param {number} selectedIndex — 0-basierter Index der gewählten Option (im gemischten Array)
* @returns {Object} Ergebnis:
* - correct {boolean}
* - correctIndex {number} — Index der richtigen Antwort im gemischten Array
* - explanation {string}
* - funFact {string|null}
* - difficulty {number} — Schwierigkeit der beantworteten Frage
* - nextDifficulty {number} — Nächste adaptive Schwierigkeit
*/
submitAnswer(selectedIndex) {
if (!this._currentQuestion) {
throw new Error('Keine aktive Frage. Rufe erst getNextQuestion() auf.');
}
const question = this._findQuestionById(this._currentQuestion.id);
const category = question.category;
// Mapping: gemischter Index → Original-Index
const originalSelected = this._currentOptionMap[selectedIndex];
const correct = originalSelected === question.correctIndex;
// Korrekte Antwort im gemischten Array finden
const correctShuffledIndex = this._currentOptionMap.indexOf(question.correctIndex);
// Score tracken
this._scores[category].total++;
if (correct) this._scores[category].correct++;
this._scores[category].maxDifficultyReached = Math.max(
this._scores[category].maxDifficultyReached,
question.difficulty
);
this._scores[category].answers.push({
questionId: question.id,
difficulty: question.difficulty,
correct
});
this._totalAsked++;
this._questionInCategory++;
// Adaptive Schwierigkeit anpassen
const prevDifficulty = this._currentDifficulty;
if (correct) {
this._currentDifficulty = Math.min(CONFIG.MAX_DIFFICULTY, this._currentDifficulty + 1);
} else {
this._currentDifficulty = Math.max(CONFIG.MIN_DIFFICULTY, this._currentDifficulty - 1);
}
// Kategorie-Fortschritt prüfen
const minReached = this._questionInCategory >= CONFIG.QUESTIONS_PER_CATEGORY;
const maxReached = this._questionInCategory >= CONFIG.MAX_QUESTIONS_PER_CATEGORY;
const scoreData = this._scores[category];
const needsTiebreaker = minReached && !maxReached &&
scoreData.correct > 0 && scoreData.correct < scoreData.total;
if (maxReached || (minReached && !needsTiebreaker)) {
this._advanceCategory();
}
this._currentQuestion = null;
return {
correct,
correctIndex: correctShuffledIndex,
explanation: question.explanation,
funFact: question.funFact || null,
difficulty: question.difficulty,
nextDifficulty: this._currentDifficulty,
isLastInCategory: this._questionInCategory === 0 // wurde gerade reset
};
}
// ── Results API ─────────────────────────────────────────────
/**
* Gibt die Ergebnis-Scores pro Kategorie zurück (0.0–1.0).
*
* Score-Berechnung berücksichtigt nicht nur Korrektheit,
* sondern auch die erreichte Schwierigkeitsstufe:
* - Diff 1 richtig = 0.25
* - Diff 2 richtig = 0.50
* - Diff 3 richtig = 0.75
* - Diff 4 richtig = 1.00
* - Falsche Antworten = 0
*
* @returns {Object<string, number>} Kategorie → Score
*/
getResults() {
const results = {};
for (const [cat, data] of Object.entries(this._scores)) {
if (data.total === 0) {
results[cat] = 0;
continue;
}
// Gewichteter Score: korrekte Antworten × Difficulty-Weight
let weightedSum = 0;
let maxPossible = 0;
for (const answer of data.answers) {
const weight = answer.difficulty * 0.25;
maxPossible += weight;
if (answer.correct) {
weightedSum += weight;
}
}
results[cat] = maxPossible > 0
? Math.round((weightedSum / maxPossible) * 100) / 100
: 0;
}
return results;
}
/**
* Berechnet das Gesamtlevel basierend auf den Scores.
* @returns {'beginner'|'intermediate'|'advanced'}
*/
getLevel() {
const scores = this.getResults();
const values = Object.values(scores);
if (values.length === 0) return 'beginner';
const avg = values.reduce((s, v) => s + v, 0) / values.length;
if (avg >= 0.7) return 'advanced';
if (avg >= 0.4) return 'intermediate';
return 'beginner';
}
/**
* Gibt detaillierte Statistiken zurück.
* @returns {Object}
*/
getDetailedStats() {
return {
totalQuestions: this._totalAsked,
scores: this.getResults(),
level: this.getLevel(),
perCategory: { ...this._scores },
estimatedMinutes: CONFIG.ESTIMATED_MINUTES
};
}
/**
* Gibt empfohlene Themen zurück (basierend auf guten Scores).
* @returns {string[]} Topic-Tags für die Content-Registry
*/
getRecommendedTopics() {
const scores = this.getResults();
const topics = [];
// Mapping: Assessment-Kategorien → Content-Registry Topics
const TOPIC_MAP = {
'history': ['history'],
'current-ai': ['current-ai', 'ethics'],
'general-ai': ['neural-networks', 'rule-systems'],
'math': ['math-foundations', 'neural-networks'],
'cs-practice': ['search-trees', 'game-theory', 'heuristics']
};
for (const [cat, score] of Object.entries(scores)) {
if (score >= 0.4 && TOPIC_MAP[cat]) {
topics.push(...TOPIC_MAP[cat]);
}
}
// Deduplizieren
return [...new Set(topics)];
}
/**
* Gibt Kategorien zurück, in denen der Nutzer schwach ist.
* @returns {string[]} Kategorie-Keys mit Score < 0.4
*/
getWeakCategories() {
const scores = this.getResults();
return Object.entries(scores)
.filter(([, score]) => score < 0.4)
.map(([cat]) => cat);
}
// ── Progress API ────────────────────────────────────────────
/**
* Geschätzte Gesamtanzahl der Fragen.
* @returns {number}
*/
getTotalEstimate() {
return this._estimateTotal();
}
/**
* Aktueller Fortschritt als Prozentwert.
* @returns {number} 0–100
*/
getProgressPercent() {
const total = this._estimateTotal();
return total > 0 ? Math.round((this._totalAsked / total) * 100) : 0;
}
/**
* Ob der Test abgeschlossen ist.
* @returns {boolean}
*/
isFinished() {
return this._finished || !this.hasNext();
}
// ── Private ─────────────────────────────────────────────────
/**
* Baut den Fragen-Pool pro Kategorie × Difficulty.
* @returns {Map<string, Map<number, Object[]>>}
* @private
*/
_buildPool() {
const pool = new Map();
for (const cat of this._catalog.categories) {
const byDiff = new Map();
for (let d = 1; d <= 4; d++) {
byDiff.set(d, this._catalog.questions.filter(
q => q.category === cat && q.difficulty === d
));
}
pool.set(cat, byDiff);
}
return pool;
}
/**
* Wählt die beste verfügbare Frage für Kategorie + Difficulty.
* Fällt auf benachbarte Schwierigkeit zurück, falls nötig.
* @param {string} category
* @param {number} targetDifficulty
* @returns {Object|null}
* @private
*/
_pickQuestion(category, targetDifficulty) {
const catPool = this._pool.get(category);
if (!catPool) return null;
// Erst genau die Ziel-Schwierigkeit versuchen
const candidates = this._getAvailable(catPool, targetDifficulty);
if (candidates.length > 0) {
return candidates[Math.floor(Math.random() * candidates.length)];
}
// Fallback: benachbarte Schwierigkeit (+1, -1, +2, -2)
for (const offset of [1, -1, 2, -2]) {
const alt = targetDifficulty + offset;
if (alt < CONFIG.MIN_DIFFICULTY || alt > CONFIG.MAX_DIFFICULTY) continue;
const altCandidates = this._getAvailable(catPool, alt);
if (altCandidates.length > 0) {
return altCandidates[Math.floor(Math.random() * altCandidates.length)];
}
}
return null;
}
/**
* Gibt verfügbare (noch nicht gestellte) Fragen für eine Difficulty.
* @param {Map<number, Object[]>} catPool
* @param {number} difficulty
* @returns {Object[]}
* @private
*/
_getAvailable(catPool, difficulty) {
const questions = catPool.get(difficulty) || [];
return questions.filter(q => !this._asked.has(q.id));
}
/**
* Geht zur nächsten Kategorie über.
* @private
*/
_advanceCategory() {
this._categoryIndex++;
this._questionInCategory = 0;
if (this._categoryIndex >= this._categoryOrder.length) {
this._finished = true;
}
}
/**
* Schätzt die Gesamtanzahl der Fragen.
* @returns {number}
* @private
*/
_estimateTotal() {
return this._categoryOrder.length * CONFIG.QUESTIONS_PER_CATEGORY;
}
/**
* Findet eine Frage im Katalog anhand der ID.
* @param {string} id
* @returns {Object}
* @private
*/
_findQuestionById(id) {
return this._catalog.questions.find(q => q.id === id);
}
// ── Static Helpers ──────────────────────────────────────────
/**
* Fisher-Yates Shuffle.
* @param {Array} arr
* @returns {Array} Neues gemischtes Array
* @private
*/
static _shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/**
* Mischt Antwortoptionen und gibt das Mapping zurück.
* @param {string[]} options
* @returns {{ shuffled: string[], map: number[] }}
* map[newIndex] = originalIndex
* @private
*/
static _shuffleOptions(options) {
const indices = options.map((_, i) => i);
const shuffledIndices = AssessmentEngine._shuffle(indices);
return {
shuffled: shuffledIndices.map(i => options[i]),
map: shuffledIndices
};
}
}
// Global Export (analog zu iframe-bridge.js)
if (typeof window !== 'undefined') {
window.AssessmentEngine = AssessmentEngine;
window.ASSESSMENT_CONFIG = CONFIG;
}