feat(vocabService, VocabPracticeDialog, VocabLessonView): enhance vocabulary handling and exposure tracking
All checks were successful
Deploy to production / deploy (push) Successful in 2m51s

- Updated vocabService to merge extracted vocabularies and improve handling of learning and reference pairs.
- Introduced normalization and exposure tracking in VocabPracticeDialog to ensure diverse and underexposed vocabulary practice.
- Enhanced VocabLessonView with methods to identify underexposed vocabularies and adjust selection logic for improved learning outcomes.
- Implemented new constants for minimum exposure requirements to optimize vocabulary training sessions.
This commit is contained in:
Torsten Schulz (local)
2026-04-17 08:58:50 +02:00
parent d119869750
commit 54a77c2e08
5 changed files with 139 additions and 14 deletions

0
.codex Normal file
View File

View File

@@ -321,17 +321,17 @@ export const LESSON_DIDACTICS = {
{ target: 'Duha', gloss: 'zwei' },
{ target: 'Tulo', gloss: 'drei' },
{ target: 'Upat', gloss: 'vier' },
{ target: 'Lima', gloss: 'fuenf' },
{ target: 'Lima', gloss: 'fünf' },
{ target: 'Unom', gloss: 'sechs' },
{ target: 'Pito', gloss: 'sieben' },
{ target: 'Walo', gloss: 'acht' },
{ target: 'Siyam', gloss: 'neun' },
{ target: 'Napulo', gloss: 'zehn' },
{ target: 'Napulo ug usa', gloss: 'elf' },
{ target: 'Napulo ug duha', gloss: 'zwoelf' },
{ target: 'Napulo ug duha', gloss: 'zwölf' },
{ target: 'Napulo ug tulo', gloss: 'dreizehn' },
{ target: 'Napulo ug upat', gloss: 'vierzehn' },
{ target: 'Napulo ug lima', gloss: 'fuenfzehn' },
{ target: 'Napulo ug lima', gloss: 'fünfzehn' },
{ target: 'Napulo ug unom', gloss: 'sechzehn' },
{ target: 'Napulo ug pito', gloss: 'siebzehn' },
{ target: 'Napulo ug walo', gloss: 'achtzehn' },

View File

@@ -614,6 +614,14 @@ export default class VocabService {
const practicalTasks = Array.isArray(lesson?.practicalTasks) ? lesson.practicalTasks : [];
const corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : [];
corePatterns.forEach((entry) => {
const pattern = this._normalizeCorePatternEntry(entry);
const reference = String(pattern?.target || '').trim();
const learning = String(pattern?.gloss || '').trim();
if (!learning || !reference || learning === reference) return;
vocabMap.set(`${learning}-${reference}`, { learning, reference });
});
speakingPrompts.forEach((prompt, index) => {
const learning = String(prompt?.prompt || prompt?.title || '').trim();
const refEntry = corePatterns[index] ?? corePatterns[0];
@@ -1488,9 +1496,12 @@ export default class VocabService {
const extractedFromExercises = this._extractTrainerVocabsFromExercises(
(lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true }))
);
const vocabs = extractedFromExercises.length > 0
? extractedFromExercises
: this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
const fallbackVocabs = this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
const mergedVocabs = new Map();
[...fallbackVocabs, ...extractedFromExercises].forEach((entry) => {
if (!entry?.learning || !entry?.reference) return;
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
});
return {
lesson: {
@@ -1499,7 +1510,7 @@ export default class VocabService {
courseId: lesson.courseId,
courseTitle: lesson.course.title
},
vocabs
vocabs: Array.from(mergedVocabs.values())
};
}

View File

@@ -105,6 +105,8 @@
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
const PRACTICE_MIN_EXPOSURES = 3;
export default {
name: 'VocabPracticeDialog',
components: { DialogWidget },
@@ -233,6 +235,29 @@ export default {
.trim();
return normalized.replace(/\s+/g, '');
},
normalizePool(items = []) {
const seen = new Set();
return (Array.isArray(items) ? items : [])
.map((item, index) => {
const learning = String(item?.learning || '').trim();
const reference = String(item?.reference || '').trim();
if (!learning || !reference || this.normalize(learning) === this.normalize(reference)) {
return null;
}
const key = `${this.normalize(learning)}|${this.normalize(reference)}`;
if (seen.has(key)) {
return null;
}
seen.add(key);
return {
...item,
id: item?.id || item?.key || `${key}|${index}`,
learning,
reference
};
})
.filter(Boolean);
},
resetQuestion() {
this.current = null;
this.direction = this.openParams?.lessonId ? 'L2R' : (Math.random() < 0.5 ? 'L2R' : 'R2L');
@@ -283,17 +308,17 @@ export default {
untilLessonId: this.openParams.lessonId
}
});
this.pool = res.data?.vocabs || [];
this.pool = this.normalizePool(res.data?.vocabs || []);
} else {
res = await apiClient.get(`/api/vocab/lessons/${this.openParams.lessonId}/vocab-pool`);
this.pool = res.data?.vocabs || [];
this.pool = this.normalizePool(res.data?.vocabs || []);
}
} else if (this.allVocabs) {
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
this.pool = res.data?.vocabs || [];
this.pool = this.normalizePool(res.data?.vocabs || []);
} else {
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
this.pool = res.data?.vocabs || [];
this.pool = this.normalizePool(res.data?.vocabs || []);
}
} catch (e) {
console.error('Reload pool failed:', e);
@@ -331,6 +356,26 @@ export default {
pickNextItem() {
const items = this.pool;
if (!items || items.length === 0) return null;
const recent = new Set(this.lastIds);
const underexposed = items
.map((item) => {
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
return {
item,
attempts: (Number(st.c) || 0) + (Number(st.w) || 0),
wrong: Number(st.w) || 0
};
})
.filter((entry) => entry.attempts < PRACTICE_MIN_EXPOSURES && !recent.has(entry.item.id))
.sort((a, b) => {
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
return String(a.item.id).localeCompare(String(b.item.id));
});
if (underexposed.length > 0) {
return underexposed[0].item;
}
const weights = items.map((it) => this.computeWeight(it));
const sum = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * sum;

View File

@@ -1001,6 +1001,7 @@ import apiClient from '@/utils/axios.js';
const debugLog = () => {};
const LESSON_STATE_VERSION = 1;
const VOCAB_REPEAT_INTERVALS = [1, 2, 4];
const VOCAB_MIN_CURRENT_EXPOSURES = 3;
/** Mindest-Erfolgsquote im Vokabeltrainer (gesamt), damit die Kapitel-Prüfung freigeschaltet wird. */
const EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT = 70;
/** Max. Zeilen pro Seite in der einklappbaren Vokabel-Gesamtübersicht */
@@ -1123,9 +1124,20 @@ export default {
const vocabCount = this.trainableLessonVocab?.length || 0;
const exerciseCount = this.effectiveExercises?.length || 0;
const durationBonus = Math.max(0, Math.round((this.lesson?.targetMinutes || 0) / 5) - 1);
const coverageTarget = vocabCount * this.trainerMinimumCurrentExposures;
const baseTarget = Math.ceil((Math.max(vocabCount, 4) * 1.35) + (exerciseCount * 0.35) + durationBonus);
const weightedTarget = Math.ceil(baseTarget * this.lessonComplexityWeight);
return Math.max(6, Math.min(120, weightedTarget));
return Math.max(6, Math.min(160, Math.max(weightedTarget, coverageTarget)));
},
trainerMinimumCurrentExposures() {
const mode = this.lessonPedagogy?.didacticMode || this.lesson?.lessonType || '';
if (mode === 'intensive_review' || mode === 'review' || mode === 'vocab_review') {
return 2;
}
if (['grammar', 'dialogue', 'phrases', 'survival'].includes(this.lesson?.lessonType)) {
return 4;
}
return VOCAB_MIN_CURRENT_EXPOSURES;
},
trainerReviewBlendStart() {
return Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.4));
@@ -3270,6 +3282,53 @@ export default {
.map((entry) => entry.key)
);
},
getUnderexposedCurrentVocabs(pool = this.trainableLessonVocab) {
const uniqueByKey = new Map();
(pool || []).forEach((vocab) => {
const key = this.getVocabKey(vocab);
if (!uniqueByKey.has(key)) {
uniqueByKey.set(key, vocab);
}
});
const underexposed = Array.from(uniqueByKey.values())
.map((vocab) => {
const stats = this.getVocabStats(vocab);
return {
vocab,
attempts: Number(stats.attempts) || 0,
wrong: Number(stats.wrong) || 0
};
})
.filter((entry) => entry.attempts < this.trainerMinimumCurrentExposures)
.sort((a, b) => {
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
return this.getVocabKey(a.vocab).localeCompare(this.getVocabKey(b.vocab));
});
return underexposed.map((entry) => entry.vocab);
},
chooseVocabFromPool(pool = []) {
if (!pool.length) return null;
const ranked = pool
.map((vocab) => {
const stats = this.getVocabStats(vocab);
return {
vocab,
attempts: Number(stats.attempts) || 0,
wrong: Number(stats.wrong) || 0,
correct: Number(stats.correct) || 0
};
})
.sort((a, b) => {
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
if (a.correct !== b.correct) return a.correct - b.correct;
return Math.random() - 0.5;
});
return ranked[0]?.vocab || pool[Math.floor(Math.random() * pool.length)];
},
continueAfterVocabAnswer() {
const completedKey = this.currentVocabQuestion?.key || '';
this.advanceRepeatQueue(completedKey);
@@ -3461,8 +3520,18 @@ export default {
}
}
const randomIndex = Math.floor(Math.random() * sourcePool.length);
const vocab = sourcePool[randomIndex];
if (!dueRepeatVocab && questionSource === 'current' && this.vocabTrainerMode === 'multiple_choice') {
const underexposed = this.getUnderexposedCurrentVocabs(sourcePool);
if (underexposed.length > 0) {
sourcePool = underexposed;
}
}
const vocab = this.chooseVocabFromPool(sourcePool);
if (!vocab) {
this.currentVocabQuestion = null;
return;
}
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
const prompt = this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference;