/**
* Recommendation-Engine — Personalisierte Inhaltsempfehlungen.
*
* Orchestriert UserProfile (Assessment-Ergebnisse, Fortschritt)
* und ContentRegistry (Inhalte, Beziehungen) zu kontextbewussten
* Empfehlungen für den jeweiligen Nutzer.
*
* @fileoverview
* @author Alexander Wolf
* @version 1.0
* @see docs/specifications/ONBOARDING_AND_CONTENT_ARCHITECTURE.md §3
*/
// ContentRegistry und UserProfile werden als globale Window-Exporte geladen
// (per <script src="..."> in HTML, analog zu iframe-bridge.js).
/**
* Mapping: Assessment-Kategorie → Content-Registry Topics.
* Schwache Kategorien werden auf inhaltlich passende Ressourcen abgebildet.
*
* @type {Object<string, string[]>}
*/
const CATEGORY_TOPIC_MAP = {
'history': ['ai-history', 'general-ai'],
'current-ai': ['general-ai', 'neural-networks', 'machine-learning'],
'general-ai': ['general-ai', 'agent-types', 'rule-based-ai'],
'math': ['probability', 'graph-theory', 'neural-networks', 'machine-learning'],
'cs-practice': ['search-trees', 'algorithms', 'minimax', 'heuristics', 'data-structures']
};
/**
* Empfehlungs-Typen für die UI.
*
* @enum {string}
*/
const RECOMMENDATION_TYPE = {
/** Basierend auf schwachen Assessment-Kategorien */
WEAKNESS: 'weakness',
/** Nächster logischer Schritt nach Abschlüssen */
NEXT_STEP: 'next-step',
/** Passend zu Interessen */
INTEREST: 'interest',
/** Allgemeine Einsteigerempfehlung */
STARTER: 'starter'
};
/**
* Erstellt personalisierte Empfehlungen.
*
* @example
* import { RecommendationEngine } from './js/core/recommendation-engine.js';
*
* const engine = await RecommendationEngine.create('../../');
* const recs = engine.getRecommendations();
* // → [{ resource, type, reason, priority }, ...]
*/
class RecommendationEngine {
/**
* @param {ContentRegistry} registry
* @param {UserProfile} profile
*/
constructor(registry, profile) {
/** @type {ContentRegistry} */
this._registry = registry;
/** @type {UserProfile} */
this._profile = profile;
}
/**
* Erstellt eine Engine-Instanz (async Factory).
*
* @param {string} [basePath=''] - Basis-Pfad für ContentRegistry-Fetch
* @returns {Promise<RecommendationEngine>}
*/
static async create(basePath = '') {
const registry = await ContentRegistry.load(basePath);
const profile = UserProfile.load();
return new RecommendationEngine(registry, profile);
}
// ── Public API ──────────────────────────────────────────────
/**
* Gibt priorisierte Empfehlungen zurück.
*
* Strategie:
* 1. Schwache Assessment-Kategorien → passende Ressourcen
* 2. Nächste logische Schritte (Prerequisites erfüllt)
* 3. Interessen des Nutzers
* 4. Starter-Empfehlungen für neue Nutzer
*
* @param {Object} [options]
* @param {number} [options.maxResults=6] - Maximale Anzahl
* @param {string[]} [options.dismissed=[]] - Ausgeblendete IDs
* @returns {Array<{resource: Object, type: string, reason: string, priority: number}>}
*/
getRecommendations({ maxResults = 6, dismissed = [] } = {}) {
const completedIds = this._profile.completedResources || [];
const interests = this._profile.interests || [];
const level = this._profile.level || 'beginner';
const scores = this._profile.assessmentScores || {};
const dismissedSet = new Set(dismissed);
const usedIds = new Set();
/** @type {Array<{resource: Object, type: string, reason: string, priority: number}>} */
const recommendations = [];
// ── 1. Schwächen-basierte Empfehlungen ──────────────────
const weakCategories = this._getWeakCategories(scores);
for (const cat of weakCategories) {
const topics = CATEGORY_TOPIC_MAP[cat] || [];
for (const topic of topics) {
const resources = this._registry.getByTopic(topic);
for (const r of resources) {
if (this._isCandidate(r, completedIds, dismissedSet, usedIds, level)) {
recommendations.push({
resource: r,
type: RECOMMENDATION_TYPE.WEAKNESS,
reason: `Stärke dein Wissen in "${this._getCategoryLabel(cat)}"`,
priority: 100 - (scores[cat] || 0) * 100
});
usedIds.add(r.id);
break; // Max 1 pro Topic
}
}
}
}
// ── 2. Nächste logische Schritte ────────────────────────
const completedSet = new Set(completedIds);
for (const r of this._registry.resources) {
if (!this._isCandidate(r, completedIds, dismissedSet, usedIds, level)) continue;
if (r.prerequisites.length === 0) continue;
const allMet = r.prerequisites.every(id => completedSet.has(id));
if (allMet) {
recommendations.push({
resource: r,
type: RECOMMENDATION_TYPE.NEXT_STEP,
reason: 'Nächster logischer Schritt',
priority: 80
});
usedIds.add(r.id);
}
}
// ── 3. Interessen-basierte Empfehlungen ─────────────────
for (const topic of interests) {
const resources = this._registry.getByTopic(topic);
for (const r of resources) {
if (this._isCandidate(r, completedIds, dismissedSet, usedIds, level)) {
recommendations.push({
resource: r,
type: RECOMMENDATION_TYPE.INTEREST,
reason: `Passt zu deinem Interesse: ${topic}`,
priority: 60
});
usedIds.add(r.id);
break;
}
}
}
// ── 4. Starter-Empfehlungen ─────────────────────────────
if (recommendations.length < maxResults && completedIds.length < 3) {
const starters = this._registry.resources
.filter(r => this._isCandidate(r, completedIds, dismissedSet, usedIds, level))
.filter(r => r.difficulty <= 2 && r.prerequisites.length === 0)
.sort((a, b) => a.difficulty - b.difficulty);
for (const r of starters) {
if (recommendations.length >= maxResults) break;
recommendations.push({
resource: r,
type: RECOMMENDATION_TYPE.STARTER,
reason: 'Guter Einstiegspunkt',
priority: 40
});
usedIds.add(r.id);
}
}
// Sortieren nach Priorität, limitieren
return recommendations
.sort((a, b) => b.priority - a.priority)
.slice(0, maxResults);
}
/**
* Gibt den empfohlenen nächsten Inhalt zurück (Top-1).
* @returns {{resource: Object, type: string, reason: string}|null}
*/
getNextRecommendation() {
const recs = this.getRecommendations({ maxResults: 1 });
return recs[0] || null;
}
/**
* Gibt Empfehlungen als gruppierte Sektionen zurück (für UI-Rendering).
*
* @returns {Array<{title: string, type: string, items: Object[]}>}
*/
getGroupedRecommendations() {
const all = this.getRecommendations({ maxResults: 12 });
const groups = new Map();
const groupTitles = {
[RECOMMENDATION_TYPE.WEAKNESS]: 'Empfohlen für dich',
[RECOMMENDATION_TYPE.NEXT_STEP]: 'Nächste Schritte',
[RECOMMENDATION_TYPE.INTEREST]: 'Basierend auf deinen Interessen',
[RECOMMENDATION_TYPE.STARTER]: 'Gute Einstiegspunkte'
};
for (const rec of all) {
if (!groups.has(rec.type)) {
groups.set(rec.type, {
title: groupTitles[rec.type] || rec.type,
type: rec.type,
items: []
});
}
groups.get(rec.type).items.push(rec);
}
return [...groups.values()];
}
// ── Private ─────────────────────────────────────────────────
/**
* Gibt Assessment-Kategorien zurück, in denen der Nutzer schwach ist.
* @param {Object<string, number>} scores - Kategorie → Score (0.0 – 1.0)
* @returns {string[]} Schwache Kategorien, sortiert (schwächste zuerst)
* @private
*/
_getWeakCategories(scores) {
if (!scores || Object.keys(scores).length === 0) return [];
return Object.entries(scores)
.filter(([, score]) => score < 0.5)
.sort(([, a], [, b]) => a - b)
.map(([cat]) => cat);
}
/**
* Prüft, ob eine Ressource als Empfehlung in Frage kommt.
* @param {Object} resource
* @param {string[]} completedIds
* @param {Set<string>} dismissedSet
* @param {Set<string>} usedIds - Bereits empfohlene IDs (Duplikatvermeidung)
* @param {string} level
* @returns {boolean}
* @private
*/
_isCandidate(resource, completedIds, dismissedSet, usedIds, level) {
if (completedIds.includes(resource.id)) return false;
if (dismissedSet.has(resource.id)) return false;
if (usedIds.has(resource.id)) return false;
// Level-Filter: Beginner nicht mit Difficulty 4/5 überfordern
const maxDifficulty = { beginner: 3, intermediate: 4, advanced: 5 };
const max = maxDifficulty[level] || 5;
if (resource.difficulty > max) return false;
return true;
}
/**
* Gibt den deutschen Label-Text für eine Assessment-Kategorie zurück.
* @param {string} category
* @returns {string}
* @private
*/
_getCategoryLabel(category) {
const labels = {
'history': 'Geschichte der KI',
'current-ai': 'Aktuelle KI',
'general-ai': 'Allgemeinwissen KI',
'math': 'Mathematik',
'cs-practice': 'Informatik & Algorithmik'
};
return labels[category] || category;
}
}
// Global Export (analog zu iframe-bridge.js)
if (typeof window !== 'undefined') {
window.RecommendationEngine = RecommendationEngine;
window.RECOMMENDATION_TYPE = RECOMMENDATION_TYPE;
}