From 3d9bca099ce7538eb50424dbd7104820779718e5 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Mon, 30 Mar 2026 15:13:10 +0200 Subject: [PATCH] feat(vocab): add vocab distractor pool functionality - 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. --- backend/controllers/vocabController.js | 3 + backend/routers/vocabRouter.js | 1 + .../scripts/create-bisaya-course-content.js | 7 +- backend/services/vocabService.js | 101 +++++++++++++- .../update-bisaya-babay-multiple-choice.sql | 33 +++++ frontend/src/views/social/VocabLessonView.vue | 124 +++++++++++++++--- 6 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 backend/sql/update-bisaya-babay-multiple-choice.sql diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index f6fd579..38793e1 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -27,6 +27,9 @@ class VocabController { this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 }); this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query)); this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId)); + this.getVocabDistractorPool = this._wrapWithUser((userId, req) => + this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId) + ); this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode)); this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body)); this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId)); diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 59d81a3..8760e25 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -28,6 +28,7 @@ router.post('/courses', vocabController.createCourse); router.get('/courses', vocabController.getCourses); router.get('/courses/my', vocabController.getMyCourses); router.post('/courses/find-by-code', vocabController.getCourseByShareCode); +router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool); router.get('/courses/:courseId', vocabController.getCourse); router.put('/courses/:courseId', vocabController.updateCourse); router.delete('/courses/:courseId', vocabController.deleteCourse); diff --git a/backend/scripts/create-bisaya-course-content.js b/backend/scripts/create-bisaya-course-content.js index 9100d24..b4b8294 100644 --- a/backend/scripts/create-bisaya-course-content.js +++ b/backend/scripts/create-bisaya-course-content.js @@ -107,13 +107,14 @@ const BISAYA_EXERCISES = { questionData: { type: 'multiple_choice', question: 'Was bedeutet "Babay"?', - options: ['Tschüss / Auf Wiedersehen', 'Wie geht es dir?', 'Guten Tag', 'Ich bin müde'] + options: ['Tschüss', 'Auf Wiedersehen', 'Wie geht es dir?', 'Guten Tag'] }, answerData: { type: 'multiple_choice', - correctAnswer: 0 + // Beide gelten als richtig (Lehnwort von „bye-bye“) + correctAnswer: [0, 1] }, - explanation: '"Babay" ist eine einfache alltägliche Verabschiedung.' + explanation: '"Babay" ist eine einfache Verabschiedung — vergleichbar mit „Tschüss“ oder „Auf Wiedersehen“.' }, withTypeName('dialog_completion', { title: 'Begrüßungsdialog ergänzen', diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 5d55d2d..b83a2c4 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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], + }; + } } diff --git a/backend/sql/update-bisaya-babay-multiple-choice.sql b/backend/sql/update-bisaya-babay-multiple-choice.sql new file mode 100644 index 0000000..cb2ec12 --- /dev/null +++ b/backend/sql/update-bisaya-babay-multiple-choice.sql @@ -0,0 +1,33 @@ +-- Bisaya-Kurs: Übung „Abschiedsform erkennen“ (Was bedeutet „Babay“?) +-- Statt einer kombinierten Option „Tschüss / Auf Wiedersehen“: zwei gültige Antworten +-- (correctAnswer [0, 1]), passend zu backend/services/vocabService.js (multiple_choice). +-- +-- Nach dem Einspielen: Frontend deployen, falls der Vokabeltrainer aus Übungen +-- mehrere korrekte MC-Indizes verarbeiten soll (VocabLessonView importantVocab). + +UPDATE community.vocab_grammar_exercise AS e +SET + question_data = jsonb_build_object( + 'type', 'multiple_choice', + 'question', 'Was bedeutet "Babay"?', + 'options', jsonb_build_array( + 'Tschüss', + 'Auf Wiedersehen', + 'Wie geht es dir?', + 'Guten Tag' + ) + ), + answer_data = jsonb_build_object( + 'type', 'multiple_choice', + 'correctAnswer', jsonb_build_array(0, 1) + ), + explanation = '"Babay" ist eine einfache Verabschiedung — vergleichbar mit „Tschüss“ oder „Auf Wiedersehen“.' +WHERE e.exercise_type_id = 2 + AND e.title = 'Abschiedsform erkennen' + AND ( + e.question_data->>'question' = 'Was bedeutet "Babay"?' + OR (e.question_data->'options'->>0) = 'Tschüss / Auf Wiedersehen' + ); + +-- Erwartet: mindestens eine Zeile pro betroffener Übung (pro Kurs/Lektion). +-- SELECT COUNT(*) vorher/nachher bei Bedarf prüfen. diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 5e667c5..3a2fd28 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -735,7 +735,11 @@ export default { nextLessonId: null, showCompletionDialog: false, showErrorDialog: false, - errorMessage: '' + errorMessage: '', + /** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */ + distractorPool: { target: [], native: [] }, + /** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */ + mcRandomizedOptions: {} }; }, computed: { @@ -1091,6 +1095,8 @@ export default { this.vocabTrainerPhase = 'current'; this.vocabTrainerCurrentAttempts = 0; this.vocabTrainerReviewAttempts = 0; + this.distractorPool = { target: [], native: [] }; + this.mcRandomizedOptions = {}; // Reset Flags this.isCheckingLessonCompletion = false; this.isNavigatingToNext = false; @@ -1116,17 +1122,27 @@ export default { this.focusAssistantCard(); }); } - // Initialisiere mit effectiveExercises (für Review: reviewVocabExercises, sonst: grammarExercises) - this.$nextTick(async () => { - const exercises = this.effectiveExercises; - if (exercises && exercises.length > 0) { - debugLog('[VocabLessonView] Übungen für Kapitel-Prüfung:', exercises.length); - this.initializeExercises(exercises); - } else { - debugLog('[VocabLessonView] Lade Übungen separat...'); - await this.loadGrammarExercises(); - } - }); + try { + const poolRes = await apiClient.get(`/api/vocab/courses/${this.courseId}/distractor-pool`, { + params: { beforeLessonId: this.lessonId } + }); + this.distractorPool = poolRes.data || { target: [], native: [] }; + } catch (poolErr) { + console.warn('[VocabLessonView] Distraktor-Pool nicht geladen:', poolErr); + this.distractorPool = { target: [], native: [] }; + } + await this.$nextTick(); + let exercises = this.effectiveExercises; + if (!exercises || exercises.length === 0) { + debugLog('[VocabLessonView] Lade Übungen separat...'); + await this.loadGrammarExercises(); + exercises = this.effectiveExercises; + } + if (exercises && exercises.length > 0) { + debugLog('[VocabLessonView] Übungen für Kapitel-Prüfung:', exercises.length); + this.initializeExercises(exercises); + this.buildMcRandomizedOptions(); + } debugLog('[VocabLessonView] loadLesson abgeschlossen'); } catch (e) { console.error('[VocabLessonView] Fehler beim Laden der Lektion:', e); @@ -1224,7 +1240,6 @@ export default { const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`); const exercises = res.data || []; this.lesson.grammarExercises = exercises; - this.initializeExercises(exercises); } catch (e) { console.error('Konnte Grammatik-Übungen nicht laden:', e); this.lesson.grammarExercises = []; @@ -1284,6 +1299,74 @@ export default { ? JSON.parse(exercise.answerData) : exercise.answerData; }, + _shuffleArray(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; + }, + /** Muss zu backend/services/vocabService _classifyMcQuestionSide passen */ + _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'; + }, + /** + * Zufällige Distraktoren aus gelernten Vokabeln (vorherige Lektionen), gleiche Sprache wie die richtigen Optionen. + * Deaktivierbar pro Übung: questionData.randomizeDistractors === false + */ + randomizeMcOptionsIfPossible(exercise) { + const q = this.getQuestionData(exercise); + const a = this.getAnswerData(exercise); + if (!q || !a || q.type !== 'multiple_choice' || q.randomizeDistractors === false) return null; + const qtext = q.question || ''; + const side = this._classifyMcQuestionSide(qtext); + if (side === 'unknown') return null; + const options = q.options || []; + let correctIndices = []; + if (a.correctAnswer !== undefined) { + correctIndices = Array.isArray(a.correctAnswer) ? a.correctAnswer.map(Number) : [Number(a.correctAnswer)]; + } else if (a.correct !== undefined) { + correctIndices = Array.isArray(a.correct) ? a.correct.map(Number) : [Number(a.correct)]; + } + const correctTexts = correctIndices.map((i) => options[i]).filter((t) => t !== undefined && t !== null); + if (!correctTexts.length) return null; + const totalSlots = options.length; + const need = totalSlots - correctTexts.length; + if (need <= 0) return null; + const norm = (s) => String(s).trim().toLowerCase(); + const correctSet = new Set(correctTexts.map(norm)); + const poolRaw = side === 'target' ? this.distractorPool.target : this.distractorPool.native; + let poolFiltered = poolRaw.filter((w) => w && !correctSet.has(norm(w))); + poolFiltered = this._shuffleArray(poolFiltered); + let picked = poolFiltered.slice(0, need); + if (picked.length < need) { + const wrongFromDb = options.filter((_, i) => !correctIndices.includes(i)); + const shuffledWrong = this._shuffleArray(wrongFromDb); + for (const w of shuffledWrong) { + if (picked.length >= need) break; + if (!picked.some((p) => norm(p) === norm(w))) picked.push(w); + } + } + if (picked.length < need) return null; + const all = this._shuffleArray([...correctTexts, ...picked]); + return { options: all, useTextAnswer: true }; + }, + buildMcRandomizedOptions() { + this.mcRandomizedOptions = {}; + const exercises = this.effectiveExercises; + if (!exercises) return; + exercises.forEach((ex) => { + if (this.getExerciseType(ex) !== 'multiple_choice') return; + const built = this.randomizeMcOptionsIfPossible(ex); + if (built) { + this.mcRandomizedOptions[ex.id] = built; + } + }); + }, getQuestionText(exercise) { const qData = this.getQuestionData(exercise); if (!qData) return exercise.title; @@ -1301,6 +1384,10 @@ export default { return exercise.title; }, getOptions(exercise) { + const custom = this.mcRandomizedOptions[exercise.id]; + if (custom && Array.isArray(custom.options) && custom.options.length > 0) { + return custom.options; + } const qData = this.getQuestionData(exercise); return qData?.options || []; }, @@ -1340,8 +1427,15 @@ export default { answer = [answer]; } } else if (exerciseType === 'multiple_choice') { - // Multiple Choice: Index als Zahl - answer = Number(answer); + const ro = this.mcRandomizedOptions[exercise.id]; + if (ro && ro.useTextAnswer) { + const opts = this.getOptions(exercise); + const idx = Number(answer); + const pick = opts[idx]; + answer = pick !== undefined && pick !== null ? String(pick) : ''; + } else { + answer = Number(answer); + } } else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') { // Transformation: String answer = String(answer || '').trim();