diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index d6548b0..3e05cbc 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -523,6 +523,8 @@ "exerciseWrongTitle": "Noch nicht richtig", "exerciseReinforcementGoPractice": "Zum Üben wechseln", "exerciseReinforcementStay": "Bei der Prüfung bleiben", + "exerciseReinforcementGoPracticeAck": "Gelesen, zum Üben wechseln", + "exerciseReinforcementStayAck": "Gelesen, bei der Prüfung bleiben", "exerciseStatusOpen": "Offen", "exerciseStatusCorrect": "Erledigt", "exerciseStatusRetry": "Nochmal prüfen", diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index b4cf391..d17f6d0 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -523,6 +523,8 @@ "exerciseWrongTitle": "Not quite right", "exerciseReinforcementGoPractice": "Go to practice", "exerciseReinforcementStay": "Stay on the test", + "exerciseReinforcementGoPracticeAck": "Read, go to practice", + "exerciseReinforcementStayAck": "Read, stay on the test", "exerciseStatusOpen": "Open", "exerciseStatusCorrect": "Done", "exerciseStatusRetry": "Try again", diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index 90628e1..b299f64 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -521,6 +521,8 @@ "exerciseWrongTitle": "Aún no es correcto", "exerciseReinforcementGoPractice": "Ir a practicar", "exerciseReinforcementStay": "Seguir en la prueba", + "exerciseReinforcementGoPracticeAck": "Leído, ir a practicar", + "exerciseReinforcementStayAck": "Leído, seguir en la prueba", "exerciseStatusOpen": "Pendiente", "exerciseStatusCorrect": "Hecha", "exerciseStatusRetry": "Revisar otra vez", diff --git a/frontend/src/views/social/VocabLessonReviewView.vue b/frontend/src/views/social/VocabLessonReviewView.vue index 4c27a6d..0c3f987 100644 --- a/frontend/src/views/social/VocabLessonReviewView.vue +++ b/frontend/src/views/social/VocabLessonReviewView.vue @@ -49,6 +49,14 @@ > {{ $t('socialnetwork.vocab.courses.checkAnswer') }} +

