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) {
|
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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user