diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/update-bisaya-didactics.js b/backend/scripts/update-bisaya-didactics.js index 4efebdb..4f0b135 100644 --- a/backend/scripts/update-bisaya-didactics.js +++ b/backend/scripts/update-bisaya-didactics.js @@ -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' }, diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 28efc88..8540d6e 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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()) }; } diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue index 8073ec0..6025589 100644 --- a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -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; diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 4c2dd67..bb64d55 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -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;