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 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 })]));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user