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

@@ -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;
}
/**