feat(vocab): enhance vocabService and VocabLessonView for improved exercise handling
All checks were successful
Deploy to production / deploy (push) Successful in 2m58s
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:
@@ -58,12 +58,14 @@ export default class VocabService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitized = {};
|
const sanitized = {};
|
||||||
|
const synMcId = /^syn-\d+-\d+-l2r$/;
|
||||||
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
sanitized[exerciseId] = this._sanitizeStringArray(answer, {
|
sanitized[idStr] = this._sanitizeStringArray(answer, {
|
||||||
maxItems: 12,
|
maxItems: 12,
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
keepEmpty: true
|
keepEmpty: true
|
||||||
@@ -71,11 +73,11 @@ export default class VocabService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof answer === 'string') {
|
if (typeof answer === 'string') {
|
||||||
sanitized[exerciseId] = this._sanitizeShortString(answer, 200);
|
sanitized[idStr] = this._sanitizeShortString(answer, 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof answer === 'number' && Number.isFinite(answer)) {
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const synMcId = /^syn-\d+-\d+-l2r$/;
|
||||||
const sanitized = {};
|
const sanitized = {};
|
||||||
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitized[exerciseId] = {
|
sanitized[idStr] = {
|
||||||
correct: Boolean(result.correct),
|
correct: Boolean(result.correct),
|
||||||
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
|
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
|
||||||
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
|
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
|
||||||
@@ -1961,6 +1965,246 @@ export default class VocabService {
|
|||||||
return { success: true };
|
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) {
|
async getLesson(hashedUserId, lessonId) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||||
@@ -2017,6 +2261,11 @@ export default class VocabService {
|
|||||||
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
|
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plainLesson.grammarExercises = await this._mergeSyntheticChapterLexemeMcExercises(
|
||||||
|
plainLesson,
|
||||||
|
plainLesson.grammarExercises || []
|
||||||
|
);
|
||||||
|
|
||||||
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
|
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
|
||||||
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
|
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
|
||||||
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson);
|
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson);
|
||||||
@@ -2772,7 +3021,9 @@ export default class VocabService {
|
|||||||
order: [['exerciseNumber', 'ASC']]
|
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) {
|
async getGrammarExercise(hashedUserId, exerciseId) {
|
||||||
@@ -2802,6 +3053,16 @@ export default class VocabService {
|
|||||||
|
|
||||||
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
|
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
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, {
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
||||||
include: [
|
include: [
|
||||||
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
||||||
@@ -3150,13 +3411,17 @@ export default class VocabService {
|
|||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
|
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
|
||||||
|
|
||||||
const exerciseIds = exercises.map(e => e.id);
|
const numericExerciseIds = exercises
|
||||||
const progress = await VocabGrammarExerciseProgress.findAll({
|
.map((e) => e.id)
|
||||||
|
.filter((id) => /^\d+$/.test(String(id)));
|
||||||
|
const progress = numericExerciseIds.length
|
||||||
|
? await VocabGrammarExerciseProgress.findAll({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
exerciseId: { [Op.in]: exerciseIds }
|
exerciseId: { [Op.in]: numericExerciseIds }
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
|
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
|
||||||
|
|
||||||
|
|||||||
@@ -979,7 +979,7 @@ export default {
|
|||||||
const durationBonus = Math.max(0, Math.round((this.lesson?.targetMinutes || 0) / 5) - 1);
|
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 baseTarget = Math.ceil((Math.max(vocabCount, 4) * 1.35) + (exerciseCount * 0.35) + durationBonus);
|
||||||
const weightedTarget = Math.ceil(baseTarget * this.lessonComplexityWeight);
|
const weightedTarget = Math.ceil(baseTarget * this.lessonComplexityWeight);
|
||||||
return Math.min(24, Math.max(6, weightedTarget));
|
return Math.max(6, Math.min(120, weightedTarget));
|
||||||
},
|
},
|
||||||
trainerReviewBlendStart() {
|
trainerReviewBlendStart() {
|
||||||
return Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.4));
|
return Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.4));
|
||||||
@@ -989,7 +989,7 @@ export default {
|
|||||||
},
|
},
|
||||||
trainerExerciseUnlockAttempts() {
|
trainerExerciseUnlockAttempts() {
|
||||||
const unlockTarget = this.trainerNewFocusTarget + Math.ceil((this.effectiveExercises?.length || 0) * 0.25);
|
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() {
|
currentReviewShare() {
|
||||||
if (!this.hasPreviousVocab) {
|
if (!this.hasPreviousVocab) {
|
||||||
|
|||||||
Reference in New Issue
Block a user