@@ -80,8 +88,10 @@ export default { typedAnswer: '', feedback: '', feedbackCorrect: false, + needsFeedbackAck: false, correctCount: 0, - reviewDone: false + reviewDone: false, + weakVocabMap: {} }; }, computed: { @@ -103,6 +113,9 @@ export default { normalize(s) { return String(s || '').trim().toLowerCase(); }, + getItemKey(item) { + return `${String(item?.gloss || '').trim()}|${String(item?.target || '').trim()}`; + }, parseCorePatterns() { const raw = this.lesson?.didactics?.corePatterns || []; const out = []; @@ -151,6 +164,7 @@ export default { this.feedback = ''; this.selectedOption = ''; this.typedAnswer = ''; + this.needsFeedbackAck = false; if (!this.currentItem) return; this.mode = this.currentItem.gloss ? 'multiple_choice' : 'typing'; if (this.mode === 'multiple_choice') { @@ -170,26 +184,85 @@ export default { if (isCorrect) { this.correctCount += 1; this.feedback = this.$t('socialnetwork.vocab.courses.correct'); + this.needsFeedbackAck = false; + window.setTimeout(() => { + this.advanceAfterFeedback(); + }, 550); } else { this.feedback = `${this.$t('socialnetwork.vocab.courses.wrong')} - ${this.$t('socialnetwork.vocab.courses.correctAnswer')}: ${this.mode === 'multiple_choice' ? this.currentItem.gloss : this.currentItem.target}`; + this.needsFeedbackAck = true; + const key = this.getItemKey(this.currentItem); + const existing = this.weakVocabMap[key] || { + learning: this.currentItem.gloss || '', + reference: this.currentItem.target || '', + wrongCount: 0, + lastWrongAt: '' + }; + existing.wrongCount += 1; + existing.lastWrongAt = new Date().toISOString(); + this.weakVocabMap[key] = existing; } - - window.setTimeout(async () => { - this.currentIndex += 1; - if (this.currentIndex >= this.reviewQueue.length) { - await this.finishReview(); - return; - } - this.setupCurrent(); - }, 500); + }, + async advanceAfterFeedback() { + this.currentIndex += 1; + if (this.currentIndex >= this.reviewQueue.length) { + await this.finishReview(); + return; + } + this.setupCurrent(); }, async finishReview() { this.reviewDone = true; try { + let mergedWeak = Object.values(this.weakVocabMap); + try { + const { data: progressList } = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`); + const existingProgress = Array.isArray(progressList) + ? progressList.find((p) => Number(p.lessonId) === Number(this.lessonId)) + : null; + const existingWeak = Array.isArray(existingProgress?.lessonState?.reviewWeakVocab) + ? existingProgress.lessonState.reviewWeakVocab + : []; + const map = new Map(); + existingWeak.forEach((entry) => { + const key = `${String(entry?.learning || '').trim()}|${String(entry?.reference || '').trim()}`; + if (!key) return; + map.set(key, { + learning: String(entry?.learning || '').trim(), + reference: String(entry?.reference || '').trim(), + wrongCount: Math.max(0, Number(entry?.wrongCount) || 0), + lastWrongAt: String(entry?.lastWrongAt || '') + }); + }); + mergedWeak.forEach((entry) => { + const key = `${String(entry?.learning || '').trim()}|${String(entry?.reference || '').trim()}`; + if (!key) return; + const prev = map.get(key); + if (!prev) { + map.set(key, entry); + } else { + map.set(key, { + learning: prev.learning || entry.learning, + reference: prev.reference || entry.reference, + wrongCount: Math.max(0, Number(prev.wrongCount) || 0) + Math.max(0, Number(entry.wrongCount) || 0), + lastWrongAt: entry.lastWrongAt || prev.lastWrongAt + }); + } + }); + mergedWeak = Array.from(map.values()) + .filter((entry) => entry.learning && entry.reference) + .sort((a, b) => (b.wrongCount || 0) - (a.wrongCount || 0)) + .slice(0, 40); + } catch (mergeErr) { + console.warn('Konnte bestehende Review-Schwachstellen nicht laden:', mergeErr); + } await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, { completed: true, score: 100, - timeSpentMinutes: 1 + timeSpentMinutes: 1, + lessonState: { + reviewWeakVocab: mergedWeak + } }); } catch (e) { console.error('Review-Fortschritt konnte nicht gespeichert werden:', e); diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 6cd71f2..b57002c 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -902,11 +902,10 @@ -

+
{{ $t('socialnetwork.vocab.courses.exerciseWrongTitle') }} -

@@ -917,10 +916,10 @@

@@ -1013,6 +1012,8 @@ export default { exerciseSequentialIndex: 0, /** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */ distractorPool: { target: [], native: [] }, + /** Fortschritt aller Kurslektionen inkl. lessonState für Spezial-Trainer-Boost */ + courseProgressList: [], /** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */ mcRandomizedOptions: {}, lessonStatePersistenceReady: false, @@ -2038,6 +2039,7 @@ export default { this.vocabTrainerPhase = 'current'; this.vocabTrainerCurrentAttempts = 0; this.vocabTrainerReviewAttempts = 0; + this.courseProgressList = []; this.distractorPool = { target: [], native: [] }; this.mcRandomizedOptions = {}; // Reset Flags @@ -2059,6 +2061,7 @@ export default { try { const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`); this.lesson = res.data; + await this.loadCourseProgressForBoost(); debugLog('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title); if (this.$route.query.assistant) { this.$nextTick(() => { @@ -2095,6 +2098,14 @@ export default { this.loading = false; } }, + async loadCourseProgressForBoost() { + try { + const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`); + this.courseProgressList = Array.isArray(data) ? data : []; + } catch (e) { + this.courseProgressList = []; + } + }, focusAssistantCard() { const target = this.$refs.assistantCard; if (!target || typeof target.scrollIntoView !== 'function') { @@ -2688,9 +2699,43 @@ export default { if (!this.previousVocab || this.previousVocab.length === 0) return []; const currentKeys = new Set(this.trainableLessonVocab.map(v => this.getVocabKey(v))); const filtered = this.previousVocab.filter(v => !currentKeys.has(this.getVocabKey(v))); - // Zufällig mischen und auf 40 begrenzen - const shuffled = [...filtered].sort(() => Math.random() - 0.5); - return shuffled.slice(0, 40); + const filteredByKey = new Map(filtered.map((v) => [this.getVocabKey(v), v])); + + const weakMap = new Map(); + (this.courseProgressList || []).forEach((entry) => { + const lessonId = Number(entry?.lessonId); + if (!Number.isFinite(lessonId) || lessonId === Number(this.lessonId)) return; + const weakList = Array.isArray(entry?.lessonState?.reviewWeakVocab) + ? entry.lessonState.reviewWeakVocab + : []; + weakList.forEach((w) => { + const learning = String(w?.learning || '').trim(); + const reference = String(w?.reference || '').trim(); + if (!learning || !reference) return; + const key = `${learning}|${reference}`; + if (currentKeys.has(key) || !filteredByKey.has(key)) return; + const prev = weakMap.get(key) || 0; + weakMap.set(key, prev + Math.max(1, Number(w?.wrongCount) || 1)); + }); + }); + + const boosted = []; + Array.from(weakMap.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .forEach(([key, score]) => { + const vocab = filteredByKey.get(key); + if (!vocab) return; + const weight = Math.min(4, Math.max(1, Math.round(score))); + for (let i = 0; i < weight; i++) { + boosted.push(vocab); + } + }); + + const boostedKeySet = new Set(Array.from(weakMap.keys())); + const rest = filtered.filter((v) => !boostedKeySet.has(this.getVocabKey(v))); + const shuffled = [...rest].sort(() => Math.random() - 0.5); + return [...boosted, ...shuffled].slice(0, 40); }, getVocabKey(vocab) { return `${vocab.learning}|${vocab.reference}`;