feat: erweitere VocabService um Unterstützung für Grammatikübungen aus vorherigen Lektionen
All checks were successful
Deploy to production / deploy (push) Successful in 2m6s

This commit is contained in:
Torsten Schulz (local)
2026-05-26 15:11:01 +02:00
parent 04ab072dc5
commit b1d04812a4

View File

@@ -2664,28 +2664,99 @@ export default class VocabService {
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
return list;
}
const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
let rows = [];
// If this lesson belongs to a week, prefer vocab from previous lessons of the same week
if (plainLesson.weekNumber) {
try {
const weekExercises = await this._getWeekVocabExercises(plainLesson.courseId, plainLesson.weekNumber, plainLesson.lessonNumber);
const extracted = this._extractTrainerVocabsFromExercises(weekExercises || []);
if (extracted && extracted.length) {
// Map extracted pairs to row-like objects with numeric ids
rows = extracted.map((item, idx) => ({ id: 2000000 + idx, learning: item.learning, reference: item.reference }));
}
} catch (err) {
// ignore and fallback to chapter lexemes
rows = [];
}
}
if (!rows.length) {
rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
}
if (!rows.length) {
plainLesson.chapterLexemeExamCount = 0;
plainLesson.chapterLexemeTrainingCount = 0;
plainLesson.chapterLexemeTraining = [];
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) {
// Skip multi-word learning items (they are sentences, not single lexemes)
// Sampling parameters
const EXAM_MAX = 15; // max items in the chapter exam
const TRAINING_RATIO = 0.67; // fraction of chapter lexemes to include in training pool
const seed = (Number(plainLesson.id) * 100003) >>> 0;
const shuffled = this._seededShuffle(augmentedRows.slice(), seed);
const totalRows = shuffled.length;
const trainingTarget = Math.max(0, Math.min(totalRows, Math.ceil(totalRows * TRAINING_RATIO)));
const examTarget = Math.max(0, Math.min(EXAM_MAX, totalRows));
const examSelected = [];
const trainingSelected = [];
for (const row of shuffled) {
const learningText = String(row.learning || '').trim();
if (!learningText || learningText.split(/\s+/).length > 1) {
// skip multi-word learning items for both exam and training
continue;
}
if (this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) {
continue;
// Fill training pool up to target (allow duplicates between exam and training)
if (trainingSelected.length < trainingTarget) {
trainingSelected.push(row);
}
// For exam, also enforce that the pair is not already covered by an existing MC
if (examSelected.length < examTarget && !this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) {
examSelected.push(row);
}
if (examSelected.length >= examTarget && trainingSelected.length >= trainingTarget) {
break;
}
}
// Fallback: if examSelected is smaller than examTarget, try to relax duplicate check
if (examSelected.length < examTarget) {
for (const row of shuffled) {
if (examSelected.find((r) => Number(r.id) === Number(row.id))) continue;
const learningText = String(row.learning || '').trim();
if (!learningText || learningText.split(/\s+/).length > 1) continue;
examSelected.push(row);
if (examSelected.length >= examTarget) break;
}
}
// Attach metadata for frontend/training use
plainLesson.chapterLexemeExamCount = examSelected.length;
plainLesson.chapterLexemeTrainingCount = trainingSelected.length;
plainLesson.chapterLexemeTraining = trainingSelected.map((r) => ({ id: r.id, learning: r.learning, reference: r.reference }));
// Build synthetic exercises only for examSelected
for (const row of examSelected) {
maxNum += 1;
const ex = this._buildSyntheticLexemeMcExercisePlain(plainLesson.id, row, maxNum);
if (ex) {
list.push(ex);
}
}
return list;
}
@@ -3066,6 +3137,35 @@ export default class VocabService {
return exercises.map(e => e.get({ plain: true }));
}
/**
* Sammelt GrammatikÜbungen aus vorherigen Lektionen derselben Woche
*/
async _getWeekVocabExercises(courseId, weekNumber, currentLessonNumber) {
if (weekNumber == null) return [];
const previousLessons = await VocabCourseLesson.findAll({
where: {
courseId: courseId,
weekNumber: weekNumber,
lessonNumber: { [Op.lt]: currentLessonNumber },
lessonType: { [Op.notIn]: ['review', 'vocab_review'] }
},
attributes: ['id']
});
if (previousLessons.length === 0) return [];
const lessonIds = previousLessons.map(l => l.id);
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: { [Op.in]: lessonIds } },
include: [
{ model: VocabGrammarExerciseType, as: 'exerciseType' },
{ model: VocabCourseLesson, as: 'lesson', attributes: ['id', 'lessonNumber', 'title'] }
],
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'], ['exerciseNumber', 'ASC']]
});
return exercises.map(e => e.get({ plain: true }));
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);