Files
yourpart3/backend/services/vocabService.js
Torsten Schulz (local) bc8d63058a
All checks were successful
Deploy to production / deploy (push) Successful in 2m10s
feat: implement neue SRS-Logik zur Berechnung von Intervallen und füge Diagnoseskript für fällige Items hinzu
2026-06-03 17:13:50 +02:00

4598 lines
154 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.
import crypto from 'crypto';
import User from '../models/community/user.js';
import VocabCourse from '../models/community/vocab_course.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js';
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
import VocabSrsItem from '../models/community/vocab_srs_item.js';
import UserParamType from '../models/type/user_param.js';
import UserParam from '../models/community/user_param.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize';
import { BISAYA_PHASE1_DIDACTICS, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js';
export default class VocabService {
_normalizeSrsText(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
_buildSrsItemKey({ courseId, lessonId = null, learning, reference, direction = 'BOTH' }) {
const raw = [
Number(courseId) || 0,
lessonId == null ? 'course' : Number(lessonId) || 0,
String(direction || 'BOTH').toUpperCase(),
this._normalizeSrsText(learning),
this._normalizeSrsText(reference)
].join('|');
return crypto.createHash('sha1').update(raw).digest('hex');
}
_decorateSrsVocabs(vocabs = [], { courseId, lessonId = null } = {}) {
return (Array.isArray(vocabs) ? vocabs : [])
.map((entry) => {
const learning = String(entry?.learning || '').trim();
const reference = String(entry?.reference || '').trim();
if (!this._isTrainableSrsPair({ learning, reference })) {
return null;
}
const direction = String(entry?.direction || 'BOTH').toUpperCase();
const itemKey = this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction });
return {
...entry,
id: entry?.id || itemKey,
itemKey,
courseId: Number(courseId) || null,
lessonId: lessonId == null ? null : Number(lessonId),
learning,
reference,
direction
};
})
.filter(Boolean);
}
_isInstructionLikeText(value) {
const text = String(value || '').trim();
if (!text) {
return false;
}
const wordCount = text.split(/\s+/).filter(Boolean).length;
if (wordCount < 3) {
return false;
}
const normalized = text.toLowerCase().normalize('NFKC');
const startsWithTaskVerb = /^(sage|sag|frage|frag|bitte|stelle|sprich|erzähle|erzaehle|beschreibe|bilde|wähle|waehle|ordne|übersetze|uebersetze|nenne|nenn|beginne|verwende|nutze|reagiere|kombiniere|spiele|löse|loese|beantworte|ergänze|ergaenze|formuliere|lies|entscheide|zeige)\b/i.test(normalized);
const startsWithTakeTask = /^nimm\b/i.test(normalized)
&& (
/\b(ein|eine|einen|zwei|drei|vier|fünf|fuenf|sechs|sieben|acht|neun|zehn|\d+)\b/i.test(normalized)
|| /\b(w[oö]rter|verben|gegenstände|gegenstaende|sätze|saetze|muster|beispiele)\b/i.test(normalized)
);
const containsTaskChain = /\b(und|,)\s*(sage|sag|frage|frag|bitte|stelle|sprich|erzähle|erzaehle|beschreibe|bilde|wähle|waehle|ordne|übersetze|uebersetze|nenne|nenn|verwende|nutze|reagiere|kombiniere|spiele|löse|loese|beantworte|ergänze|ergaenze|formuliere|lies|entscheide|zeige)\b/i.test(normalized);
const containsPracticeMarker = /\b(laut|jeweils|zu jedem|zu jeder|umgebung|alltagsszene|rollenspiel|mini-dialog|szene)\b/i.test(normalized);
return startsWithTaskVerb || startsWithTakeTask || (containsTaskChain && containsPracticeMarker);
}
_isTrainableSrsPair(entry) {
const learning = String(entry?.learning || '').trim();
const reference = String(entry?.reference || '').trim();
if (!learning || !reference || this._normalizeSrsText(learning) === this._normalizeSrsText(reference)) {
return false;
}
if (this._looksLikeFragmentMismatch(learning, reference)) {
return false;
}
return !this._isInstructionLikeText(learning) && !this._isInstructionLikeText(reference);
}
_wordCount(value) {
return String(value || '')
.trim()
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.length;
}
_looksLikeFragmentMismatch(left, right) {
const leftWords = this._wordCount(left);
const rightWords = this._wordCount(right);
const leftText = String(left || '').trim();
const rightText = String(right || '').trim();
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
}
_calculateSrsSchedule(item, { correct, rating = null } = {}) {
const now = new Date();
const previousStage = Math.max(0, Number(item?.stage) || 0);
const previousInterval = Math.max(0, Number(item?.intervalDays) || 0);
const normalizedRating = String(rating || '').toLowerCase();
const isCorrect = Boolean(correct) && normalizedRating !== 'again';
if (!isCorrect) {
return {
stage: Math.max(0, previousStage - 1),
intervalDays: 0,
nextDueAt: new Date(now.getTime() + 10 * 60 * 1000),
lapseDelta: 1
};
}
// Neue einfache Policy:
// - 'easy' -> 7 Tage
// - 'good'/'normal' -> 4 Tage
// - 'hard' -> 1 Tag
// Außerdem: nextDueAt darf nicht mehr am gleichen Kalendertag liegen.
let intervalDays;
if (normalizedRating === 'easy') {
intervalDays = 7;
} else if (normalizedRating === 'hard') {
intervalDays = 1;
} else {
// default / 'good' / unspecified
intervalDays = 4;
}
// Bestimme nextDueAt als Start des Tages (00:00) nach intervalDays
const nextDueAt = new Date(now);
// Setze auf Mitternacht heute
nextDueAt.setHours(0, 0, 0, 0);
// Gehe vorwärts: morgen + (intervalDays - 1)
nextDueAt.setDate(nextDueAt.getDate() + 1 + Math.max(0, intervalDays - 1));
// Stage-Logik: einfache Fortschrittsstufe basierend auf intervalDays
let nextStage = Math.min(8, Math.max(0, Math.floor(Math.log2(intervalDays + 1))));
return {
stage: nextStage,
intervalDays,
nextDueAt,
lapseDelta: 0
};
}
async _ensureSrsItems(userId, { courseId, lessonId = null, vocabs = [] } = {}) {
const decorated = this._decorateSrsVocabs(vocabs, { courseId, lessonId });
if (!decorated.length) {
return [];
}
const existing = await VocabSrsItem.findAll({
where: {
userId,
itemKey: {
[Op.in]: decorated.map((entry) => entry.itemKey)
}
}
});
const existingByKey = new Map(existing.map((entry) => [entry.itemKey, entry]));
const now = new Date();
const createdItems = [];
for (const entry of decorated) {
if (existingByKey.has(entry.itemKey)) {
continue;
}
const created = await VocabSrsItem.create({
userId,
courseId: Number(courseId),
lessonId: entry.lessonId,
itemKey: entry.itemKey,
learning: entry.learning,
reference: entry.reference,
direction: entry.direction,
nextDueAt: now
});
createdItems.push(created);
existingByKey.set(entry.itemKey, created);
}
return decorated.map((entry) => {
const item = existingByKey.get(entry.itemKey);
return {
...entry,
srs: item ? {
stage: item.stage,
intervalDays: item.intervalDays,
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount,
isNew: createdItems.some((created) => created.itemKey === entry.itemKey)
} : null
};
});
}
_normalizeIsoDate(value) {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toISOString();
}
_clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(numeric)));
}
/**
* Wörterbuch-API: page ab 1, pageSize max. 100, Standard 25.
*/
_parseDictionaryPaging(query = {}) {
const page = Math.max(1, this._clampInteger(query?.page, { min: 1, max: 1_000_000, fallback: 1 }));
const pageSize = this._clampInteger(query?.pageSize, { min: 1, max: 100, fallback: 25 });
return { page, pageSize };
}
_sanitizeShortString(value, maxLength = 400) {
const text = String(value ?? '').trim();
if (!text) {
return '';
}
return text.slice(0, maxLength);
}
_sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) {
if (!Array.isArray(value)) {
return [];
}
return value
.slice(0, maxItems)
.map((entry) => this._sanitizeShortString(entry, maxLength))
.filter((entry) => keepEmpty || Boolean(entry));
}
_sanitizeExerciseAnswers(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
const synMcId = /^syn-\d+-\d+-l2r$/;
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
const idStr = String(exerciseId);
if (!/^\d+$/.test(idStr) && !synMcId.test(idStr)) {
return;
}
if (Array.isArray(answer)) {
sanitized[idStr] = this._sanitizeStringArray(answer, {
maxItems: 12,
maxLength: 200,
keepEmpty: true
});
return;
}
if (typeof answer === 'string') {
sanitized[idStr] = this._sanitizeShortString(answer, 200);
return;
}
if (typeof answer === 'number' && Number.isFinite(answer)) {
sanitized[idStr] = Math.trunc(answer);
}
});
return sanitized;
}
_sanitizeExerciseResults(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const synMcId = /^syn-\d+-\d+-l2r$/;
const sanitized = {};
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
const idStr = String(exerciseId);
if ((!/^\d+$/.test(idStr) && !synMcId.test(idStr)) || !result || typeof result !== 'object' || Array.isArray(result)) {
return;
}
sanitized[idStr] = {
correct: Boolean(result.correct),
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
explanation: this._sanitizeShortString(result.explanation, 1200)
};
});
return sanitized;
}
_sanitizeVocabTrainerStats(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
Object.entries(value).slice(0, 400).forEach(([key, stats]) => {
const safeKey = this._sanitizeShortString(key, 200);
if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) {
return;
}
sanitized[safeKey] = {
attempts: this._clampInteger(stats.attempts, { max: 5000 }),
correct: this._clampInteger(stats.correct, { max: 5000 }),
wrong: this._clampInteger(stats.wrong, { max: 5000 })
};
});
return sanitized;
}
_sanitizeRepeatQueue(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.slice(0, 100)
.map((entry) => ({
key: this._sanitizeShortString(entry?.key, 200),
dueAfter: this._clampInteger(entry?.dueAfter, { min: 0, max: 50 }),
stageIndex: this._clampInteger(entry?.stageIndex, { min: 0, max: 10 })
}))
.filter((entry) => entry.key);
}
_sanitizeLessonState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const knownKeys = [
'version',
'updatedAt',
'activeTab',
'exercisePreparationCompleted',
'lessonPrepStage',
'lessonPrepIndex',
'vocabTrainerActive',
'vocabTrainerMode',
'vocabTrainerAutoSwitchedToTyping',
'vocabTrainerCorrect',
'vocabTrainerWrong',
'vocabTrainerTotalAttempts',
'vocabTrainerCurrentAttempts',
'vocabTrainerReviewAttempts',
'vocabTrainerStats',
'vocabTrainerRepeatQueue',
'exerciseAnswers',
'exerciseResults',
'exerciseRetryPending',
'exerciseRetryPendingSinceAttempts',
'reviewStage',
'reviewNextDueAt',
'reviewLastReviewedAt'
];
const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
if (!hasKnownState) {
return {};
}
const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn';
const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice';
return {
version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }),
updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64),
activeTab,
exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted),
lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }),
lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }),
vocabTrainerActive: Boolean(value.vocabTrainerActive),
vocabTrainerMode,
vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping),
vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }),
vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }),
vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }),
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue),
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
exerciseRetryPending: Boolean(value.exerciseRetryPending),
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }),
reviewStage: this._clampInteger(value.reviewStage, { min: 0, max: 3 }),
reviewNextDueAt: this._normalizeIsoDate(value.reviewNextDueAt),
reviewLastReviewedAt: this._normalizeIsoDate(value.reviewLastReviewedAt)
};
}
_supportsScheduledReview(lessonData = null) {
const lessonType = String(lessonData?.lessonType || '').toLowerCase();
const didacticMode = String(lessonData?.didacticMode || '').toLowerCase();
if (lessonType === 'culture' || lessonType === 'review' || lessonType === 'vocab_review' || lessonType === 'weekly_review') {
return false;
}
if (didacticMode === 'intensive_review' || didacticMode === 'checkpoint') {
return false;
}
return true;
}
_removeManagedReviewState(lessonState = {}) {
const nextState = { ...(lessonState || {}) };
delete nextState.reviewStage;
delete nextState.reviewNextDueAt;
delete nextState.reviewLastReviewedAt;
return nextState;
}
_applyScheduledReviewState(lessonState = {}, {
previousCompleted = false,
nextCompleted = false,
shouldAdvanceReview = false,
lessonData = null,
now = new Date()
} = {}) {
const baseState = this._sanitizeLessonState(lessonState);
if (!this._supportsScheduledReview(lessonData) || !nextCompleted) {
return this._removeManagedReviewState(baseState);
}
const reviewIntervalsDays = [1, 3, 7];
const currentStage = this._clampInteger(baseState.reviewStage, { min: 0, max: reviewIntervalsDays.length });
const dueAtIso = this._normalizeIsoDate(baseState.reviewNextDueAt);
const dueAt = dueAtIso ? new Date(dueAtIso) : null;
const reviewLastReviewedAt = this._normalizeIsoDate(baseState.reviewLastReviewedAt);
const nowIso = this._normalizeIsoDate(now);
const nextState = {
...baseState,
reviewStage: currentStage,
reviewNextDueAt: dueAtIso,
reviewLastReviewedAt
};
if (!previousCompleted && shouldAdvanceReview) {
nextState.reviewStage = 0;
nextState.reviewLastReviewedAt = nowIso;
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[0] * 24 * 60 * 60 * 1000));
return nextState;
}
if (!dueAtIso && currentStage < reviewIntervalsDays.length) {
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[currentStage] * 24 * 60 * 60 * 1000));
}
if (!shouldAdvanceReview || !dueAt || Number.isNaN(dueAt.getTime()) || now.getTime() < dueAt.getTime()) {
return nextState;
}
const nextStage = Math.min(reviewIntervalsDays.length, currentStage + 1);
nextState.reviewStage = nextStage;
nextState.reviewLastReviewedAt = nowIso;
nextState.reviewNextDueAt = nextStage >= reviewIntervalsDays.length
? ''
: this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[nextStage] * 24 * 60 * 60 * 1000));
return nextState;
}
_serializeLessonProgress(progress, lessonData = null, options = {}) {
if (!progress) {
return null;
}
const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress };
const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80;
const hasReachedTarget = (plainProgress.score || 0) >= targetScore;
const lessonState = this._sanitizeLessonState(plainProgress.lessonState);
const reviewStage = this._clampInteger(lessonState.reviewStage, { min: 0, max: 3 });
const reviewNextDueAt = this._normalizeIsoDate(lessonState.reviewNextDueAt);
const suppressLessonReviewDue = Boolean(options.suppressLessonReviewDue);
const reviewDue = !suppressLessonReviewDue && Boolean(reviewNextDueAt && reviewStage < 3 && new Date(reviewNextDueAt).getTime() <= Date.now());
return {
...plainProgress,
lessonId: Number(plainProgress.lessonId),
lessonNumber: lessonData?.lessonNumber ?? plainProgress.lesson?.lessonNumber ?? null,
lessonState,
targetScore,
hasReachedTarget,
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget),
reviewStage,
reviewNextDueAt,
reviewDue,
reviewCompleted: reviewStage >= 3,
reviewSuppressedBySrs: suppressLessonReviewDue
};
}
async _courseHasDueSrsItems(userId, courseId) {
const dueItems = await VocabSrsItem.findAll({
where: {
userId,
courseId: Number(courseId),
nextDueAt: {
[Op.lte]: new Date()
}
},
attributes: ['learning', 'reference']
});
return dueItems.some((item) => this._isTrainableSrsPair(item));
}
async _getUserByHashedId(hashedUserId) {
const user = await User.findOne({ where: { hashedId: hashedUserId } });
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
return user;
}
async _attachLanguageNamesToCourseRows(coursesData) {
if (!coursesData.length) {
return;
}
const languageIds = [...new Set(coursesData.map((c) => c.languageId))];
if (languageIds.length > 0) {
const languages = await sequelize.query(
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
{
replacements: { languageIds },
type: sequelize.QueryTypes.SELECT
}
);
if (Array.isArray(languages)) {
const languageMap = new Map(languages.map((l) => [l.id, l.name]));
coursesData.forEach((c) => {
c.languageName = languageMap.get(c.languageId) || null;
});
}
}
const nativeLanguageIds = [...new Set(coursesData.map((c) => c.nativeLanguageId).filter((id) => id !== null))];
if (nativeLanguageIds.length > 0) {
const nativeLanguages = await sequelize.query(
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
{
replacements: { nativeLanguageIds },
type: sequelize.QueryTypes.SELECT
}
);
if (Array.isArray(nativeLanguages)) {
const nativeLanguageMap = new Map(nativeLanguages.map((l) => [l.id, l.name]));
coursesData.forEach((c) => {
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null;
});
}
}
}
async _getUserLlmConfig(userId) {
const [settingsType, apiKeyType] = await Promise.all([
UserParamType.findOne({ where: { description: 'llm_settings' } }),
UserParamType.findOne({ where: { description: 'llm_api_key' } })
]);
if (!settingsType || !apiKeyType) {
return {
enabled: false,
baseUrl: '',
model: 'gpt-4o-mini',
hasKey: false,
apiKey: null,
configured: false
};
}
const [settingsRow, keyRow] = await Promise.all([
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
]);
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null;
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
const enabled = parsed.enabled !== false;
const baseUrl = String(parsed.baseUrl || '').trim();
return {
enabled,
baseUrl,
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
hasKey,
apiKey: hasKey ? decryptedKey : null,
configured: enabled && (hasKey || Boolean(baseUrl))
};
}
_sanitizeAssistantHistory(history) {
if (!Array.isArray(history)) {
return [];
}
return history
.slice(-8)
.map((entry) => ({
role: entry?.role === 'assistant' ? 'assistant' : 'user',
content: String(entry?.content || '').trim()
}))
.filter((entry) => entry.content);
}
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
const didactics = lesson?.didactics || {};
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
const modeDirectives = {
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
};
return [
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
modeDirectives[mode] || modeDirectives.practice,
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
corePatterns.length
? `Kernmuster: ${corePatterns.map((p) => {
const n = this._normalizeCorePatternEntry(p);
if (!n) return '';
return n.gloss ? `${n.target} (${n.gloss})` : n.target;
}).filter(Boolean).join(' | ')}`
: '',
speakingPrompts.length
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
: '',
practicalTasks.length
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
: '',
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
].filter(Boolean).join('\n');
}
_extractAssistantContent(responseData) {
const rawContent = responseData?.choices?.[0]?.message?.content;
if (typeof rawContent === 'string') {
return rawContent.trim();
}
if (Array.isArray(rawContent)) {
return rawContent
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text || '';
return '';
})
.join('\n')
.trim();
}
return '';
}
_normalizeLexeme(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
_normalizeTextAnswer(text) {
const normalized = String(text || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized.replace(/\s+/g, '');
}
_expandComparableAnswerVariants(text) {
const raw = String(text || '').trim();
if (!raw) return [];
const variants = new Set([raw]);
// Beispiel: "heute (heute am Tag)" -> "heute" und "heute am Tag"
const withoutParentheses = raw.replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+/g, ' ').trim();
if (withoutParentheses) variants.add(withoutParentheses);
const parenMatches = raw.match(/\(([^)]+)\)/g) || [];
parenMatches.forEach((chunk) => {
const content = chunk.replace(/[()]/g, '').trim();
if (content) variants.add(content);
});
// Splitte auf Schrägstriche, Semikolons oder Pipes, damit z.B. "A / B" als "A" und "B" gilt
const slashParts = raw.split(/\s*[\/|;]\s*/);
if (slashParts.length > 1) {
slashParts.forEach((p) => {
const pp = String(p || '').trim();
if (pp) variants.add(pp);
});
}
// Auch Varianten trennen, die mit '/' in der ohneParentheses-Version vorkommen
if (withoutParentheses) {
const parts2 = withoutParentheses.split(/\s*[\/|;]\s*/);
if (parts2.length > 1) {
parts2.forEach((p) => {
const pp = String(p || '').trim();
if (pp) variants.add(pp);
});
}
}
return Array.from(variants);
}
_isEquivalentAnswer(userAnswer, canonicalAnswer) {
const normalizedUser = this._normalizeTextAnswer(userAnswer);
if (!normalizedUser) return false;
const canonicalVariants = this._expandComparableAnswerVariants(canonicalAnswer)
.map((entry) => this._normalizeTextAnswer(entry))
.filter(Boolean);
return canonicalVariants.includes(normalizedUser);
}
_parseExercisePayload(value) {
if (!value) return {};
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return {};
}
}
if (typeof value === 'object') {
return value;
}
return {};
}
_extractTrainerVocabsFromExercises(exercises = [], options = {}) {
const { allowGapFill = true } = options || {};
const vocabMap = new Map();
exercises.forEach((exercise) => {
try {
const qData = this._parseExercisePayload(exercise.questionData);
const aData = this._parseExercisePayload(exercise.answerData);
const exerciseType = exercise.exerciseType?.name || qData.type || '';
if (exerciseType === 'multiple_choice') {
const options = Array.isArray(qData.options) ? qData.options : [];
const correctAnswer = Array.isArray(aData.correctAnswer)
? options[aData.correctAnswer[0]]
: options[aData.correctAnswer ?? aData.correct ?? 0];
const question = String(qData.question || qData.text || '');
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i);
if (match && this._isTrainableSrsPair({ learning: match[1], reference: String(correctAnswer) })) {
vocabMap.set(`${match[1]}-${correctAnswer}`, {
learning: match[1],
reference: String(correctAnswer)
});
return;
}
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i);
if (match && this._isTrainableSrsPair({ learning: String(correctAnswer), reference: match[1] })) {
vocabMap.set(`${correctAnswer}-${match[1]}`, {
learning: String(correctAnswer),
reference: match[1]
});
}
return;
}
if (exerciseType === 'gap_fill') {
if (!allowGapFill) return;
const answers = Array.isArray(aData.answers)
? aData.answers
: (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []);
const text = String(qData.text || '');
const nativeWords = Array.from(text.matchAll(/\(([^)]+)\)/g), (m) => String(m[1] || '').trim());
if (!answers.length || !nativeWords.length) {
return;
}
answers.forEach((answer, index) => {
const nativeWord = nativeWords[index];
const normalizedAnswer = String(answer || '').trim();
if (!this._isTrainableSrsPair({ learning: nativeWord, reference: normalizedAnswer })) {
return;
}
vocabMap.set(`${nativeWord}-${normalizedAnswer}`, {
learning: nativeWord,
reference: normalizedAnswer
});
});
}
} catch (error) {
console.warn('Fehler beim Extrahieren von Trainer-Vokabeln:', error);
}
});
return Array.from(vocabMap.values());
}
_extractTrainerVocabsFromLessonDidactics(lesson) {
const vocabMap = new Map();
const corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : [];
corePatterns.forEach((entry) => {
const pattern = this._normalizeCorePatternEntry(entry);
const reference = String(pattern?.target || '').trim();
const learning = String(pattern?.gloss || '').trim();
if (!this._isTrainableSrsPair({ learning, reference })) return;
vocabMap.set(`${learning}-${reference}`, { learning, reference });
});
return Array.from(vocabMap.values());
}
_normalizeStringList(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => String(entry || '').trim())
.filter(Boolean);
}
if (typeof value === 'string') {
return value
.split(/\r?\n|;/)
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
/**
* Kernmuster: Zielsprachen-Phrase + optionale Glossierung (z. B. Deutsch).
* Unterstützt Legacy-Strings, "Phrase|Gloss" und Objekte { target, gloss } / { ceb, de }.
*/
_normalizeCorePatternEntry(entry) {
if (entry === null || entry === undefined || entry === '') {
return null;
}
if (typeof entry === 'object' && !Array.isArray(entry)) {
const target = String(entry.target ?? entry.ceb ?? entry.phrase ?? '').trim();
const gloss = String(entry.gloss ?? entry.de ?? entry.translation ?? '').trim();
if (!target) return null;
return { target, gloss };
}
const s = String(entry).trim();
if (!s) return null;
const pipe = s.indexOf('|');
if (pipe !== -1) {
const target = s.slice(0, pipe).trim();
const gloss = s.slice(pipe + 1).trim();
if (!target) return null;
return { target, gloss };
}
return { target: s, gloss: '' };
}
_normalizeCorePatternList(value) {
if (!value) return [];
const raw = Array.isArray(value)
? value
: (typeof value === 'string'
? value.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean)
: []);
return raw
.map((entry) => this._normalizeCorePatternEntry(entry))
.filter(Boolean);
}
_corePatternTarget(entry) {
const n = this._normalizeCorePatternEntry(entry);
return n ? n.target : '';
}
_enrichCorePatternsWithGloss(corePatterns = [], extractedVocabs = []) {
const glossByReference = new Map();
extractedVocabs.forEach((item) => {
const reference = this._normalizeLexeme(item?.reference);
const learning = String(item?.learning || '').trim();
if (!reference || !learning) {
return;
}
// Heuristik: Vermeide, einzelne sehr kurze Ziel-Token (z.B. "ko")
// automatisch mit mehrwortigen Glosses (z.B. "Ich arbeite") zu koppeln.
// Das verhindert fehlerhafte Glosszuweisungen für Partikeln/Pronomina.
const compactRefLen = reference.replace(/\s+/g, '').length;
const learningWordCount = this._wordCount(learning);
if (compactRefLen <= 3 && learningWordCount > 1) {
return;
}
if (!glossByReference.has(reference)) {
glossByReference.set(reference, learning);
}
});
return corePatterns
.map((entry) => this._normalizeCorePatternEntry(entry))
.filter(Boolean)
.map((entry) => {
if (entry.gloss) {
return entry;
}
const gloss = glossByReference.get(this._normalizeLexeme(entry.target)) || '';
return gloss ? { ...entry, gloss } : entry;
});
}
_mergeCorePatternGlosses(primaryPatterns = [], fallbackPatterns = []) {
const fallbackByTarget = new Map(
fallbackPatterns
.map((entry) => this._normalizeCorePatternEntry(entry))
.filter(Boolean)
.map((entry) => [this._normalizeLexeme(entry.target), entry.gloss || ''])
);
return primaryPatterns.map((entry) => {
const normalized = this._normalizeCorePatternEntry(entry);
if (!normalized) {
return null;
}
if (normalized.gloss) {
return normalized;
}
const gloss = fallbackByTarget.get(this._normalizeLexeme(normalized.target)) || '';
return gloss ? { ...normalized, gloss } : normalized;
}).filter(Boolean);
}
_normalizeStructuredList(value, keys = ['title', 'text']) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => {
if (typeof entry === 'string') {
return { title: '', text: entry.trim() };
}
if (!entry || typeof entry !== 'object') return null;
const normalized = {};
keys.forEach((key) => {
if (entry[key] !== undefined && entry[key] !== null) {
normalized[key] = String(entry[key]).trim();
}
});
return Object.keys(normalized).length > 0 ? normalized : null;
})
.filter(Boolean);
}
return [];
}
_normalizeOptionalInteger(value) {
if (value === undefined || value === null || value === '') {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
_normalizeOptionalString(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed || null;
}
_inferLessonPhaseLabel(plainLesson) {
if (plainLesson.phaseLabel) {
return plainLesson.phaseLabel;
}
const weekNumber = Number(plainLesson.weekNumber) || 0;
if (weekNumber > 0 && weekNumber <= 2) {
return 'quickstart';
}
if (weekNumber === 3) {
return 'daily_life';
}
if (weekNumber >= 4) {
return 'stabilization';
}
return 'quickstart';
}
_inferLessonDidacticMode(plainLesson) {
const lessonType = String(plainLesson.lessonType || '').toLowerCase();
const title = String(plainLesson.title || '').toLowerCase();
const storedMode = String(plainLesson.didacticMode || '').trim();
const isContrastTraining = lessonType === 'grammar' && [
'kontrast',
'fehlertraining',
' / ',
'nicht / kein',
'der / die / das',
'wo / wohin',
'du / sie',
'haben / sein',
'ich bin / ich habe',
'ich bin / ich heiße / ich komme'
].some((marker) => title.includes(marker));
if (storedMode && storedMode !== 'pattern_drill') {
return storedMode;
}
if (isContrastTraining) {
return 'contrast_training';
}
if (storedMode) {
return storedMode;
}
if (title.includes('abschluss') || title.includes('prüfung') || title.includes('test')) {
return 'checkpoint';
}
if (plainLesson.isIntensiveReview || lessonType === 'review' || lessonType === 'vocab_review' || lessonType === 'weekly_review' || title.includes('wiederholung')) {
return 'intensive_review';
}
if (lessonType === 'grammar') {
return 'pattern_drill';
}
if (lessonType === 'conversation' || lessonType === 'dialogue' || lessonType === 'phrases' || lessonType === 'survival') {
return 'guided_dialogue';
}
if (lessonType === 'culture') {
return 'real_life_scenario';
}
return 'core_input';
}
_inferLessonDifficultyWeight(plainLesson, didacticMode) {
if (plainLesson.difficultyWeight != null) {
return plainLesson.difficultyWeight;
}
switch (didacticMode) {
case 'contrast_training':
case 'pattern_drill':
return 3;
case 'guided_dialogue':
case 'real_life_scenario':
return 2;
case 'intensive_review':
case 'checkpoint':
return 2;
default:
return 1;
}
}
_inferLessonNewUnitTarget(plainLesson, didacticMode) {
if (plainLesson.newUnitTarget != null) {
return plainLesson.newUnitTarget;
}
switch (didacticMode) {
case 'contrast_training':
return 3;
case 'core_input':
return 8;
case 'guided_dialogue':
return 5;
case 'pattern_drill':
return 4;
case 'real_life_scenario':
return 3;
case 'checkpoint':
return 2;
case 'intensive_review':
return 1;
default:
return 4;
}
}
_inferLessonReviewWeight(plainLesson, didacticMode) {
if (plainLesson.reviewWeight != null) {
return plainLesson.reviewWeight;
}
switch (didacticMode) {
case 'intensive_review':
return 90;
case 'checkpoint':
return 70;
case 'contrast_training':
return 70;
case 'pattern_drill':
return 55;
case 'real_life_scenario':
return 45;
case 'guided_dialogue':
return 40;
default:
return 30;
}
}
_inferLessonBlockNumber(plainLesson) {
if (plainLesson.blockNumber != null) {
return plainLesson.blockNumber;
}
const weekNumber = Number(plainLesson.weekNumber) || 1;
return Math.max(1, Math.ceil(weekNumber / 2));
}
_buildLessonPedagogy(plainLesson) {
const didacticMode = this._inferLessonDidacticMode(plainLesson);
const phaseLabel = this._inferLessonPhaseLabel(plainLesson);
const isIntensiveReview = plainLesson.isIntensiveReview != null
? Boolean(plainLesson.isIntensiveReview)
: didacticMode === 'intensive_review';
return {
didacticMode,
phaseLabel,
blockNumber: this._inferLessonBlockNumber(plainLesson),
difficultyWeight: this._inferLessonDifficultyWeight(plainLesson, didacticMode),
newUnitTarget: this._inferLessonNewUnitTarget(plainLesson, didacticMode),
reviewWeight: this._inferLessonReviewWeight(plainLesson, didacticMode),
isIntensiveReview
};
}
_buildLessonDidactics(plainLesson) {
const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
const grammarExplanations = [];
const patterns = [];
const speakingPrompts = [];
grammarExercises.forEach((exercise) => {
const questionData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: (exercise.questionData || {});
if (exercise.explanation) {
grammarExplanations.push({
title: exercise.title || '',
text: exercise.explanation
});
}
const patternCandidates = [
questionData.pattern,
questionData.exampleSentence,
questionData.modelAnswer,
questionData.promptSentence
].filter(Boolean);
patternCandidates.forEach((candidate) => {
patterns.push(String(candidate).trim());
});
if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
speakingPrompts.push({
title: exercise.title || '',
prompt: questionData.question || questionData.text || '',
cue: questionData.expectedText || '',
keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
});
}
});
const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
const signature = `${item.title}::${item.text}`;
return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
});
const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
const extractedTrainerVocabs = this._extractTrainerVocabsFromExercises(grammarExercises);
const phase1FallbackCorePatterns = BISAYA_PHASE1_DIDACTICS[plainLesson.title]?.corePatterns || [];
let resolvedCorePatterns = this._mergeCorePatternGlosses(
this._enrichCorePatternsWithGloss(
this._normalizeCorePatternList(plainLesson.corePatterns),
extractedTrainerVocabs
),
phase1FallbackCorePatterns
);
if (
!resolvedCorePatterns.length
&& BISAYA_DIDACTICS_FRAGMENTS[plainLesson.title]?.corePatterns?.length
) {
const frag = BISAYA_DIDACTICS_FRAGMENTS[plainLesson.title];
resolvedCorePatterns = this._mergeCorePatternGlosses(
this._enrichCorePatternsWithGloss(
this._normalizeCorePatternList(frag.corePatterns),
extractedTrainerVocabs
),
phase1FallbackCorePatterns
);
}
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
return {
learningGoals: learningGoals.length > 0
? learningGoals
: [
'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
'Ein bis zwei Satzmuster aktiv anwenden.',
'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
],
corePatterns: resolvedCorePatterns.length > 0
? resolvedCorePatterns
: this._mergeCorePatternGlosses(
this._enrichCorePatternsWithGloss(
uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target),
extractedTrainerVocabs
),
phase1FallbackCorePatterns
),
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
practicalTasks: practicalTasks.length > 0
? practicalTasks
: [
{
title: 'Mini-Anwendung',
text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
}
]
};
}
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async _getChapterAccess(userId, chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid chapter id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
c.id,
c.language_id AS "languageId",
c.title,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_chapter c
JOIN community.vocab_language l ON l.id = c.language_id
WHERE c.id = :chapterId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, chapterId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Chapter not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listLanguages(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const rows = await sequelize.query(
`
SELECT
l.id,
l.name,
l.share_code AS "shareCode",
TRUE AS "isOwner"
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION ALL
SELECT
l.id,
l.name,
NULL::text AS "shareCode",
FALSE AS "isOwner"
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languages: rows };
}
async listAllLanguages() {
// Gibt alle verfügbaren Sprachen zurück (für Kursliste)
const rows = await sequelize.query(
`
SELECT
id,
name
FROM community.vocab_language
ORDER BY name ASC
`,
{
type: sequelize.QueryTypes.SELECT,
}
);
return { languages: rows };
}
async listLanguagesForMenu(userId) {
// userId ist die numerische community.user.id
const rows = await sequelize.query(
`
SELECT l.id, l.name
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION
SELECT l.id, l.name
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId },
type: sequelize.QueryTypes.SELECT,
}
);
return rows;
}
async createLanguage(hashedUserId, { name }) {
const user = await this._getUserByHashedId(hashedUserId);
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
const err = new Error('Invalid language name');
err.status = 400;
throw err;
}
// 16 hex chars => ausreichend kurz, gut teilbar
const shareCode = crypto.randomBytes(8).toString('hex');
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
VALUES (:ownerUserId, :name, :shareCode)
RETURNING id, name, share_code AS "shareCode"
`,
{
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
type: sequelize.QueryTypes.SELECT,
}
);
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return created;
}
async subscribeByShareCode(hashedUserId, { shareCode }) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const [lang] = await sequelize.query(
`
SELECT id, owner_user_id AS "ownerUserId", name
FROM community.vocab_language
WHERE share_code = :shareCode
LIMIT 1
`,
{
replacements: { shareCode: code },
type: sequelize.QueryTypes.SELECT,
}
);
if (!lang) {
const err = new Error('Language not found');
err.status = 404;
throw err;
}
// Owner braucht kein Abo
if (lang.ownerUserId === user.id) {
return { subscribed: false, message: 'Already owner', languageId: lang.id };
}
await sequelize.query(
`
INSERT INTO community.vocab_language_subscription (user_id, language_id)
VALUES (:userId, :languageId)
ON CONFLICT (user_id, language_id) DO NOTHING
`,
{
replacements: { userId: user.id, languageId: lang.id },
type: sequelize.QueryTypes.INSERT,
}
);
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return { subscribed: true, languageId: lang.id, name: lang.name };
}
async getLanguage(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
l.name,
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId: user.id, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listChapters(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
c.id,
c.title,
c.created_at AS "createdAt",
(
SELECT COUNT(*)
FROM community.vocab_chapter_lexeme cl
WHERE cl.chapter_id = c.id
)::int AS "vocabCount"
FROM community.vocab_chapter c
WHERE c.language_id = :languageId
ORDER BY c.title ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapters: rows, isOwner: access.isOwner };
}
async createChapter(hashedUserId, languageId, { title }) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
if (!access.isOwner) {
const err = new Error('Only owner can create chapters');
err.status = 403;
throw err;
}
const cleanTitle = typeof title === 'string' ? title.trim() : '';
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
const err = new Error('Invalid chapter title');
err.status = 400;
throw err;
}
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
VALUES (:languageId, :title, :userId)
RETURNING id, title, created_at AS "createdAt"
`,
{
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return created;
}
async getChapter(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
}
async listChapterVocabs(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
const rows = await sequelize.query(
`
SELECT
cl.id,
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE cl.chapter_id = :chapterId
ORDER BY l1.text ASC, l2.text ASC
`,
{
replacements: { chapterId: ch.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
}
async listLanguageVocabs(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
}
async getLessonVocabPool(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [
{
model: VocabCourse,
as: 'course'
},
{
model: VocabGrammarExercise,
as: 'grammarExercises',
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
}
],
required: false
}
]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findOne({
where: {
userId: user.id,
lessonId: lesson.id
}
});
if (lesson.course.ownerUserId !== user.id && !progress?.completed) {
const err = new Error('Lesson must be completed first');
err.status = 403;
throw err;
}
const extractedFromExercises = this._extractTrainerVocabsFromExercises(
(lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true }))
);
const fallbackVocabs = this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
const mergedVocabs = new Map();
[...fallbackVocabs, ...extractedFromExercises].forEach((entry) => {
if (!entry?.learning || !entry?.reference) return;
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
});
const vocabs = await this._ensureSrsItems(user.id, {
courseId: lesson.courseId,
lessonId: lesson.id,
vocabs: Array.from(mergedVocabs.values())
});
return {
lesson: {
id: lesson.id,
title: lesson.title,
courseId: lesson.courseId,
courseTitle: lesson.course.title
},
vocabs
};
}
async getCompletedLessonVocabPool(hashedUserId, courseId, untilLessonId = null) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
let maxLessonNumber = null;
if (untilLessonId) {
const untilLesson = await VocabCourseLesson.findOne({
where: {
id: untilLessonId,
courseId: course.id
},
attributes: ['lessonNumber']
});
if (!untilLesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
maxLessonNumber = untilLesson.lessonNumber;
}
const completedProgress = await VocabCourseProgress.findAll({
where: {
userId: user.id,
courseId: course.id,
completed: true
},
attributes: ['lessonId']
});
const completedLessonIds = completedProgress.map((entry) => entry.lessonId);
if (completedLessonIds.length === 0) {
return { courseId: course.id, vocabs: [] };
}
const lessonWhere = {
id: {
[Op.in]: completedLessonIds
},
courseId: course.id
};
if (maxLessonNumber != null) {
lessonWhere.lessonNumber = {
[Op.lte]: maxLessonNumber
};
}
const lessons = await VocabCourseLesson.findAll({
where: lessonWhere,
attributes: ['id', 'speakingPrompts', 'practicalTasks', 'corePatterns'],
order: [['lessonNumber', 'ASC']]
});
const lessonIds = lessons.map((lesson) => lesson.id);
if (!lessonIds.length) {
return { courseId: course.id, vocabs: [] };
}
const exercises = await VocabGrammarExercise.findAll({
where: {
lessonId: {
[Op.in]: lessonIds
}
},
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
}
],
order: [['lessonId', 'ASC'], ['exerciseNumber', 'ASC']]
});
const extractedFromExercises = this._extractTrainerVocabsFromExercises(exercises.map((exercise) => exercise.get({ plain: true })));
const fallbackVocabs = lessons.flatMap((lesson) =>
this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }))
);
const mergedVocabs = new Map();
[...extractedFromExercises, ...fallbackVocabs].forEach((entry) => {
if (!entry?.learning || !entry?.reference) return;
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
});
const vocabs = await this._ensureSrsItems(user.id, {
courseId: course.id,
lessonId: null,
vocabs: Array.from(mergedVocabs.values())
});
return {
courseId: course.id,
vocabs
};
}
async getCourseSrsDue(hashedUserId, courseId, query = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const limit = this._clampInteger(query?.limit, { min: 1, max: 100, fallback: 30 });
const now = new Date();
const dueWhere = {
userId: user.id,
courseId: Number(course.id),
nextDueAt: {
[Op.lte]: now
}
};
const dueRows = await VocabSrsItem.findAll({
where: dueWhere,
order: [
['nextDueAt', 'ASC'],
['wrongCount', 'DESC'],
['stage', 'ASC']
]
});
const validDueRows = dueRows.filter((item) => this._isTrainableSrsPair(item));
const rows = validDueRows.slice(0, limit);
const totalDueCount = validDueRows.length;
// Debug: Logge Anzahl fälliger SRS-Items (nur in Entwicklung sichtbar)
try {
console.debug('[VocabService] getCourseSrsDue', { userId: user.id, courseId: course.id, totalDueCount });
} catch (_) {}
return {
courseId: course.id,
dueAt: now.toISOString(),
count: rows.length,
totalDueCount,
limit,
items: rows.map((item) => ({
itemKey: item.itemKey,
courseId: item.courseId,
lessonId: item.lessonId,
learning: item.learning,
reference: item.reference,
direction: item.direction,
stage: item.stage,
intervalDays: item.intervalDays,
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount
}))
};
}
async reviewSrsItem(hashedUserId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const courseId = this._clampInteger(payload?.courseId, { min: 1, max: 1_000_000, fallback: 0 });
if (!courseId) {
const err = new Error('Missing course id');
err.status = 400;
throw err;
}
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const learning = this._sanitizeShortString(payload?.learning, 1200);
const reference = this._sanitizeShortString(payload?.reference, 1200);
if (!learning || !reference) {
const err = new Error('Missing SRS item text');
err.status = 400;
throw err;
}
const lessonId = payload?.lessonId == null
? null
: this._clampInteger(payload.lessonId, { min: 1, max: 1_000_000, fallback: 0 }) || null;
const direction = String(payload?.direction || 'BOTH').toUpperCase().slice(0, 8);
const itemKey = this._sanitizeShortString(payload?.itemKey, 80)
|| this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction });
const [item] = await VocabSrsItem.findOrCreate({
where: {
userId: user.id,
itemKey
},
defaults: {
userId: user.id,
courseId,
lessonId,
itemKey,
learning,
reference,
direction,
nextDueAt: new Date()
}
});
if (
item.learning !== learning ||
item.reference !== reference ||
item.direction !== direction ||
item.lessonId !== lessonId
) {
item.learning = learning;
item.reference = reference;
item.direction = direction;
item.lessonId = lessonId;
}
const correct = Boolean(payload?.correct);
const schedule = this._calculateSrsSchedule(item, {
correct,
rating: payload?.rating
});
item.stage = schedule.stage;
item.intervalDays = schedule.intervalDays;
item.lastReviewedAt = new Date();
item.nextDueAt = schedule.nextDueAt;
if (correct) {
item.correctCount += 1;
} else {
item.wrongCount += 1;
item.lapseCount += schedule.lapseDelta;
}
await item.save();
// Debug: Logge SRS-Updates, damit wir sehen, ob Reviews ankommen
try {
console.debug('[VocabService] reviewSrsItem saved', {
userId: user.id,
courseId: courseId,
itemKey: item.itemKey,
correct: correct,
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
stage: item.stage
});
} catch (_) {}
return {
itemKey: item.itemKey,
correct,
stage: item.stage,
intervalDays: item.intervalDays,
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount
};
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const query = typeof q === 'string' ? q.trim() : '';
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
const effective = query || learningTerm || motherTerm;
if (!effective) {
const err = new Error('Missing search term');
err.status = 400;
throw err;
}
const like = `%${effective}%`;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "motherTongue"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
LIMIT 200
`,
{
replacements: {
languageId: access.id,
like,
},
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
/**
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
*/
async getLanguageDictionary(hashedUserId, languageId, query = {}) {
const { q, page: pageParam, pageSize: pageSizeParam } = query;
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const term = typeof q === 'string' ? q.trim() : '';
const like = term ? `%${term}%` : null;
const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam });
const baseReplacements = like ? { languageId: access.id, like } : { languageId: access.id };
const countRows = await sequelize.query(
`
SELECT COUNT(*)::integer AS n
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
`,
{
replacements: baseReplacements,
type: sequelize.QueryTypes.SELECT,
}
);
const total = countRows[0]?.n ?? 0;
const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize);
const effectivePage = Math.min(Math.max(1, page), totalPages);
const offset = (effectivePage - 1) * pageSize;
let rows = [];
if (total > 0) {
rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
LIMIT :limit OFFSET :offset
`,
{
replacements: { ...baseReplacements, limit: pageSize, offset },
type: sequelize.QueryTypes.SELECT,
}
);
}
return {
languageId: access.id,
results: rows,
total,
page: effectivePage,
pageSize,
totalPages,
};
}
/**
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
*/
async getCourseDictionary(hashedUserId, courseId, query = {}) {
const { q, page: pageParam, pageSize: pageSizeParam } = query;
const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
let vocabs = pool.vocabs || [];
if (term) {
vocabs = vocabs.filter((entry) => {
const l = String(entry.learning || '').toLowerCase();
const r = String(entry.reference || '').toLowerCase();
return l.includes(term) || r.includes(term);
});
}
vocabs.sort((a, b) => {
const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' });
if (refCmp !== 0) return refCmp;
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
});
const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam });
const total = vocabs.length;
const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize);
const effectivePage = Math.min(Math.max(1, page), totalPages);
const offset = (effectivePage - 1) * pageSize;
const paged = vocabs.slice(offset, offset + pageSize);
return {
courseId: pool.courseId,
results: paged,
total,
page: effectivePage,
pageSize,
totalPages,
};
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
if (!ch.isOwner) {
const err = new Error('Only owner can add vocab');
err.status = 403;
throw err;
}
const learningText = typeof learning === 'string' ? learning.trim() : '';
const referenceText = typeof reference === 'string' ? reference.trim() : '';
if (!learningText || !referenceText) {
const err = new Error('Invalid vocab');
err.status = 400;
throw err;
}
const learningNorm = this._normalizeLexeme(learningText);
const referenceNorm = this._normalizeLexeme(referenceText);
// Transaktion: Lexeme upserten + Zuordnung setzen
return await sequelize.transaction(async (t) => {
const [learningLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [referenceLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [mapping] = await sequelize.query(
`
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
VALUES (:chapterId, :learningId, :referenceId, :userId)
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
RETURNING id
`,
{
replacements: {
chapterId: ch.id,
learningId: learningLex.id,
referenceId: referenceLex.id,
userId: user.id,
},
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
return { created: Boolean(mapping?.id) };
});
}
// ========== COURSE METHODS ==========
async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, difficultyLevel = 1, isPublic = false }) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Zugriff auf Sprache
await this._getLanguageAccess(user.id, languageId);
const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null;
const course = await VocabCourse.create({
ownerUserId: user.id,
title,
description,
languageId: Number(languageId),
nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null,
difficultyLevel: Number(difficultyLevel) || 1,
isPublic: Boolean(isPublic),
shareCode
});
return course.get({ plain: true });
}
async getCourses(hashedUserId, { includePublic = true, includeOwn = true, languageId, nativeLanguageId, search } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
// Konvertiere String-Parameter zu Booleans
const includePublicBool = includePublic === 'true' || includePublic === true;
const includeOwnBool = includeOwn === 'true' || includeOwn === true;
const where = {};
const andConditions = [];
// Zugriffsbedingungen
if (includeOwnBool && includePublicBool) {
andConditions.push({
[Op.or]: [
{ ownerUserId: user.id },
{ isPublic: true }
]
});
} else if (includeOwnBool) {
where.ownerUserId = user.id;
} else if (includePublicBool) {
where.isPublic = true;
}
// Filter nach Zielsprache (die zu lernende Sprache)
if (languageId) {
where.languageId = Number(languageId);
}
// Filter nach Muttersprache (die Sprache des Lerners)
// Wenn nativeLanguageId nicht gesetzt ist (undefined), zeige alle Kurse (kein Filter)
// Wenn nativeLanguageId === null oder 'null' (aus Frontend), zeige alle Kurse (kein Filter)
// Wenn nativeLanguageId eine Zahl ist, zeige nur Kurse für diese Muttersprache
if (nativeLanguageId !== undefined && nativeLanguageId !== null && nativeLanguageId !== 'null') {
where.nativeLanguageId = Number(nativeLanguageId);
}
// Wenn nativeLanguageId null/undefined/'null' ist, wird kein Filter angewendet = alle Kurse
// Suche nach Titel oder Beschreibung
if (search && search.trim()) {
const searchTerm = `%${search.trim()}%`;
andConditions.push({
[Op.or]: [
{ title: { [Op.iLike]: searchTerm } },
{ description: { [Op.iLike]: searchTerm } }
]
});
}
// Kombiniere alle AND-Bedingungen
// Wenn sowohl andConditions als auch direkte where-Eigenschaften existieren,
// müssen sie kombiniert werden
// WICHTIG: directWhereProps muss NACH dem Setzen aller direkten Eigenschaften berechnet werden
const directWhereProps = Object.keys(where).filter(key => {
// Filtere Op.and und Op.or heraus (sind Symbol-Keys)
return key !== Op.and && key !== Op.or && typeof key === 'string';
});
if (andConditions.length > 0) {
// Wenn where bereits direkte Eigenschaften hat, füge sie zu andConditions hinzu
if (directWhereProps.length > 0) {
const directWhere = {};
for (const key of directWhereProps) {
directWhere[key] = where[key];
delete where[key];
}
andConditions.push(directWhere);
}
// Entferne leere Objekte aus andConditions
const filteredConditions = andConditions.filter(cond => {
return cond && typeof cond === 'object' && Object.keys(cond).length > 0;
});
// Setze andConditions nur, wenn sie nicht leer sind
if (filteredConditions.length > 0) {
where[Op.and] = filteredConditions;
}
}
// Wenn nur direkte Eigenschaften existieren (andConditions.length === 0),
// bleiben sie in where (nichts zu tun, sie sind bereits dort)
const courses = await VocabCourse.findAll({
where,
order: [['createdAt', 'DESC']]
});
const coursesData = courses.map(c => c.get({ plain: true }));
await this._attachLanguageNamesToCourseRows(coursesData);
return coursesData;
}
async getCourseByShareCode(hashedUserId, shareCode) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const course = await VocabCourse.findOne({
where: { shareCode: code }
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff (öffentlich oder Besitzer)
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
return course.get({ plain: true });
}
async getCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId, {
include: [
{
model: VocabCourseLesson,
as: 'lessons',
order: [['lessonNumber', 'ASC']]
}
]
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const courseData = course.get({ plain: true });
courseData.lessons = courseData.lessons || [];
// Sortiere Lektionen nach Woche, Tag, dann Nummer
courseData.lessons.sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {
return (a.weekNumber || 999) - (b.weekNumber || 999);
}
if (a.dayNumber !== b.dayNumber) {
return (a.dayNumber || 999) - (b.dayNumber || 999);
}
return a.lessonNumber - b.lessonNumber;
});
courseData.lessons = courseData.lessons.map((lesson) => ({
...lesson,
pedagogy: this._buildLessonPedagogy(lesson)
}));
await this._attachLanguageNamesToCourseRows([courseData]);
return courseData;
}
/** Admin/Support: Kurs inkl. Lektionen ohne Sichtbarkeitsprüfung (nur serverseitig für Staff-Routen). */
async adminGetCourseWithLessonsForStaff(courseId) {
const course = await VocabCourse.findByPk(Number(courseId), {
include: [
{
model: VocabCourseLesson,
as: 'lessons',
order: [['lessonNumber', 'ASC']]
}
]
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
const courseData = course.get({ plain: true });
courseData.lessons = courseData.lessons || [];
courseData.lessons.sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {
return (a.weekNumber || 999) - (b.weekNumber || 999);
}
if (a.dayNumber !== b.dayNumber) {
return (a.dayNumber || 999) - (b.dayNumber || 999);
}
return a.lessonNumber - b.lessonNumber;
});
courseData.lessons = courseData.lessons.map((lesson) => ({
...lesson,
pedagogy: this._buildLessonPedagogy(lesson)
}));
return courseData;
}
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update the course');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (languageId !== undefined) updates.languageId = Number(languageId);
if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null;
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
if (isPublic !== undefined) {
updates.isPublic = Boolean(isPublic);
// Generiere Share-Code wenn Kurs öffentlich wird
if (isPublic && !course.shareCode) {
updates.shareCode = crypto.randomBytes(8).toString('hex');
} else if (!isPublic) {
updates.shareCode = null;
}
}
await course.update(updates);
return course.get({ plain: true });
}
async deleteCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete the course');
err.status = 403;
throw err;
}
await course.destroy();
return { success: true };
}
_seededShuffle(items, seed) {
const arr = items.slice();
let t = (Number(seed) >>> 0) ^ 0x6a09e667;
const rnd = () => {
t ^= t << 13;
t ^= t >>> 17;
t ^= t << 5;
return (t >>> 0) / 4294967296;
};
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(rnd() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
_buildDeterministicChapterLexemeMcOptions(correct, distractorSource, seed) {
const norm = (s) => this._normalizeTextAnswer(s);
const nc = norm(correct);
const filtered = distractorSource.filter((t) => norm(t) !== nc && String(t || '').trim());
const shuffled = this._seededShuffle(filtered, (seed ^ 0x9e3779b9) >>> 0);
const picks = [];
for (const p of shuffled) {
if (picks.length >= 3) {
break;
}
picks.push(p);
}
let pad = 1;
while (picks.length < 3) {
picks.push(`(${pad})`);
pad += 1;
}
const ordered = this._seededShuffle([correct, ...picks], seed >>> 0);
const correctAnswer = ordered.findIndex((o) => String(o) === String(correct));
return { options: ordered, correctAnswer: correctAnswer >= 0 ? correctAnswer : 0 };
}
_lexemePairCoveredByMultipleChoice(exerciseList, learning, reference) {
const nl = this._normalizeTextAnswer(learning);
const nr = this._normalizeTextAnswer(reference);
if (!nl || !nr) {
return false;
}
for (const ex of exerciseList) {
if (Number(ex.exerciseTypeId) !== 2) {
continue;
}
const qd = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
if (!qd || qd.type !== 'multiple_choice') {
continue;
}
const prompt = this._normalizeTextAnswer(qd.question || qd.text || '');
if (!prompt.includes(nl)) {
continue;
}
const ad = typeof ex.answerData === 'string' ? JSON.parse(ex.answerData) : ex.answerData;
const options = Array.isArray(qd.options) ? qd.options : [];
let idx = ad?.correctAnswer;
if (Array.isArray(idx)) {
idx = idx[0];
}
if (idx === undefined && ad?.correct !== undefined) {
idx = Array.isArray(ad.correct) ? ad.correct[0] : ad.correct;
}
if (idx === undefined || options[Number(idx)] === undefined) {
continue;
}
const correctOpt = this._normalizeTextAnswer(options[Number(idx)]);
if (correctOpt === nr) {
return true;
}
}
return false;
}
_buildSyntheticLexemeMcExercisePlain(lessonId, row, exerciseNumber) {
const learning = String(row.learning || '').trim();
const reference = String(row.reference || '').trim();
if (!learning || !reference) {
return null;
}
const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0;
const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(
reference,
row.allReferences || [],
seed
);
const questionData = {
type: 'multiple_choice',
question: `Was bedeutet „${learning}“?`,
options,
randomizeDistractors: false
};
const answerData = {
type: 'multiple_choice',
correctAnswer
};
return {
id: `syn-${lessonId}-${row.id}-l2r`,
lessonId,
exerciseTypeId: 2,
exerciseType: { id: 2, name: 'multiple_choice' },
exerciseNumber,
title: `Kapitel-Vokabel: ${learning}`,
instruction: 'Wähle die passende Übersetzung.',
questionData,
answerData,
explanation: null,
createdByUserId: 0
};
}
async _fetchChapterLexemeRowsForMc(chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
return [];
}
const rows = await sequelize.query(
`
SELECT
cl.id,
l1.text AS learning,
l2.text AS reference
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE cl.chapter_id = :chapterId
ORDER BY cl.id ASC
`,
{
replacements: { chapterId: id },
type: sequelize.QueryTypes.SELECT
}
);
return rows;
}
async _mergeSyntheticChapterLexemeMcExercises(plainLesson, grammarExercises) {
const list = Array.isArray(grammarExercises) ? [...grammarExercises] : [];
if (!plainLesson?.chapterId) {
return list;
}
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review' || plainLesson.lessonType === 'weekly_review') {
return list;
}
let rows = [];
// If this lesson belongs to a week, prefer vocab from previous lessons of the same week
if (plainLesson.weekNumber) {
try {
const weekExercises = await this._getWeekVocabExercises(plainLesson.courseId, plainLesson.weekNumber, plainLesson.lessonNumber);
const extracted = this._extractTrainerVocabsFromExercises(weekExercises || [], { allowGapFill: false });
if (extracted && extracted.length) {
// Map extracted pairs to row-like objects with numeric ids
rows = extracted.map((item, idx) => ({ id: 2000000 + idx, learning: item.learning, reference: item.reference }));
}
} catch (err) {
// ignore and fallback to chapter lexemes
rows = [];
}
}
if (!rows.length) {
rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
}
if (!rows.length) {
plainLesson.chapterLexemeExamCount = 0;
plainLesson.chapterLexemeTrainingCount = 0;
plainLesson.chapterLexemeTraining = [];
return list;
}
const allReferences = rows.map((r) => r.reference).filter(Boolean);
let maxNum = list.reduce((m, ex) => Math.max(m, Number(ex.exerciseNumber) || 0), 0);
const augmentedRows = rows.map((r) => ({ ...r, allReferences }));
// Sampling parameters
const EXAM_MAX = 15; // max items in the chapter exam
const TRAINING_RATIO = 0.67; // fraction of chapter lexemes to include in training pool
const seed = (Number(plainLesson.id) * 100003) >>> 0;
const shuffled = this._seededShuffle(augmentedRows.slice(), seed);
const totalRows = shuffled.length;
const trainingTarget = Math.max(0, Math.min(totalRows, Math.ceil(totalRows * TRAINING_RATIO)));
const examTarget = Math.max(0, Math.min(EXAM_MAX, totalRows));
const examSelected = [];
const trainingSelected = [];
for (const row of shuffled) {
const learningText = String(row.learning || '').trim();
if (!learningText || learningText.split(/\s+/).length > 1) {
// skip multi-word learning items for both exam and training
continue;
}
// Fill training pool up to target (allow duplicates between exam and training)
if (trainingSelected.length < trainingTarget) {
trainingSelected.push(row);
}
// For exam, also enforce that the pair is not already covered by an existing MC
if (examSelected.length < examTarget && !this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) {
examSelected.push(row);
}
if (examSelected.length >= examTarget && trainingSelected.length >= trainingTarget) {
break;
}
}
// Fallback: if examSelected is smaller than examTarget, try to relax duplicate check
if (examSelected.length < examTarget) {
for (const row of shuffled) {
if (examSelected.find((r) => Number(r.id) === Number(row.id))) continue;
const learningText = String(row.learning || '').trim();
if (!learningText || learningText.split(/\s+/).length > 1) continue;
examSelected.push(row);
if (examSelected.length >= examTarget) break;
}
}
// Attach metadata for frontend/training use
plainLesson.chapterLexemeExamCount = examSelected.length;
plainLesson.chapterLexemeTrainingCount = trainingSelected.length;
plainLesson.chapterLexemeTraining = trainingSelected.map((r) => ({ id: r.id, learning: r.learning, reference: r.reference }));
// Build synthetic exercises only for examSelected
for (const row of examSelected) {
maxNum += 1;
const ex = this._buildSyntheticLexemeMcExercisePlain(plainLesson.id, row, maxNum);
if (ex) {
list.push(ex);
}
}
return list;
}
async _checkSyntheticLexemeMcAnswer(user, lessonId, chapterLexemeId, userAnswer) {
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
if (!lesson.chapterId) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const rows = await this._fetchChapterLexemeRowsForMc(lesson.chapterId);
const row = rows.find((r) => Number(r.id) === Number(chapterLexemeId));
if (!row) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const learning = String(row.learning || '').trim();
const reference = String(row.reference || '').trim();
if (!learning || !reference) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const allReferences = rows.map((r) => r.reference).filter(Boolean);
const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0;
const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(reference, allReferences, seed);
const questionData = {
type: 'multiple_choice',
question: `Was bedeutet „${learning}“?`,
options,
randomizeDistractors: false
};
const answerData = {
type: 'multiple_choice',
correctAnswer
};
const isCorrect = this._checkAnswer(answerData, questionData, userAnswer, 2);
const correctIdx = Number(correctAnswer);
const correctAnswerText = options[correctIdx];
const alternatives = options.filter((_, idx) => idx !== correctIdx);
return {
correct: isCorrect,
correctAnswer: correctAnswerText || null,
alternatives,
explanation: null,
progress: {
attempts: 1,
correctAttempts: isCorrect ? 1 : 0,
lastAttemptAt: new Date(),
completed: Boolean(isCorrect),
completedAt: isCorrect ? new Date() : null
}
};
}
async getLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [
{
model: VocabCourse,
as: 'course'
},
{
model: VocabGrammarExercise,
as: 'grammarExercises',
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
}
],
required: false,
separate: true,
order: [['exerciseNumber', 'ASC']]
}
]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findOne({
where: {
userId: user.id,
lessonId: lesson.id
}
});
const plainLesson = lesson.get({ plain: true });
// Normalize exercise types: if question text contains clear gap placeholders,
// ensure the questionData.type is 'gap_fill' so the frontend renders the gap UI.
(plainLesson.grammarExercises || []).forEach((ex) => {
try {
const qData = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData || '{}') : (ex.questionData || {});
const text = String(qData.text || qData.question || '').trim();
const hasUnderscoreGap = /_{3,}/.test(text);
const hasBraceGap = /\{\s*gap\s*\}/i.test(text);
const hasParenPlaceholders = /\(\s*_{0,}\s*\)/.test(text);
if ((hasUnderscoreGap || hasBraceGap || hasParenPlaceholders) && qData.type !== 'gap_fill') {
qData.type = 'gap_fill';
ex.questionData = qData;
}
} catch (err) {
// ignore parse errors
}
});
const isWeeklyReview = plainLesson.lessonType === 'weekly_review';
const isCheckpoint = (String(plainLesson.didacticMode || '').toLowerCase() === 'checkpoint')
|| (/checkpoint/i.test(String(plainLesson.title || '')) && plainLesson.weekNumber != null);
// Lade Vokabeln aus vorherigen Lektionen (für Wiederholung UND für gemischten Vokabeltrainer)
if (plainLesson.lessonNumber > 1 && !isWeeklyReview && !isCheckpoint) {
plainLesson.previousLessonExercises = await this._getReviewVocabExercises(plainLesson.courseId, plainLesson.lessonNumber);
}
// Checkpoint-Lektionen: kleine Stichprobe aus den Wochen-Übungen (ca. 10-30%)
if (isCheckpoint) {
const weeklyLessons = await this._getReviewLessons(
plainLesson.courseId,
plainLesson.lessonNumber,
plainLesson.weekNumber
);
const weeklyExercises = await this._getReviewVocabExercises(
plainLesson.courseId,
plainLesson.lessonNumber,
plainLesson.weekNumber
);
plainLesson.reviewLessons = weeklyLessons;
plainLesson.previousLessonExercises = [];
plainLesson.weeklyReviewTrainingExercises = weeklyExercises;
plainLesson.reviewVocabExercises = this._selectCheckpointExamExercises(weeklyExercises, plainLesson.id);
plainLesson.weeklyReviewExamCount = plainLesson.reviewVocabExercises.length;
plainLesson.weeklyReviewTrainingCount = weeklyExercises.length;
plainLesson.corePatterns = weeklyLessons.flatMap((entry) => {
if (Array.isArray(entry.corePatterns)) return entry.corePatterns;
if (typeof entry.corePatterns === 'string') {
try {
const parsed = JSON.parse(entry.corePatterns);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
});
} else if (isWeeklyReview) {
const weeklyLessons = await this._getReviewLessons(
plainLesson.courseId,
plainLesson.lessonNumber,
plainLesson.weekNumber
);
const weeklyExercises = await this._getReviewVocabExercises(
plainLesson.courseId,
plainLesson.lessonNumber,
plainLesson.weekNumber
);
plainLesson.reviewLessons = weeklyLessons;
plainLesson.previousLessonExercises = [];
plainLesson.weeklyReviewTrainingExercises = weeklyExercises;
plainLesson.reviewVocabExercises = this._selectWeeklyReviewExamExercises(weeklyExercises, plainLesson.id);
plainLesson.weeklyReviewExamCount = plainLesson.reviewVocabExercises.length;
plainLesson.weeklyReviewTrainingCount = weeklyExercises.length;
plainLesson.corePatterns = weeklyLessons.flatMap((entry) => {
if (Array.isArray(entry.corePatterns)) return entry.corePatterns;
if (typeof entry.corePatterns === 'string') {
try {
const parsed = JSON.parse(entry.corePatterns);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
});
} else if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
// Kursweite/gezielt kuratierte Reviews behalten das bisherige Verhalten.
plainLesson.reviewLessons = await this._getReviewLessons(
plainLesson.courseId,
plainLesson.lessonNumber
);
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
}
plainLesson.grammarExercises = await this._mergeSyntheticChapterLexemeMcExercises(
plainLesson,
plainLesson.grammarExercises || []
);
const suppressLessonReviewDue = await this._courseHasDueSrsItems(user.id, plainLesson.courseId);
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson, { suppressLessonReviewDue });
return plainLesson;
}
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
const requestId = `assist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const log = (...args) => console.log(`[LLM ${requestId}]`, ...args);
const startedAt = Date.now();
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await this.getLesson(hashedUserId, lessonId);
const config = await this._getUserLlmConfig(user.id);
log('start', {
userId: user.id,
lessonId,
enabled: config.enabled,
configured: config.configured,
hasKey: config.hasKey,
baseUrl: config.baseUrl || '(default openai)',
model: config.model
});
if (!config.enabled) {
log('aborted: assistant disabled in user settings');
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
err.status = 400;
throw err;
}
if (!config.configured) {
log('aborted: assistant not configured');
const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.');
err.status = 400;
throw err;
}
const message = String(payload?.message || '').trim();
if (!message) {
log('aborted: empty message');
const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.');
err.status = 400;
throw err;
}
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
const history = this._sanitizeAssistantHistory(payload?.history);
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
const headers = {
'Content-Type': 'application/json'
};
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
const controller = new AbortController();
const configuredTimeout = Number(process.env.LLM_ASSISTANT_TIMEOUT_MS);
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout >= 30000
? configuredTimeout
: 300000;
const timeout = setTimeout(() => {
log('timeout reached, aborting fetch', { timeoutMs });
controller.abort();
}, timeoutMs);
const temperatureByMode = {
explain: 0.4,
practice: 0.5,
correct: 0.1
};
const temperature = Number.isFinite(temperatureByMode[mode]) ? temperatureByMode[mode] : 0.5;
log('request', {
endpoint,
mode,
temperature,
timeoutMs,
historyMessages: history.length,
messagePreview: message.slice(0, 120)
});
let response;
let fetchStartedAt = Date.now();
try {
response = await fetch(endpoint, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model: config.model,
temperature,
messages: [
{
role: 'system',
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
},
...history,
{
role: 'user',
content: message
}
]
})
});
log('response received', {
status: response.status,
latencyMs: Date.now() - fetchStartedAt
});
} catch (error) {
log('fetch failed', {
name: error?.name,
message: error?.message,
cause: error?.cause?.code || error?.cause?.errno || null,
latencyMs: Date.now() - fetchStartedAt
});
const err = new Error(
error?.name === 'AbortError'
? 'Der Sprachassistent hat das Antwort-Zeitlimit überschritten.'
: 'Der Sprachassistent konnte nicht erreicht werden.'
);
err.status = 502;
throw err;
} finally {
clearTimeout(timeout);
}
let responseData = null;
try {
responseData = await response.json();
} catch (parseError) {
log('failed to parse response JSON', { message: parseError?.message });
responseData = null;
}
if (!response.ok) {
log('upstream returned non-ok', {
status: response.status,
body: responseData ? JSON.stringify(responseData).slice(0, 500) : null
});
const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.';
const err = new Error(messageFromApi);
err.status = response.status || 502;
throw err;
}
const reply = this._extractAssistantContent(responseData);
if (!reply) {
log('empty reply from upstream', {
responsePreview: responseData ? JSON.stringify(responseData).slice(0, 500) : null
});
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
err.status = 502;
throw err;
}
log('done', {
totalMs: Date.now() - startedAt,
model: responseData?.model || config.model,
replyLength: reply.length
});
return {
reply,
model: responseData?.model || config.model,
mode
};
}
/**
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
*/
async _getReviewLessons(courseId, currentLessonNumber, weekNumber = null) {
const where = {
courseId: courseId,
lessonNumber: {
[Op.lt]: currentLessonNumber // Nur Lektionen mit kleinerer Nummer
},
lessonType: {
[Op.notIn]: ['review', 'vocab_review', 'weekly_review'] // Keine anderen Wiederholungslektionen
}
};
if (weekNumber != null) {
where.weekNumber = weekNumber;
}
const lessons = await VocabCourseLesson.findAll({
where,
order: [['lessonNumber', 'ASC']],
attributes: ['id', 'lessonNumber', 'title', 'corePatterns']
});
return lessons.map(l => l.get({ plain: true }));
}
/**
* Sammelt alle Grammatik-Übungen aus vorherigen Lektionen für Wiederholungslektionen
*/
async _getReviewVocabExercises(courseId, currentLessonNumber, weekNumber = null) {
const where = {
courseId: courseId,
lessonNumber: {
[Op.lt]: currentLessonNumber
},
lessonType: {
[Op.notIn]: ['review', 'vocab_review', 'weekly_review']
}
};
if (weekNumber != null) {
where.weekNumber = weekNumber;
}
const previousLessons = await VocabCourseLesson.findAll({
where,
attributes: ['id']
});
if (previousLessons.length === 0) {
return [];
}
const lessonIds = previousLessons.map(l => l.id);
const exercises = await VocabGrammarExercise.findAll({
where: {
lessonId: {
[Op.in]: lessonIds
}
},
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
},
{
model: VocabCourseLesson,
as: 'lesson',
attributes: ['id', 'lessonNumber', 'title']
}
],
order: [
[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'],
['exerciseNumber', 'ASC']
]
});
return exercises.map(e => e.get({ plain: true }));
}
_selectWeeklyReviewExamExercises(exercises = [], lessonId) {
const list = Array.isArray(exercises) ? exercises : [];
if (list.length === 0) return [];
const seed = (Number(lessonId) * 100003) >>> 0;
const percentage = 40 + (seed % 21);
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100));
return this._seededShuffle(list.slice(), seed).slice(0, targetCount);
}
_selectCheckpointExamExercises(exercises = [], lessonId) {
const list = Array.isArray(exercises) ? exercises : [];
if (list.length === 0) return [];
const seed = (Number(lessonId) * 100003) >>> 0;
// Checkpoints: smaller sample ~10-30%
const percentage = 10 + (seed % 21);
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100));
return this._seededShuffle(list.slice(), seed).slice(0, targetCount);
}
/**
* Sammelt GrammatikÜbungen aus vorherigen Lektionen derselben Woche
*/
async _getWeekVocabExercises(courseId, weekNumber, currentLessonNumber) {
if (weekNumber == null) return [];
const previousLessons = await VocabCourseLesson.findAll({
where: {
courseId: courseId,
weekNumber: weekNumber,
lessonNumber: { [Op.lt]: currentLessonNumber },
lessonType: { [Op.notIn]: ['review', 'vocab_review', 'weekly_review'] }
},
attributes: ['id']
});
if (previousLessons.length === 0) return [];
const lessonIds = previousLessons.map(l => l.id);
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: { [Op.in]: lessonIds } },
include: [
{ model: VocabGrammarExerciseType, as: 'exerciseType' },
{ model: VocabCourseLesson, as: 'lesson', attributes: ['id', 'lessonNumber', 'title'] }
],
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'], ['exerciseNumber', 'ASC']]
});
return exercises.map(e => e.get({ plain: true }));
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add lessons');
err.status = 403;
throw err;
}
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
if (chapterId) {
const [chapter] = await sequelize.query(
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
{
replacements: { chapterId: Number(chapterId) },
type: sequelize.QueryTypes.SELECT
}
);
if (!chapter || chapter.language_id !== course.languageId) {
const err = new Error('Chapter does not belong to the course language');
err.status = 400;
throw err;
}
}
const lesson = await VocabCourseLesson.create({
courseId: course.id,
chapterId: chapterId ? Number(chapterId) : null,
lessonNumber: Number(lessonNumber),
title,
description,
weekNumber: weekNumber ? Number(weekNumber) : null,
dayNumber: dayNumber ? Number(dayNumber) : null,
lessonType: lessonType || 'vocab',
didacticMode: this._normalizeOptionalString(didacticMode),
phaseLabel: this._normalizeOptionalString(phaseLabel),
blockNumber: this._normalizeOptionalInteger(blockNumber),
difficultyWeight: this._normalizeOptionalInteger(difficultyWeight),
newUnitTarget: this._normalizeOptionalInteger(newUnitTarget),
reviewWeight: this._normalizeOptionalInteger(reviewWeight),
isIntensiveReview: isIntensiveReview !== undefined ? Boolean(isIntensiveReview) : false,
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
learningGoals: this._normalizeStringList(learningGoals),
corePatterns: this._normalizeCorePatternList(corePatterns),
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
});
return lesson.get({ plain: true });
}
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update lessons');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
if (lessonType !== undefined) updates.lessonType = lessonType;
if (didacticMode !== undefined) updates.didacticMode = this._normalizeOptionalString(didacticMode);
if (phaseLabel !== undefined) updates.phaseLabel = this._normalizeOptionalString(phaseLabel);
if (blockNumber !== undefined) updates.blockNumber = this._normalizeOptionalInteger(blockNumber);
if (difficultyWeight !== undefined) updates.difficultyWeight = this._normalizeOptionalInteger(difficultyWeight);
if (newUnitTarget !== undefined) updates.newUnitTarget = this._normalizeOptionalInteger(newUnitTarget);
if (reviewWeight !== undefined) updates.reviewWeight = this._normalizeOptionalInteger(reviewWeight);
if (isIntensiveReview !== undefined) updates.isIntensiveReview = Boolean(isIntensiveReview);
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
if (corePatterns !== undefined) updates.corePatterns = this._normalizeCorePatternList(corePatterns);
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
await lesson.update(updates);
return lesson.get({ plain: true });
}
async deleteLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete lessons');
err.status = 403;
throw err;
}
await lesson.destroy();
return { success: true };
}
async enrollInCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
where: { userId: user.id, courseId: course.id },
defaults: { userId: user.id, courseId: course.id }
});
if (!created) {
const err = new Error('Already enrolled in this course');
err.status = 400;
throw err;
}
return enrollment.get({ plain: true });
}
async unenrollFromCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 404;
throw err;
}
await enrollment.destroy();
return { success: true };
}
async getMyCourses(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [{ model: VocabCourse, as: 'course' }],
order: [['enrolledAt', 'DESC']]
});
return enrollments.map(e => ({
...e.course.get({ plain: true }),
enrolledAt: e.enrolledAt
}));
}
/**
* Kompakte Übersicht für das Start-Dashboard: eingeschriebene Kurse und „aktuelle“ Lektion
* (gleiche Logik wie VocabCourseView.currentLesson: erste unvollständige, sonst letzte).
*/
async getDashboardLearningSummary(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [
{
model: VocabCourse,
as: 'course',
required: true,
attributes: ['id', 'title']
}
],
order: [['enrolledAt', 'DESC']]
});
const courseById = new Map();
for (const e of enrollments) {
const c = e.course?.get({ plain: true });
if (!c?.id || courseById.has(c.id)) {
continue;
}
courseById.set(c.id, { id: c.id, title: c.title || '' });
}
const coursesMeta = [...courseById.values()];
if (coursesMeta.length === 0) {
return { courses: [] };
}
const courseIds = coursesMeta.map((c) => c.id);
const lessons = await VocabCourseLesson.findAll({
where: { courseId: { [Op.in]: courseIds } },
attributes: ['id', 'courseId', 'lessonNumber', 'title'],
order: [
['courseId', 'ASC'],
['lessonNumber', 'ASC']
]
});
const progressRows = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: { [Op.in]: courseIds } },
attributes: ['lessonId', 'completed']
});
const completedByLessonId = new Map();
for (const row of progressRows) {
const plain = row.get({ plain: true });
completedByLessonId.set(plain.lessonId, Boolean(plain.completed));
}
const lessonsByCourse = new Map();
for (const row of lessons) {
const plain = row.get({ plain: true });
const list = lessonsByCourse.get(plain.courseId) || [];
list.push(plain);
lessonsByCourse.set(plain.courseId, list);
}
const courses = [];
for (const meta of coursesMeta) {
const sorted = lessonsByCourse.get(meta.id) || [];
if (sorted.length === 0) {
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: null,
allLessonsCompleted: false
});
continue;
}
let current = null;
for (const lesson of sorted) {
if (!completedByLessonId.get(lesson.id)) {
current = lesson;
break;
}
}
if (!current) {
current = sorted[sorted.length - 1];
}
const allLessonsCompleted = sorted.every((lesson) => completedByLessonId.get(lesson.id) === true);
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: {
id: current.id,
lessonNumber: current.lessonNumber,
title: current.title || ''
},
allLessonsCompleted
});
}
return { courses };
}
/**
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.
*/
async listEnrolledVocabCoursesForUser(targetHashedUserId) {
const user = await this._getUserByHashedId(targetHashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [{ model: VocabCourse, as: 'course', required: true }],
order: [['enrolledAt', 'DESC']]
});
const byCourseId = new Map();
for (const e of enrollments) {
const row = e.course;
if (!row) {
continue;
}
const plain = row.get({ plain: true });
if (byCourseId.has(plain.id)) {
continue;
}
byCourseId.set(plain.id, {
...plain,
enrolledAt: e.enrolledAt
});
}
const coursesData = [...byCourseId.values()];
await this._attachLanguageNamesToCourseRows(coursesData);
return coursesData;
}
async getCourseProgress(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: Number(courseId) },
include: [{ model: VocabCourseLesson, as: 'lesson' }],
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
});
const suppressLessonReviewDue = await this._courseHasDueSrsItems(user.id, courseId);
return progress.map((entry) => this._serializeLessonProgress(entry, entry.lesson, { suppressLessonReviewDue }));
}
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes, lessonState }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
const targetScore = lessonData.targetScorePercent || 80;
const actualScore = Number(score) || 0;
const hasReachedTarget = actualScore >= targetScore;
const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState);
const didSubmitResult = completed !== undefined || score !== undefined;
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
const [progress, created] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: lesson.courseId,
lessonId: lesson.id,
completed: isCompleted,
score: actualScore,
lessonState: sanitizedLessonState || {},
lastAccessedAt: new Date()
}
});
if (!created) {
const updates = { lastAccessedAt: new Date() };
if (score !== undefined) {
updates.score = Math.max(progress.score, actualScore);
// Prüfe, ob Ziel jetzt erreicht wurde
if (updates.score >= targetScore && !progress.completed) {
if (!lessonData.requiresReview) {
updates.completed = true;
updates.completedAt = new Date();
}
}
}
if (completed !== undefined) {
updates.completed = Boolean(completed);
if (completed && !progress.completedAt) {
updates.completedAt = new Date();
}
}
if (sanitizedLessonState !== undefined) {
updates.lessonState = sanitizedLessonState;
}
const nextCompleted = updates.completed !== undefined ? Boolean(updates.completed) : Boolean(progress.completed);
const mergedLessonState = {
...this._sanitizeLessonState(progress.lessonState),
...(updates.lessonState || {})
};
updates.lessonState = this._applyScheduledReviewState(mergedLessonState, {
previousCompleted: Boolean(progress.completed),
nextCompleted,
shouldAdvanceReview: didSubmitResult && nextCompleted,
lessonData,
now: updates.completedAt || updates.lastAccessedAt || new Date()
});
await progress.update(updates);
} else if (isCompleted) {
progress.completed = true;
progress.completedAt = new Date();
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState || {}, {
previousCompleted: false,
nextCompleted: true,
shouldAdvanceReview: didSubmitResult,
lessonData,
now: progress.completedAt
});
await progress.save();
} else if (sanitizedLessonState !== undefined) {
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState, {
previousCompleted: false,
nextCompleted: false,
shouldAdvanceReview: false,
lessonData,
now: new Date()
});
await progress.save();
}
return this._serializeLessonProgress(progress, lessonData);
}
/**
* Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress).
* Gesamtkurs / andere Lektionen bleiben unberührt.
*/
async _purgeLessonProgressForUser(userId, lessonId) {
const numericLessonId = Number(lessonId);
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: numericLessonId },
attributes: ['id']
});
const exerciseIds = exercises.map((e) => e.id);
let deletedExerciseProgressRows = 0;
if (exerciseIds.length > 0) {
deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({
where: { userId, exerciseId: { [Op.in]: exerciseIds } }
});
}
const deletedLessonProgressRows = await VocabCourseProgress.destroy({
where: { userId, lessonId: numericLessonId }
});
return {
success: true,
lessonId: numericLessonId,
deletedLessonProgressRows,
deletedExerciseProgressRows
};
}
/** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */
async resetMyLessonProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
/** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */
async adminResetLessonProgressForUser(targetHashedUserId, lessonId) {
const user = await this._getUserByHashedId(targetHashedUserId);
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
/**
* Admin: Alle Lektionen eines Kurses bis einschließlich lesson_number als abgeschlossen setzen
* (nur Zeilen, die noch nicht completed sind). Nur bei eingeschriebenem Nutzer.
*/
async adminMarkLessonsCompleteThrough(targetHashedUserId, courseId, throughLessonNumber) {
const user = await this._getUserByHashedId(targetHashedUserId);
const cid = Number(courseId);
const maxNum = Number(throughLessonNumber);
if (!Number.isFinite(cid) || cid < 1) {
const err = new Error('Invalid courseId');
err.status = 400;
throw err;
}
if (!Number.isFinite(maxNum) || maxNum < 1) {
const err = new Error('Invalid throughLessonNumber');
err.status = 400;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: cid }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const lessons = await VocabCourseLesson.findAll({
where: { courseId: cid, lessonNumber: { [Op.lte]: maxNum } },
order: [['lessonNumber', 'ASC']]
});
const now = new Date();
const details = [];
for (const lesson of lessons) {
const lessonData = lesson.get({ plain: true });
const targetScore = lessonData.targetScorePercent || 80;
const [progress] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: cid,
lessonId: lesson.id,
completed: false,
score: 0,
lessonState: {}
}
});
if (progress.completed) {
details.push({
lessonNumber: lesson.lessonNumber,
lessonId: lesson.id,
status: 'unchanged'
});
continue;
}
const mergedState = this._applyScheduledReviewState(
this._sanitizeLessonState(progress.lessonState),
{
previousCompleted: false,
nextCompleted: true,
shouldAdvanceReview: true,
lessonData,
now
}
);
await progress.update({
completed: true,
completedAt: now,
score: Math.max(Number(progress.score) || 0, targetScore),
lastAccessedAt: now,
lessonState: mergedState
});
details.push({
lessonNumber: lesson.lessonNumber,
lessonId: lesson.id,
status: 'marked_complete'
});
}
return {
courseId: cid,
throughLessonNumber: maxNum,
lessonsConsidered: lessons.length,
details
};
}
// ========== GRAMMAR EXERCISE METHODS ==========
async getExerciseTypes() {
const types = await VocabGrammarExerciseType.findAll({
order: [['name', 'ASC']]
});
return types.map(t => t.get({ plain: true }));
}
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe, ob User Besitzer des Kurses ist
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add grammar exercises');
err.status = 403;
throw err;
}
const exercise = await VocabGrammarExercise.create({
lessonId: lesson.id,
exerciseTypeId: Number(exerciseTypeId),
exerciseNumber: Number(exerciseNumber),
title,
instruction,
questionData,
answerData,
explanation,
createdByUserId: user.id
});
return exercise.get({ plain: true });
}
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: lesson.id },
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
order: [['exerciseNumber', 'ASC']]
});
const plainLesson = lesson.get({ plain: true });
const list = exercises.map((e) => e.get({ plain: true }));
// Normalize questionData for gap-fill hints: if text contains placeholders
list.forEach((ex) => {
try {
const qData = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData || '{}') : (ex.questionData || {});
const text = String(qData.text || qData.question || '').trim();
const hasUnderscoreGap = /_{3,}/.test(text);
const hasBraceGap = /\{\s*gap\s*\}/i.test(text);
const hasParenPlaceholders = /\(\s*_{0,}\s*\)/.test(text);
// If answerData suggests a gap-like exercise and question text contains gap markers, ensure type is gap_fill
if ((hasUnderscoreGap || hasBraceGap || hasParenPlaceholders) && qData.type !== 'gap_fill') {
qData.type = 'gap_fill';
ex.questionData = qData;
}
// If answerData has explicit answers but there's no question text, and exerciseTypeId indicates input, add a placeholder
const aData = typeof ex.answerData === 'string' ? JSON.parse(ex.answerData || '{}') : (ex.answerData || {});
const answers = Array.isArray(aData.answers) ? aData.answers : (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []);
if ((!qData.text && (!qData.question || String(qData.question).trim() === '')) && answers.length && qData.type !== 'multiple_choice') {
// build a minimal gap_fill text using learning hint if available in ex.title
const learningHintMatch = String(ex.title || '').match(/:(.*)$/);
const hint = learningHintMatch ? learningHintMatch[1].trim() : '';
qData.type = 'gap_fill';
qData.text = hint ? `{gap} (${hint})` : '{gap}';
ex.questionData = qData;
}
} catch (err) {
// ignore parse errors
}
});
return await this._mergeSyntheticChapterLexemeMcExercises(plainLesson, list);
}
async getGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
return exercise.get({ plain: true });
}
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
const user = await this._getUserByHashedId(hashedUserId);
const exIdStr = String(exerciseId ?? '');
const synMatch = /^syn-(\d+)-(\d+)-l2r$/.exec(exIdStr);
if (synMatch) {
return this._checkSyntheticLexemeMcAnswer(
user,
Number(synMatch[1]),
Number(synMatch[2]),
userAnswer
);
}
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: exercise.lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const originalAnswerData = typeof exercise.answerData === 'string'
? JSON.parse(exercise.answerData)
: exercise.answerData;
const questionData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: exercise.questionData;
const effectiveAnswerData = exercise.exerciseTypeId === 2
? await this._expandMultipleChoiceAnswerData(exercise, originalAnswerData, questionData)
: originalAnswerData;
// Überprüfe Antwort
const isCorrect = this._checkAnswer(effectiveAnswerData, questionData, userAnswer, exercise.exerciseTypeId);
// Speichere Fortschritt
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
where: { userId: user.id, exerciseId: exercise.id },
defaults: {
userId: user.id,
exerciseId: exercise.id,
attempts: 1,
correctAttempts: isCorrect ? 1 : 0,
lastAttemptAt: new Date(),
completed: false
}
});
if (!created) {
progress.attempts += 1;
if (isCorrect) {
progress.correctAttempts += 1;
if (!progress.completed) {
progress.completed = true;
progress.completedAt = new Date();
}
}
progress.lastAttemptAt = new Date();
await progress.save();
} else if (isCorrect) {
progress.completed = true;
progress.completedAt = new Date();
await progress.save();
}
// Extrahiere richtige Antwort und Alternativen
const answerData = effectiveAnswerData;
let correctAnswer = null;
let alternatives = [];
// Für Multiple Choice: Extrahiere die richtige(n) Antwort(en) aus dem Index/den Indizes
if (exercise.exerciseTypeId === 2 && answerData.correctAnswer !== undefined) {
const options = questionData?.options || [];
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
let correctIndices = [];
if (Array.isArray(answerData.correctAnswer)) {
correctIndices = answerData.correctAnswer.map(idx => Number(idx));
} else {
correctIndices = [Number(answerData.correctAnswer)];
}
// Extrahiere alle korrekten Antworten
const correctAnswersList = correctIndices
.map(idx => options[idx])
.filter(opt => opt !== undefined);
if (correctAnswersList.length > 0) {
// Wenn mehrere richtige Antworten: Zeige alle an, getrennt durch " / "
correctAnswer = correctAnswersList.join(' / ');
}
// Alternativen sind alle anderen Optionen (nicht korrekte)
alternatives = options.filter((opt, idx) => !correctIndices.includes(idx));
}
// Für Gap Fill: Extrahiere aus answers Array
else if (exercise.exerciseTypeId === 1 && answerData.answers) {
correctAnswer = Array.isArray(answerData.answers)
? answerData.answers.join(', ')
: answerData.answers;
}
// Für Reading Aloud: Extrahiere den erwarteten Text
else if (questionData.type === 'reading_aloud') {
correctAnswer = questionData.text || answerData.expectedText || '';
}
// Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter
else if (questionData.type === 'speaking_from_memory') {
correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || [];
}
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
if (Array.isArray(rawCorrect)) {
correctAnswer = rawCorrect.join(' / ');
} else {
correctAnswer = rawCorrect || questionData.modelAnswer || '';
}
alternatives = answerData.alternatives || questionData.keywords || [];
}
// Fallback: Versuche correct oder correctAnswer
else {
correctAnswer = Array.isArray(answerData.correct)
? answerData.correct[0]
: (answerData.correct || answerData.correctAnswer);
alternatives = answerData.alternatives || [];
}
return {
correct: isCorrect,
correctAnswer: correctAnswer,
alternatives: alternatives,
explanation: exercise.explanation,
progress: progress.get({ plain: true })
};
}
async _getExerciseTypeIdByName(typeName) {
const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } });
return type ? type.id : null;
}
_extractMultipleChoiceIndices(answerData) {
if (!answerData) return [];
if (answerData.correctAnswer !== undefined) {
return Array.isArray(answerData.correctAnswer)
? answerData.correctAnswer.map(idx => Number(idx)).filter(Number.isInteger)
: [Number(answerData.correctAnswer)].filter(Number.isInteger);
}
if (answerData.correct !== undefined) {
return Array.isArray(answerData.correct)
? answerData.correct.map(idx => Number(idx)).filter(Number.isInteger)
: [Number(answerData.correct)].filter(Number.isInteger);
}
return [];
}
_getMultipleChoicePrompt(questionData) {
return this._normalizeTextAnswer(
questionData?.question || questionData?.text || questionData?.prompt || ''
);
}
async _expandMultipleChoiceAnswerData(exercise, answerData, questionData) {
const options = Array.isArray(questionData?.options) ? questionData.options : [];
const baseIndices = this._extractMultipleChoiceIndices(answerData);
if (!options.length || !baseIndices.length || !exercise?.lessonId) {
return answerData;
}
const prompt = this._getMultipleChoicePrompt(questionData);
if (!prompt) {
return answerData;
}
const optionIndexMap = new Map();
options.forEach((option, index) => {
const normalizedOption = this._normalizeTextAnswer(option);
if (!normalizedOption) return;
const existing = optionIndexMap.get(normalizedOption) || [];
existing.push(index);
optionIndexMap.set(normalizedOption, existing);
});
const lessonExercises = await VocabGrammarExercise.findAll({
where: {
lessonId: exercise.lessonId,
exerciseTypeId: 2
},
attributes: ['id', 'questionData', 'answerData']
});
const expandedIndices = new Set(baseIndices);
lessonExercises.forEach((candidate) => {
const candidateQuestionData = typeof candidate.questionData === 'string'
? JSON.parse(candidate.questionData)
: candidate.questionData;
const candidatePrompt = this._getMultipleChoicePrompt(candidateQuestionData);
if (candidatePrompt !== prompt) {
return;
}
const candidateAnswerData = typeof candidate.answerData === 'string'
? JSON.parse(candidate.answerData)
: candidate.answerData;
const candidateOptions = Array.isArray(candidateQuestionData?.options) ? candidateQuestionData.options : [];
const candidateIndices = this._extractMultipleChoiceIndices(candidateAnswerData);
candidateIndices.forEach((candidateIndex) => {
const candidateOption = candidateOptions[candidateIndex];
const normalizedOption = this._normalizeTextAnswer(candidateOption);
const matchingIndices = optionIndexMap.get(normalizedOption) || [];
matchingIndices.forEach((matchingIndex) => expandedIndices.add(matchingIndex));
});
});
return {
...answerData,
correctAnswer: Array.from(expandedIndices).sort((a, b) => a - b)
};
}
_checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) {
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
if (!answerData || userAnswer === undefined || userAnswer === null) return false;
// Parse JSON strings
const parsedAnswerData = typeof answerData === 'string' ? JSON.parse(answerData) : answerData;
const parsedQuestionData = typeof questionData === 'string' ? JSON.parse(questionData) : questionData;
// Für Multiple Choice: Prüfe ob userAnswer (Index) mit correctAnswer (Index oder Array von Indizes) übereinstimmt
if (exerciseTypeId === 2) { // multiple_choice
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
let correctIndices = [];
if (parsedAnswerData.correctAnswer !== undefined) {
// Kann ein einzelner Index oder ein Array von Indizes sein
if (Array.isArray(parsedAnswerData.correctAnswer)) {
correctIndices = parsedAnswerData.correctAnswer.map(idx => Number(idx));
} else {
correctIndices = [Number(parsedAnswerData.correctAnswer)];
}
} else if (parsedAnswerData.correct !== undefined) {
// Fallback: Prüfe auch 'correct' Feld
if (Array.isArray(parsedAnswerData.correct)) {
correctIndices = parsedAnswerData.correct.map(idx => Number(idx));
} else {
correctIndices = [Number(parsedAnswerData.correct)];
}
}
if (correctIndices.length === 0) return false;
const options = Array.isArray(parsedQuestionData?.options) ? parsedQuestionData.options : [];
const correctTexts = correctIndices
.map((i) => options[i])
.filter((opt) => opt !== undefined && opt !== null);
const norm = (s) => this._normalizeTextAnswer(s);
// Nach zufälligen Distraktoren: Client sendet gewählten Optionstext statt Index
if (typeof userAnswer === 'string') {
const u = norm(userAnswer);
if (!u) return false;
return correctTexts.some((t) => this._isEquivalentAnswer(userAnswer, t));
}
// Legacy: Index in die gespeicherten (nicht gemischten) Optionen
const userIndex = Number(userAnswer);
if (Number.isNaN(userIndex)) return false;
return correctIndices.includes(userIndex);
}
// Für Lückentext: Normalisiere und vergleiche
if (exerciseTypeId === 1) { // gap_fill
const correctAnswers = parsedAnswerData.answers || parsedAnswerData.correct || [];
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
// userAnswer ist ein Array von Antworten
if (Array.isArray(userAnswer)) {
if (userAnswer.length !== correctAnswersArray.length) return false;
return userAnswer.every((ans, idx) => {
const correct = correctAnswersArray[idx];
return this._isEquivalentAnswer(ans, correct);
});
} else {
// Fallback: Einzelne Antwort
return correctAnswersArray.some((correct) => this._isEquivalentAnswer(userAnswer, correct));
}
}
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
// Vergleiche mit dem erwarteten Text aus questionData.text
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
const normalizedExpected = this._normalizeTextAnswer(expectedText);
const normalizedUser = this._normalizeTextAnswer(userAnswer);
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
if (parsedQuestionData.type === 'reading_aloud') {
// Exakter Vergleich (kann später mit Levenshtein erweitert werden)
return normalizedUser === normalizedExpected;
}
// Für speaking_from_memory: Flexibler Vergleich (Schlüsselwörter)
if (parsedQuestionData.type === 'speaking_from_memory') {
const keywords = parsedQuestionData.keywords || [];
if (keywords.length === 0) {
// Fallback: Exakter Vergleich
return normalizedUser === normalizedExpected;
}
// Prüfe ob alle Schlüsselwörter vorhanden sind
return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
if (parsedQuestionData.type === 'situational_response') {
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
if (keywords.length > 0) {
const normalizedUser = this._normalizeTextAnswer(userAnswer);
return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
return answers
.filter(Boolean)
.some((answer) => this._isEquivalentAnswer(userAnswer, answer));
}
// Für andere Typen: einfacher String-Vergleich inkl. hinterlegter Alternativen
const primaryAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
const primaryAnswersArray = Array.isArray(primaryAnswers) ? primaryAnswers : [primaryAnswers];
const alternativeAnswersArray = Array.isArray(parsedAnswerData.alternatives)
? parsedAnswerData.alternatives
: [];
const correctAnswersArray = [...primaryAnswersArray, ...alternativeAnswersArray];
return correctAnswersArray
.filter(Boolean)
.some((correct) => this._isEquivalentAnswer(userAnswer, correct));
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
const numericExerciseIds = exercises
.map((e) => e.id)
.filter((id) => /^\d+$/.test(String(id)));
const progress = numericExerciseIds.length
? await VocabGrammarExerciseProgress.findAll({
where: {
userId: user.id,
exerciseId: { [Op.in]: numericExerciseIds }
}
})
: [];
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
return exercises.map(exercise => ({
...exercise,
progress: progressMap.get(exercise.id) || null
}));
}
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update exercises');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (instruction !== undefined) updates.instruction = instruction;
if (questionData !== undefined) updates.questionData = questionData;
if (answerData !== undefined) updates.answerData = answerData;
if (explanation !== undefined) updates.explanation = explanation;
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
await exercise.update(updates);
return exercise.get({ plain: true });
}
async deleteGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete exercises');
err.status = 403;
throw err;
}
await exercise.destroy();
return { success: true };
}
/**
* Explizite Zuordnung der Antwortsprache (sprachneutral).
* questionData.answerLanguage: 'target' | 'native'
* oder questionData.answerLanguageId: 1 = target (Lernsprache), 2 = native (Muttersprache)
* Ohne diese Felder: 'unknown' (kein Eintrag in den Distraktor-Pools für diese Frage).
* @param {object} questionData
* @returns {'target'|'native'|'unknown'}
*/
_resolveMcAnswerSide(questionData) {
if (!questionData || typeof questionData !== 'object') return 'unknown';
const raw = questionData.answerLanguage;
if (typeof raw === 'string') {
const s = raw.trim().toLowerCase();
if (s === 'target' || s === 'learning' || s === 'l2') return 'target';
if (s === 'native' || s === 'l1') return 'native';
}
const id = questionData.answerLanguageId;
if (id === 1 || id === '1') return 'target';
if (id === 2 || id === '2') return 'native';
return 'unknown';
}
/**
* Sammelt Vokabeln aus allen Multiple-Choice-Übungen von Lektionen **vor** der angegebenen Lektion
* (gleicher Kurs), getrennt nach Ziel- vs. Muttersprache anhand von answerLanguage / answerLanguageId.
*/
async getVocabDistractorPool(hashedUserId, courseId, beforeLessonId) {
if (!beforeLessonId) {
const err = new Error('beforeLessonId is required');
err.status = 400;
throw err;
}
const user = await this._getUserByHashedId(hashedUserId);
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) },
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const currentLesson = await VocabCourseLesson.findByPk(beforeLessonId);
if (!currentLesson || currentLesson.courseId !== Number(courseId)) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
const priorLessons = await VocabCourseLesson.findAll({
where: {
courseId: Number(courseId),
lessonNumber: { [Op.lt]: currentLesson.lessonNumber },
},
attributes: ['id'],
order: [['lessonNumber', 'ASC']],
});
const lessonIds = priorLessons.map((l) => l.id);
if (lessonIds.length === 0) {
return { target: [], native: [] };
}
const exercises = await VocabGrammarExercise.findAll({
where: {
lessonId: { [Op.in]: lessonIds },
exerciseTypeId: 2,
},
attributes: ['questionData'],
});
const target = new Set();
const native = new Set();
for (const ex of exercises) {
const qd =
typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
const opts = qd?.options;
if (!Array.isArray(opts)) continue;
const side = this._resolveMcAnswerSide(qd);
if (side === 'target') {
opts.forEach((o) => target.add(String(o).trim()));
} else if (side === 'native') {
opts.forEach((o) => native.add(String(o).trim()));
}
}
return {
target: [...target],
native: [...native],
};
}
}