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
All checks were successful
Deploy to production / deploy (push) Successful in 3m21s
This commit is contained in:
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user