4589 lines
154 KiB
JavaScript
4589 lines
154 KiB
JavaScript
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
|
||
};
|
||
}
|
||
|
||
const intervals = [0, 1, 3, 7, 14, 30, 60, 120, 240];
|
||
let nextStage = Math.min(intervals.length - 1, previousStage + 1);
|
||
|
||
if (normalizedRating === 'hard') {
|
||
nextStage = Math.max(1, previousStage);
|
||
}
|
||
if (normalizedRating === 'easy') {
|
||
nextStage = Math.min(intervals.length - 1, previousStage + 2);
|
||
}
|
||
|
||
let intervalDays = intervals[nextStage] ?? Math.max(1, previousInterval * 2);
|
||
if (normalizedRating === 'hard') {
|
||
intervalDays = Math.max(1, Math.ceil(Math.max(previousInterval, 1) * 1.2));
|
||
}
|
||
|
||
return {
|
||
stage: nextStage,
|
||
intervalDays,
|
||
nextDueAt: new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000),
|
||
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],
|
||
};
|
||
}
|
||
}
|