feat(vocab): add vocab distractor pool functionality
All checks were successful
Deploy to production / deploy (push) Successful in 3m9s

- Implemented a new endpoint to retrieve a pool of distractors for vocabulary exercises based on prior lessons.
- Updated the VocabController and VocabRouter to include the new getVocabDistractorPool method.
- Enhanced VocabService to classify questions and gather distractors from previous lessons.
- Modified VocabLessonView to fetch and utilize the distractor pool for multiple-choice exercises, improving the learning experience.
This commit is contained in:
Torsten Schulz (local)
2026-03-30 15:13:10 +02:00
parent 2b83c45e97
commit 3d9bca099c
6 changed files with 249 additions and 20 deletions

View File

@@ -1932,9 +1932,22 @@ export default class VocabService {
}
if (correctIndices.length === 0) return false;
// userAnswer ist der Index (0, 1, 2, ...)
const correctTexts = correctIndices
.map((i) => options[i])
.filter((opt) => opt !== undefined && opt !== null);
const norm = (s) => String(s).trim().toLowerCase();
// 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) => norm(t) === u);
}
// Legacy: Index in die gespeicherten (nicht gemischten) Optionen
const userIndex = Number(userAnswer);
if (Number.isNaN(userIndex)) return false;
return correctIndices.includes(userIndex);
}
@@ -2083,4 +2096,88 @@ export default class VocabService {
await exercise.destroy();
return { success: true };
}
/**
* Ordnet eine Multiple-Choice-Frage der Zielsprache (zu lernen) oder Muttersprache (Erklärung) zu,
* damit Distraktoren aus dem passenden Wortpool gewählt werden können.
* @returns {'target'|'native'|'unknown'}
*/
_classifyMcQuestionSide(question) {
const q = String(question || '');
if (/Wie sagt man\s/i.test(q) || /Übersetze/i.test(q)) return 'target';
if (/Was bedeutet/i.test(q)) 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 der Frageformulierung.
*/
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 question = qd?.question || '';
const opts = qd?.options;
if (!Array.isArray(opts)) continue;
const side = this._classifyMcQuestionSide(question);
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],
};
}
}