From 504f527a3fe9223c957a5a2fa44fef0d2d899988 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 2 Sep 2026 13:52:26 +0200 Subject: [PATCH] feat(exercises): enhance context in gap fill exercises and update migration for route descriptions --- ...0-repair-bisaya-directions-context-gap.cjs | 29 ++++++++ .../scripts/create-bisaya-course-content.js | 20 ++++-- backend/services/vocabService.js | 70 ++++++++++++++++++- 3 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 backend/migrations-active/20260902000000-repair-bisaya-directions-context-gap.cjs diff --git a/backend/migrations-active/20260902000000-repair-bisaya-directions-context-gap.cjs b/backend/migrations-active/20260902000000-repair-bisaya-directions-context-gap.cjs new file mode 100644 index 0000000..09ae53c --- /dev/null +++ b/backend/migrations-active/20260902000000-repair-bisaya-directions-context-gap.cjs @@ -0,0 +1,29 @@ +'use strict'; + +/** + * The generated exercise for "Wege & Verkehr" used a bare `{gap}` while its + * instruction asked for a route description. A gap must be embedded in the + * route so the learner can infer the expected direction from the context. + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + UPDATE community.vocab_grammar_exercise AS exercise + SET title = 'Wege & Verkehr: Satz im Kontext ergänzen', + question_data = '{"type":"gap_fill","text":"{gap}. Tuo. Wala. Duol ra.","gaps":1}'::jsonb, + answer_data = '{"type":"gap_fill","answers":["Diretso"]}'::jsonb, + explanation = 'Die vollständige Wegbeschreibung lautet: "Diretso. Tuo. Wala. Duol ra."' + FROM community.vocab_course_lesson AS lesson + WHERE exercise.lesson_id = lesson.id + AND (lesson.id = 1532 OR (lesson.course_id = 1 AND lesson.lesson_number = 55)) + AND lesson.title = 'Wege & Verkehr' + AND exercise.question_data->>'type' = 'gap_fill' + AND exercise.question_data->>'text' = '{gap}' + AND exercise.answer_data->'answers' @> '["tuo"]'::jsonb; + `); + }, + + async down() { + // Do not restore the context-free task. + } +}; diff --git a/backend/scripts/create-bisaya-course-content.js b/backend/scripts/create-bisaya-course-content.js index edcf6ab..42815db 100755 --- a/backend/scripts/create-bisaya-course-content.js +++ b/backend/scripts/create-bisaya-course-content.js @@ -599,7 +599,8 @@ function buildGapExercise(lessonTitle, pattern) { function buildContextGapExercise(lesson, didactics, pattern) { const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null; - const cue = normalizeText(speakingPrompt?.cue || '').toLowerCase(); + const scenarioCue = normalizeText(speakingPrompt?.cue || ''); + const cue = scenarioCue.toLowerCase(); // A contextual prompt must test a pattern that actually occurs in its // scenario. Otherwise a cultural keyword such as "respeto" can be paired // with an invitation-declining situation. @@ -608,14 +609,21 @@ function buildContextGapExercise(lesson, didactics, pattern) { .find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase())); // Do not hide a complete sentence behind one blank. Learners need enough // visible context to know which word or short phrase belongs in the gap. - const gapExercise = scenarioPattern - ? buildGapExercise(lesson.title, scenarioPattern) - : buildGapExercise(lesson.title, pattern); + const scenarioSteps = scenarioCue + .split(/[.!?]+/) + .map((step) => step.trim()) + .filter(Boolean); + // A multi-step cue is already the concrete scenario. Do not reduce a + // route such as "Diretso. Tuo. Wala. Duol ra." to a bare direction word. + const gapTarget = scenarioSteps.length >= 2 + ? scenarioCue + : (scenarioPattern || pattern); + const gapExercise = buildGapExercise(lesson.title, gapTarget); if (!gapExercise) return null; return { ...gapExercise, - title: `${lesson.title}: Kernmuster ergänzen`, + title: `${lesson.title}: Satz im Kontext ergänzen`, instruction: `Vervollständige die Formulierung passend zur Situation: ${getScenarioPrompt(lesson, didactics)}` }; } @@ -634,7 +642,7 @@ function buildSentenceExercise(lessonTitle, pattern) { instruction: 'Ordne die Wörter zu einem korrekten Bisaya-Satz.', questionData: { type: 'sentence_building', - question: `Baue das Kernmuster aus der Lektion "${lessonTitle}".`, + question: 'Ordne diese Wörter zu einem vollständigen Bisaya-Satz.', tokens }, answerData: { diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 19df4d4..9e8e7fd 100755 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -3617,7 +3617,7 @@ export default class VocabService { const seed = (Number(lessonId) * 100003) >>> 0; const percentage = 40 + (seed % 21); const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100)); - return this._seededShuffle(list.slice(), seed).slice(0, targetCount); + return this._selectConcreteExamExercises(list, targetCount, seed); } _selectCheckpointExamExercises(exercises = [], lessonId) { @@ -3628,7 +3628,73 @@ export default class VocabService { // Checkpoints: smaller sample ~10-30% const percentage = 10 + (seed % 21); const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100)); - return this._seededShuffle(list.slice(), seed).slice(0, targetCount); + return this._selectConcreteExamExercises(list, targetCount, seed); + } + + _getExamExerciseQuestionType(exercise) { + const questionData = typeof exercise?.questionData === 'string' + ? JSON.parse(exercise.questionData) + : (exercise?.questionData || {}); + return String(questionData.type || exercise?.exerciseType?.name || '').trim().toLowerCase(); + } + + _isGenericPatternPrompt(exercise) { + const questionData = typeof exercise?.questionData === 'string' + ? JSON.parse(exercise.questionData) + : (exercise?.questionData || {}); + const prompt = String(questionData.question || questionData.text || exercise?.instruction || ''); + return /(?:kernmuster|zentrales? muster)/i.test(prompt); + } + + /** + * Review and checkpoint exams must be concrete and reliably checkable. + * Prefer gap fills (one is included whenever available), then multiple + * choice, and use open "core pattern" prompts only when there is no other + * material available at all. + */ + _selectConcreteExamExercises(exercises = [], targetCount, seed) { + const list = Array.isArray(exercises) ? exercises : []; + const groups = { + gap: [], + multipleChoice: [], + concreteOther: [], + fallback: [] + }; + + for (const exercise of list) { + const type = this._getExamExerciseQuestionType(exercise); + if (type === 'gap_fill') { + groups.gap.push(exercise); + } else if (type === 'multiple_choice') { + groups.multipleChoice.push(exercise); + } else if (!this._isGenericPatternPrompt(exercise) + && !['speaking_from_memory', 'reading_aloud'].includes(type)) { + groups.concreteOther.push(exercise); + } else { + groups.fallback.push(exercise); + } + } + + const selected = []; + const addUntilFull = (items, groupSeed) => { + for (const exercise of this._seededShuffle(items, groupSeed)) { + if (selected.length >= targetCount) break; + selected.push(exercise); + } + }; + + // A gap fill is a good, unambiguous opening question and avoids exams + // consisting only of abstract sentence-building prompts. Keep the rest + // balanced so a larger exam does not become a list of gaps only. + const shuffledGaps = this._seededShuffle(groups.gap, (seed ^ 0x1f123bb5) >>> 0); + if (shuffledGaps.length > 0) { + selected.push(shuffledGaps.shift()); + } + addUntilFull(groups.multipleChoice, (seed ^ 0x39a1f8c7) >>> 0); + addUntilFull(shuffledGaps, (seed ^ 0x2d98c6f1) >>> 0); + addUntilFull(groups.concreteOther, (seed ^ 0x5bd1e995) >>> 0); + addUntilFull(groups.fallback, (seed ^ 0x7f4a7c15) >>> 0); + return selected; } /**