From b1d04812a457d40b55df67db26a64389a10c7324 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 26 May 2026 15:11:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20erweitere=20VocabService=20um=20Unterst?= =?UTF-8?q?=C3=BCtzung=20f=C3=BCr=20Grammatik=C3=BCbungen=20aus=20vorherig?= =?UTF-8?q?en=20Lektionen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/vocabService.js | 110 +++++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 5 deletions(-) diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 60d0c4b..0ebfbad 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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);