diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index db74357..1ffbfe3 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -58,12 +58,14 @@ export default class VocabService { } const sanitized = {}; + const synMcId = /^syn-\d+-\d+-l2r$/; Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => { - if (!/^\d+$/.test(String(exerciseId))) { + const idStr = String(exerciseId); + if (!/^\d+$/.test(idStr) && !synMcId.test(idStr)) { return; } if (Array.isArray(answer)) { - sanitized[exerciseId] = this._sanitizeStringArray(answer, { + sanitized[idStr] = this._sanitizeStringArray(answer, { maxItems: 12, maxLength: 200, keepEmpty: true @@ -71,11 +73,11 @@ export default class VocabService { return; } if (typeof answer === 'string') { - sanitized[exerciseId] = this._sanitizeShortString(answer, 200); + sanitized[idStr] = this._sanitizeShortString(answer, 200); return; } if (typeof answer === 'number' && Number.isFinite(answer)) { - sanitized[exerciseId] = Math.trunc(answer); + sanitized[idStr] = Math.trunc(answer); } }); @@ -87,13 +89,15 @@ export default class VocabService { return {}; } + const synMcId = /^syn-\d+-\d+-l2r$/; const sanitized = {}; Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => { - if (!/^\d+$/.test(String(exerciseId)) || !result || typeof result !== 'object' || Array.isArray(result)) { + const idStr = String(exerciseId); + if ((!/^\d+$/.test(idStr) && !synMcId.test(idStr)) || !result || typeof result !== 'object' || Array.isArray(result)) { return; } - sanitized[exerciseId] = { + sanitized[idStr] = { correct: Boolean(result.correct), correctAnswer: this._sanitizeShortString(result.correctAnswer, 400), alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }), @@ -1961,6 +1965,246 @@ export default class VocabService { 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') { + return list; + } + const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId); + if (!rows.length) { + 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 })); + for (const row of augmentedRows) { + if (this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) { + continue; + } + 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, { @@ -2016,7 +2260,12 @@ export default class VocabService { plainLesson.reviewLessons = await this._getReviewLessons(plainLesson.courseId, plainLesson.lessonNumber); plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || []; } - + + plainLesson.grammarExercises = await this._mergeSyntheticChapterLexemeMcExercises( + plainLesson, + plainLesson.grammarExercises || [] + ); + plainLesson.didactics = this._buildLessonDidactics(plainLesson); plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson); plainLesson.progress = this._serializeLessonProgress(progress, plainLesson); @@ -2772,7 +3021,9 @@ export default class VocabService { order: [['exerciseNumber', 'ASC']] }); - return exercises.map(e => e.get({ plain: true })); + const plainLesson = lesson.get({ plain: true }); + const list = exercises.map((e) => e.get({ plain: true })); + return await this._mergeSyntheticChapterLexemeMcExercises(plainLesson, list); } async getGrammarExercise(hashedUserId, exerciseId) { @@ -2802,6 +3053,16 @@ export default class VocabService { 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' }] } @@ -3149,14 +3410,18 @@ export default class VocabService { async getGrammarExerciseProgress(hashedUserId, lessonId) { const user = await this._getUserByHashedId(hashedUserId); const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId); - - const exerciseIds = exercises.map(e => e.id); - const progress = await VocabGrammarExerciseProgress.findAll({ - where: { - userId: user.id, - exerciseId: { [Op.in]: exerciseIds } - } - }); + + 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 })])); diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 89a8602..a756288 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -979,7 +979,7 @@ export default { const durationBonus = Math.max(0, Math.round((this.lesson?.targetMinutes || 0) / 5) - 1); const baseTarget = Math.ceil((Math.max(vocabCount, 4) * 1.35) + (exerciseCount * 0.35) + durationBonus); const weightedTarget = Math.ceil(baseTarget * this.lessonComplexityWeight); - return Math.min(24, Math.max(6, weightedTarget)); + return Math.max(6, Math.min(120, weightedTarget)); }, trainerReviewBlendStart() { return Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.4)); @@ -989,7 +989,7 @@ export default { }, trainerExerciseUnlockAttempts() { const unlockTarget = this.trainerNewFocusTarget + Math.ceil((this.effectiveExercises?.length || 0) * 0.25); - return Math.min(28, Math.max(6, unlockTarget)); + return Math.max(6, Math.min(140, unlockTarget)); }, currentReviewShare() { if (!this.hasPreviousVocab) {