feat(exercises): enhance context in gap fill exercises and update migration for route descriptions
All checks were successful
Deploy to production / deploy (push) Successful in 3m21s

This commit is contained in:
Torsten Schulz (local)
2026-09-02 13:52:26 +02:00
parent 76da586b8e
commit 504f527a3f
3 changed files with 111 additions and 8 deletions

View File

@@ -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.
}
};

View File

@@ -599,7 +599,8 @@ function buildGapExercise(lessonTitle, pattern) {
function buildContextGapExercise(lesson, didactics, pattern) { function buildContextGapExercise(lesson, didactics, pattern) {
const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null; 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 // A contextual prompt must test a pattern that actually occurs in its
// scenario. Otherwise a cultural keyword such as "respeto" can be paired // scenario. Otherwise a cultural keyword such as "respeto" can be paired
// with an invitation-declining situation. // with an invitation-declining situation.
@@ -608,14 +609,21 @@ function buildContextGapExercise(lesson, didactics, pattern) {
.find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase())); .find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase()));
// Do not hide a complete sentence behind one blank. Learners need enough // 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. // visible context to know which word or short phrase belongs in the gap.
const gapExercise = scenarioPattern const scenarioSteps = scenarioCue
? buildGapExercise(lesson.title, scenarioPattern) .split(/[.!?]+/)
: buildGapExercise(lesson.title, pattern); .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; if (!gapExercise) return null;
return { return {
...gapExercise, ...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)}` 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.', instruction: 'Ordne die Wörter zu einem korrekten Bisaya-Satz.',
questionData: { questionData: {
type: 'sentence_building', type: 'sentence_building',
question: `Baue das Kernmuster aus der Lektion "${lessonTitle}".`, question: 'Ordne diese Wörter zu einem vollständigen Bisaya-Satz.',
tokens tokens
}, },
answerData: { answerData: {

View File

@@ -3617,7 +3617,7 @@ export default class VocabService {
const seed = (Number(lessonId) * 100003) >>> 0; const seed = (Number(lessonId) * 100003) >>> 0;
const percentage = 40 + (seed % 21); const percentage = 40 + (seed % 21);
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100)); 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) { _selectCheckpointExamExercises(exercises = [], lessonId) {
@@ -3628,7 +3628,73 @@ export default class VocabService {
// Checkpoints: smaller sample ~10-30% // Checkpoints: smaller sample ~10-30%
const percentage = 10 + (seed % 21); const percentage = 10 + (seed % 21);
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100)); 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;
} }
/** /**