feat(vocab): enhance vocabService and VocabLessonView for improved exercise handling
All checks were successful
Deploy to production / deploy (push) Successful in 2m58s

- Updated VocabService to include validation for synthetic exercise IDs and added methods for generating multiple-choice exercises based on chapter lexemes.
- Implemented a seeded shuffle function to randomize distractor options in multiple-choice questions, ensuring varied user experiences.
- Modified VocabLessonView to adjust target calculations for lesson goals and unlock attempts, increasing the maximum limits for better flexibility in user progress tracking.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 14:23:21 +02:00
parent 3d2ccd620a
commit 77e6f8d3e8
2 changed files with 283 additions and 18 deletions

View File

@@ -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 })]));

View File

@@ -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) {